feat: allow unrestricted independent job concurrency
This commit is contained in:
@@ -43,6 +43,19 @@ from archive_control.v1 import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _job_id_for_command(command: control_pb2.Command) -> str:
|
||||
"""Return the durable job key for commands whose execution is serialized."""
|
||||
|
||||
payload = command.WhichOneof("payload")
|
||||
if payload == "assign_job":
|
||||
return command.assign_job.job.job_id
|
||||
if payload == "execute_step":
|
||||
return command.execute_step.job_id
|
||||
if payload == "cancel_job":
|
||||
return command.cancel_job.job_id
|
||||
raise ValueError(f"command {command.command_id} does not execute a job")
|
||||
|
||||
|
||||
class ArchiveClientDaemon:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -95,7 +108,10 @@ class ArchiveClientDaemon:
|
||||
self._lease = DatabaseLease(config.state_db)
|
||||
self._active_route_commands: set[str] = set()
|
||||
self._active_job_commands: set[str] = set()
|
||||
self._job_execution_lock = asyncio.Lock()
|
||||
# Commands for one job remain ordered locally, while unrelated jobs
|
||||
# may use the node's available qB/Syncthing/filesystem capacity in
|
||||
# parallel. The control daemon owns admission policy.
|
||||
self._job_execution_locks: dict[str, asyncio.Lock] = {}
|
||||
self.jobs = (
|
||||
ClientJobExecutor(
|
||||
client_id=config.client_id,
|
||||
@@ -560,7 +576,9 @@ class ArchiveClientDaemon:
|
||||
correlation_id: str,
|
||||
outbound: asyncio.Queue[str],
|
||||
) -> None:
|
||||
async with self._job_execution_lock:
|
||||
job_id = _job_id_for_command(command)
|
||||
lock = self._job_execution_locks.setdefault(job_id, asyncio.Lock())
|
||||
async with lock:
|
||||
await self._execute_job_command_locked(
|
||||
command, correlation_id, outbound
|
||||
)
|
||||
|
||||
@@ -86,6 +86,8 @@ class ClientJobExecutor:
|
||||
self.verification_timeout = verification_timeout
|
||||
self.free_space_reserve_bytes = free_space_reserve_bytes
|
||||
self._cancel_events: dict[str, threading.Event] = {}
|
||||
self._execution_locks: dict[str, threading.Lock] = {}
|
||||
self._execution_locks_guard = threading.Lock()
|
||||
|
||||
def request_cancel(self, job_id: str) -> None:
|
||||
self._cancel_events.setdefault(job_id, threading.Event()).set()
|
||||
@@ -115,6 +117,22 @@ class ClientJobExecutor:
|
||||
self,
|
||||
command: control_pb2.ExecuteStepCommand,
|
||||
event_callback: Callable[[control_pb2.JobEvent], None] | None = None,
|
||||
) -> list[control_pb2.JobEvent]:
|
||||
# asyncio cancellation of a connection-bound task cannot stop the
|
||||
# synchronous filesystem/qB operation already running in its worker
|
||||
# thread. A replay after reconnect therefore waits for that operation
|
||||
# and then reads its durable event journal instead of executing twice.
|
||||
with self._execution_lock(command.job_id):
|
||||
return self._execute_locked(command, event_callback)
|
||||
|
||||
def _execution_lock(self, job_id: str) -> threading.Lock:
|
||||
with self._execution_locks_guard:
|
||||
return self._execution_locks.setdefault(job_id, threading.Lock())
|
||||
|
||||
def _execute_locked(
|
||||
self,
|
||||
command: control_pb2.ExecuteStepCommand,
|
||||
event_callback: Callable[[control_pb2.JobEvent], None] | None = None,
|
||||
) -> list[control_pb2.JobEvent]:
|
||||
definition = self._definition(command.job_id)
|
||||
replay = self._replay(
|
||||
|
||||
Reference in New Issue
Block a user