feat(v2): expose execution control and event APIs

This commit is contained in:
2026-07-27 21:54:05 +02:00
parent 057895f138
commit 474efa526f
3 changed files with 162 additions and 4 deletions
+66
View File
@@ -11,6 +11,7 @@ from backup_tool.db.models import Execution, Job
ACTIVE_STATES = frozenset({"queued", "preparing", "running", "verifying", "cancelling"})
TERMINAL_STATES = frozenset({"committed", "cancelled", "failed"})
TRANSIENT_REASONS = frozenset({"worker_lost", "timeout", "unavailable", "transient_io"})
_ALLOWED: Mapping[str, frozenset[str]] = {
"queued": frozenset({"preparing", "cancelled", "failed"}),
@@ -107,6 +108,71 @@ async def heartbeat(
return getattr(result, "rowcount", 0) == 1
async def request_cancellation(db: AsyncSession, execution_id: str) -> Execution | None:
result = await db.execute(
update(Execution)
.where(
Execution.id == execution_id,
Execution.state.in_({"queued", "preparing", "running"}),
)
.values(state="cancelling", reason_code="cancellation_requested")
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return None
await db.commit()
return await db.get(Execution, execution_id)
async def complete_cancellation(db: AsyncSession, execution_id: str, owner: str) -> bool:
result = await db.execute(
update(Execution)
.where(
Execution.id == execution_id,
Execution.lease_owner == owner,
Execution.state == "cancelling",
)
.values(
state="cancelled",
completed_at=datetime.now(UTC),
lease_owner=None,
lease_expires_at=None,
)
)
await db.commit()
return getattr(result, "rowcount", 0) == 1
async def retry(db: AsyncSession, execution_id: str) -> Execution | None:
execution = await db.get(Execution, execution_id)
if execution is None:
return None
if execution.state != "failed" or execution.reason_code not in TRANSIENT_REASONS:
raise EnqueueError("retry_not_allowed", "Execution failure is not retryable.")
execution.state = "queued"
execution.attempt += 1
execution.lease_owner = None
execution.lease_expires_at = None
execution.heartbeat_at = None
execution.reason_code = None
execution.operator_message = None
await db.commit()
await db.refresh(execution)
return execution
def public_event(execution: Execution) -> dict[str, object]:
"""Return redacted progress suitable for polling or SSE."""
progress = {k: v for k, v in execution.progress.items() if k not in {"path", "secret", "token"}}
return {
"id": execution.id,
"state": execution.state,
"attempt": execution.attempt,
"reason_code": execution.reason_code,
"progress": progress,
}
async def recover_stale(db: AsyncSession) -> int:
now = datetime.now(UTC)
result = await db.execute(