fix(v2): fence execution leases and redact progress

This commit is contained in:
2026-07-27 22:01:33 +02:00
parent f1651c18ad
commit 3f1136b99e
2 changed files with 28 additions and 11 deletions
+18 -7
View File
@@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.db.models import Execution, Job
from backup_tool.security.redaction import redact
ACTIVE_STATES = frozenset({"queued", "preparing", "running", "verifying", "cancelling"})
TERMINAL_STATES = frozenset({"committed", "cancelled", "failed"})
@@ -101,6 +102,7 @@ async def heartbeat(
Execution.id == execution_id,
Execution.lease_owner == owner,
Execution.state.in_(ACTIVE_STATES),
Execution.lease_expires_at >= now,
)
.values(heartbeat_at=now, lease_expires_at=now + timedelta(seconds=lease_seconds))
)
@@ -109,14 +111,19 @@ async def heartbeat(
async def request_cancellation(db: AsyncSession, execution_id: str) -> Execution | None:
# Queued work has no worker and may terminate immediately; leased work asks its owner.
now = datetime.now(UTC)
result = await db.execute(
update(Execution)
.where(
Execution.id == execution_id,
Execution.state.in_({"queued", "preparing", "running"}),
)
.values(state="cancelling", reason_code="cancellation_requested")
.where(Execution.id == execution_id, Execution.state == "queued")
.values(state="cancelled", reason_code="cancellation_requested", completed_at=now)
)
if getattr(result, "rowcount", 0) != 1:
result = await db.execute(
update(Execution)
.where(Execution.id == execution_id, Execution.state.in_({"preparing", "running"}))
.values(state="cancelling", reason_code="cancellation_requested")
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return None
@@ -130,6 +137,7 @@ async def complete_cancellation(db: AsyncSession, execution_id: str, owner: str)
.where(
Execution.id == execution_id,
Execution.lease_owner == owner,
Execution.lease_expires_at >= datetime.now(UTC),
Execution.state == "cancelling",
)
.values(
@@ -163,7 +171,7 @@ async def retry(db: AsyncSession, execution_id: str) -> Execution | None:
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"}}
progress = redact(execution.progress)
return {
"id": execution.id,
"state": execution.state,
@@ -177,7 +185,10 @@ async def recover_stale(db: AsyncSession) -> int:
now = datetime.now(UTC)
result = await db.execute(
update(Execution)
.where(Execution.state.in_({"preparing", "running"}), Execution.lease_expires_at < now)
.where(
Execution.state.in_({"preparing", "running", "verifying", "cancelling"}),
Execution.lease_expires_at < now,
)
.values(
state="queued",
lease_owner=None,