Recover transient Syncthing and SQLite job failures
This commit is contained in:
+39
-1
@@ -12,7 +12,7 @@ from archive_clients.jobs import (
|
||||
JobExecutionError,
|
||||
_resource_fingerprint,
|
||||
)
|
||||
from archive_clients.syncthing import RouteSetupError
|
||||
from archive_clients.syncthing import RouteSetupError, SyncthingTransferStatus
|
||||
from archive_clients.resources import normalize_resource
|
||||
from archive_clients.state import ClientStore
|
||||
from archive_control.v1 import control_pb2, job_pb2
|
||||
@@ -39,6 +39,44 @@ class SlowRescanSyncthing(CompleteSyncthing):
|
||||
|
||||
|
||||
class ClientJobHappyPathTests(unittest.TestCase):
|
||||
def test_syncthing_api_outage_during_transfer_is_retried(self):
|
||||
"""A transient local REST outage must not terminally fail the job."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
store = ClientStore(root / "client.db")
|
||||
store.initialize()
|
||||
definition = job_pb2.JobDefinition(
|
||||
job_id=str(uuid4()),
|
||||
transfer={
|
||||
"source_client_id": "cache-1",
|
||||
"target_client_id": "archive-1",
|
||||
"route_id": "route-1",
|
||||
},
|
||||
)
|
||||
observer = Mock()
|
||||
observer.status.side_effect = [
|
||||
RouteSetupError("Syncthing API is unavailable"),
|
||||
SyncthingTransferStatus(1, 42, 42, True, 0),
|
||||
]
|
||||
executor = ClientJobExecutor(
|
||||
client_id="archive-1",
|
||||
qbittorrent=Mock(),
|
||||
store=store,
|
||||
qb_root=root,
|
||||
qb_api_root=Path("/downloads"),
|
||||
route_path=lambda _: root,
|
||||
syncthing_transport=Mock(),
|
||||
sparse_supported=True,
|
||||
poll_interval=0,
|
||||
)
|
||||
executor._observer = Mock(return_value=observer)
|
||||
progress = Mock()
|
||||
|
||||
executor._wait_for_syncthing(definition, progress)
|
||||
|
||||
self.assertEqual(observer.status.call_count, 2)
|
||||
progress.assert_called_once_with(1, 42, 42, "0 Syncthing items still needed")
|
||||
|
||||
def test_reconnect_replay_never_duplicates_any_transfer_step(self):
|
||||
for step in (
|
||||
job_pb2.JOB_STEP_KIND_SOURCE_STAGE,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -13,6 +14,40 @@ from archive_clients.state import (
|
||||
|
||||
|
||||
class ClientStoreTests(unittest.TestCase):
|
||||
def test_database_connections_serialize_local_writers(self):
|
||||
"""Concurrent jobs share one daemon DB without SQLite lock failures."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
database = Path(directory) / "state.db"
|
||||
first = ClientStore(database)
|
||||
second = ClientStore(database)
|
||||
first.initialize()
|
||||
barrier = threading.Barrier(2)
|
||||
failures: list[Exception] = []
|
||||
|
||||
def write(store, command_id):
|
||||
try:
|
||||
barrier.wait()
|
||||
store.accept_command(command_id, '{"kind":"job"}', '{"ok":true}')
|
||||
except Exception as exc: # pragma: no cover - assertion below
|
||||
failures.append(exc)
|
||||
|
||||
left = threading.Thread(target=write, args=(first, "command-1"))
|
||||
right = threading.Thread(target=write, args=(second, "command-2"))
|
||||
left.start()
|
||||
right.start()
|
||||
left.join(1)
|
||||
right.join(1)
|
||||
|
||||
self.assertFalse(left.is_alive())
|
||||
self.assertFalse(right.is_alive())
|
||||
self.assertEqual(failures, [])
|
||||
self.assertEqual(len(first.list_accepted_commands()), 2)
|
||||
with first._connect() as connection:
|
||||
self.assertEqual(
|
||||
connection.execute("PRAGMA busy_timeout").fetchone()[0],
|
||||
30000,
|
||||
)
|
||||
|
||||
def test_command_acceptance_is_durable_and_content_addressed(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
database = Path(directory) / "state.db"
|
||||
|
||||
Reference in New Issue
Block a user