feat(v2): enqueue and lease durable executions
This commit is contained in:
@@ -29,6 +29,7 @@ from backup_tool.db.models import (
|
|||||||
Source,
|
Source,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from backup_tool.execution import EnqueueError, enqueue
|
||||||
from backup_tool.repository import (
|
from backup_tool.repository import (
|
||||||
RepositoryError,
|
RepositoryError,
|
||||||
initialize,
|
initialize,
|
||||||
@@ -750,6 +751,32 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
"state": job.state,
|
"state": job.state,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.post("/api/v2/jobs/{job_id}/executions", status_code=202)
|
||||||
|
async def enqueue_execution(
|
||||||
|
job_id: str,
|
||||||
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
|
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
execution = await enqueue(db, job_id)
|
||||||
|
except EnqueueError as error:
|
||||||
|
if error.code == "execution_active":
|
||||||
|
raise Problem(
|
||||||
|
409,
|
||||||
|
"execution_active",
|
||||||
|
f"Job already has active execution {error.active_execution_id}.",
|
||||||
|
) from error
|
||||||
|
if error.code == "job_disabled":
|
||||||
|
raise Problem(409, "job_disabled", str(error)) from error
|
||||||
|
raise Problem(404, "resource_not_found", str(error)) from error
|
||||||
|
return {
|
||||||
|
"id": execution.id,
|
||||||
|
"job_id": execution.job_id,
|
||||||
|
"state": execution.state,
|
||||||
|
"trigger": execution.trigger,
|
||||||
|
"attempt": execution.attempt,
|
||||||
|
}
|
||||||
|
|
||||||
@app.get("/api/v2/audit")
|
@app.get("/api/v2/audit")
|
||||||
async def list_audit(
|
async def list_audit(
|
||||||
db: Annotated[AsyncSession, Depends(session)],
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backup_tool.db.models import Execution, Job
|
||||||
|
|
||||||
ACTIVE_STATES = frozenset({"queued", "preparing", "running", "verifying", "cancelling"})
|
ACTIVE_STATES = frozenset({"queued", "preparing", "running", "verifying", "cancelling"})
|
||||||
TERMINAL_STATES = frozenset({"committed", "cancelled", "failed"})
|
TERMINAL_STATES = frozenset({"committed", "cancelled", "failed"})
|
||||||
@@ -21,8 +28,97 @@ class TransitionError(ValueError):
|
|||||||
code = "invalid_transition"
|
code = "invalid_transition"
|
||||||
|
|
||||||
|
|
||||||
|
class EnqueueError(ValueError):
|
||||||
|
def __init__(self, code: str, detail: str, active_execution_id: str | None = None):
|
||||||
|
super().__init__(detail)
|
||||||
|
self.code = code
|
||||||
|
self.active_execution_id = active_execution_id
|
||||||
|
|
||||||
|
|
||||||
def transition(current: str, target: str) -> str:
|
def transition(current: str, target: str) -> str:
|
||||||
"""Validate and return one durable execution-state transition."""
|
"""Validate and return one durable execution-state transition."""
|
||||||
if target not in _ALLOWED.get(current, frozenset()):
|
if target not in _ALLOWED.get(current, frozenset()):
|
||||||
raise TransitionError(f"Cannot transition execution from {current!r} to {target!r}.")
|
raise TransitionError(f"Cannot transition execution from {current!r} to {target!r}.")
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue(db: AsyncSession, job_id: str, trigger: str = "manual") -> Execution:
|
||||||
|
"""Create exactly one active execution for an enabled active job."""
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job is None:
|
||||||
|
raise EnqueueError("resource_not_found", "Job was not found.")
|
||||||
|
if job.state != "active" or not job.enabled:
|
||||||
|
raise EnqueueError("job_disabled", "Job is disabled or archived.")
|
||||||
|
job_identifier = job.id
|
||||||
|
execution = Execution(job_id=job_identifier, trigger=trigger, progress={})
|
||||||
|
db.add(execution)
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError as error:
|
||||||
|
await db.rollback()
|
||||||
|
existing = await db.scalar(
|
||||||
|
select(Execution.id).where(
|
||||||
|
Execution.job_id == job_identifier, Execution.state.in_(ACTIVE_STATES)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise EnqueueError(
|
||||||
|
"execution_active", "Job already has an active execution.", existing
|
||||||
|
) from error
|
||||||
|
await db.refresh(execution)
|
||||||
|
return execution
|
||||||
|
|
||||||
|
|
||||||
|
async def claim(
|
||||||
|
db: AsyncSession, execution_id: str, owner: str, lease_seconds: int = 60
|
||||||
|
) -> Execution | None:
|
||||||
|
"""Atomically lease a queued execution; a stale lease may be recovered."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
expires = now + timedelta(seconds=lease_seconds)
|
||||||
|
result = await db.execute(
|
||||||
|
update(Execution)
|
||||||
|
.where(
|
||||||
|
Execution.id == execution_id,
|
||||||
|
Execution.state.in_({"queued", "preparing"}),
|
||||||
|
(Execution.lease_expires_at.is_(None)) | (Execution.lease_expires_at < now),
|
||||||
|
)
|
||||||
|
.values(state="preparing", lease_owner=owner, lease_expires_at=expires, heartbeat_at=now)
|
||||||
|
)
|
||||||
|
if getattr(result, "rowcount", 0) != 1:
|
||||||
|
await db.rollback()
|
||||||
|
return None
|
||||||
|
await db.commit()
|
||||||
|
return await db.get(Execution, execution_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def heartbeat(
|
||||||
|
db: AsyncSession, execution_id: str, owner: str, lease_seconds: int = 60
|
||||||
|
) -> bool:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
result = await db.execute(
|
||||||
|
update(Execution)
|
||||||
|
.where(
|
||||||
|
Execution.id == execution_id,
|
||||||
|
Execution.lease_owner == owner,
|
||||||
|
Execution.state.in_(ACTIVE_STATES),
|
||||||
|
)
|
||||||
|
.values(heartbeat_at=now, lease_expires_at=now + timedelta(seconds=lease_seconds))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return getattr(result, "rowcount", 0) == 1
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
.values(
|
||||||
|
state="queued",
|
||||||
|
lease_owner=None,
|
||||||
|
lease_expires_at=None,
|
||||||
|
heartbeat_at=None,
|
||||||
|
reason_code="worker_lost",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return getattr(result, "rowcount", 0) or 0
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
|
|||||||
)
|
)
|
||||||
assert job.status_code == 201
|
assert job.status_code == 201
|
||||||
assert "destination_path" not in job.json()
|
assert "destination_path" not in job.json()
|
||||||
|
execution = await client.post(
|
||||||
|
f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers
|
||||||
|
)
|
||||||
|
assert execution.status_code == 202
|
||||||
|
assert execution.json()["state"] == "queued"
|
||||||
|
duplicate = await client.post(
|
||||||
|
f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers
|
||||||
|
)
|
||||||
|
assert duplicate.status_code == 409
|
||||||
|
assert duplicate.json()["code"] == "execution_active"
|
||||||
archived = await client.delete(f"/api/v2/sources/{source_id}", headers=headers)
|
archived = await client.delete(f"/api/v2/sources/{source_id}", headers=headers)
|
||||||
assert archived.status_code == 204
|
assert archived.status_code == 204
|
||||||
assert (
|
assert (
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from backup_tool.execution import ACTIVE_STATES, TERMINAL_STATES, TransitionError, transition
|
from backup_tool.execution import (
|
||||||
|
ACTIVE_STATES,
|
||||||
|
TERMINAL_STATES,
|
||||||
|
TransitionError,
|
||||||
|
transition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_transitions_are_monotonic_and_terminal_states_cannot_change() -> None:
|
def test_transitions_are_monotonic_and_terminal_states_cannot_change() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user