fix(v2): atomically record execution mutations

This commit is contained in:
2026-07-28 10:45:23 +02:00
parent d7d40dc645
commit c558801aac
2 changed files with 47 additions and 39 deletions
+29 -31
View File
@@ -54,9 +54,9 @@ async def enqueue(db: AsyncSession, job_id: str, trigger: str = "manual") -> Exe
job_identifier = job.id job_identifier = job.id
execution = Execution(job_id=job_identifier, trigger=trigger, progress={}) execution = Execution(job_id=job_identifier, trigger=trigger, progress={})
db.add(execution) db.add(execution)
try:
await db.flush() await db.flush()
await record_event(db, execution) await record_event(db, execution)
try:
await db.commit() await db.commit()
except IntegrityError as error: except IntegrityError as error:
await db.rollback() await db.rollback()
@@ -69,8 +69,6 @@ async def enqueue(db: AsyncSession, job_id: str, trigger: str = "manual") -> Exe
"execution_active", "Job already has an active execution.", existing "execution_active", "Job already has an active execution.", existing
) from error ) from error
await db.refresh(execution) await db.refresh(execution)
await record_event(db, execution)
await db.commit()
return execution return execution
@@ -92,7 +90,6 @@ async def claim(
if getattr(result, "rowcount", 0) != 1: if getattr(result, "rowcount", 0) != 1:
await db.rollback() await db.rollback()
return None return None
await db.commit()
execution = await db.get(Execution, execution_id) execution = await db.get(Execution, execution_id)
if execution is not None: if execution is not None:
await record_event(db, execution) await record_event(db, execution)
@@ -135,7 +132,6 @@ async def request_cancellation(db: AsyncSession, execution_id: str) -> Execution
if getattr(result, "rowcount", 0) != 1: if getattr(result, "rowcount", 0) != 1:
await db.rollback() await db.rollback()
return None return None
await db.commit()
execution = await db.get(Execution, execution_id) execution = await db.get(Execution, execution_id)
if execution is not None: if execution is not None:
await record_event(db, execution) await record_event(db, execution)
@@ -159,8 +155,14 @@ async def complete_cancellation(db: AsyncSession, execution_id: str, owner: str)
lease_expires_at=None, lease_expires_at=None,
) )
) )
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return False
execution = await db.get(Execution, execution_id)
if execution is not None:
await record_event(db, execution)
await db.commit() await db.commit()
return getattr(result, "rowcount", 0) == 1 return True
async def retry(db: AsyncSession, execution_id: str) -> Execution | None: async def retry(db: AsyncSession, execution_id: str) -> Execution | None:
@@ -176,6 +178,7 @@ async def retry(db: AsyncSession, execution_id: str) -> Execution | None:
execution.heartbeat_at = None execution.heartbeat_at = None
execution.reason_code = None execution.reason_code = None
execution.operator_message = None execution.operator_message = None
await record_event(db, execution)
try: try:
await db.commit() await db.commit()
except IntegrityError as error: except IntegrityError as error:
@@ -189,8 +192,6 @@ async def retry(db: AsyncSession, execution_id: str) -> Execution | None:
"execution_active", "Job already has an active execution.", active_id "execution_active", "Job already has an active execution.", active_id
) from error ) from error
await db.refresh(execution) await db.refresh(execution)
await record_event(db, execution)
await db.commit()
return execution return execution
@@ -237,32 +238,29 @@ def public_event(execution: Execution) -> dict[str, object]:
async def recover_stale(db: AsyncSession) -> int: async def recover_stale(db: AsyncSession) -> int:
"""Atomically recover expired executions and append one event per mutation."""
now = datetime.now(UTC) now = datetime.now(UTC)
cancelled = await db.execute( executions = list(
update(Execution) (
.where(Execution.state == "cancelling", Execution.lease_expires_at < now) await db.scalars(
.values( select(Execution).where(
state="cancelled", Execution.state.in_({"preparing", "running", "verifying", "cancelling"}),
completed_at=now,
lease_owner=None,
lease_expires_at=None,
heartbeat_at=None,
reason_code="cancellation_requested",
)
)
recovered = await db.execute(
update(Execution)
.where(
Execution.state.in_({"preparing", "running", "verifying"}),
Execution.lease_expires_at < now, Execution.lease_expires_at < now,
) )
.values(
state="queued",
lease_owner=None,
lease_expires_at=None,
heartbeat_at=None,
reason_code="worker_lost",
) )
).all()
) )
for execution in executions:
if execution.state == "cancelling":
execution.state = "cancelled"
execution.completed_at = now
execution.reason_code = "cancellation_requested"
else:
execution.state = "queued"
execution.reason_code = "worker_lost"
execution.lease_owner = None
execution.lease_expires_at = None
execution.heartbeat_at = None
await record_event(db, execution)
await db.commit() await db.commit()
return (getattr(cancelled, "rowcount", 0) or 0) + (getattr(recovered, "rowcount", 0) or 0) return len(executions)
+12 -2
View File
@@ -17,7 +17,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker
from backup_tool.config import Settings from backup_tool.config import Settings
from backup_tool.db.engine import create_engine from backup_tool.db.engine import create_engine
from backup_tool.db.models import Execution from backup_tool.db.models import Execution
from backup_tool.execution import claim, complete_cancellation, heartbeat, recover_stale, transition from backup_tool.execution import (
claim,
complete_cancellation,
heartbeat,
record_event,
recover_stale,
transition,
)
class Worker: class Worker:
@@ -51,7 +58,7 @@ class Worker:
await complete_cancellation(db, execution.id, self.owner) await complete_cancellation(db, execution.id, self.owner)
return True return True
now = datetime.now(UTC) now = datetime.now(UTC)
await db.execute( result = await db.execute(
update(Execution) update(Execution)
.where( .where(
Execution.id == execution.id, Execution.id == execution.id,
@@ -61,6 +68,9 @@ class Worker:
) )
.values(state=transition("preparing", "running"), started_at=now) .values(state=transition("preparing", "running"), started_at=now)
) )
if getattr(result, "rowcount", 0) == 1:
await db.refresh(execution)
await record_event(db, execution)
await db.commit() await db.commit()
await heartbeat(db, execution.id, self.owner) await heartbeat(db, execution.id, self.owner)
return True return True