feat(v2): add durable worker lifecycle service

This commit is contained in:
2026-07-27 22:04:02 +02:00
parent 3f1136b99e
commit 8c07c43ed2
+92
View File
@@ -0,0 +1,92 @@
"""Single-node durable worker role.
The worker owns leases; backup publishing is deliberately supplied by later M6 work.
"""
from __future__ import annotations
import asyncio
import signal
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Execution
from backup_tool.execution import claim, complete_cancellation, heartbeat, recover_stale, transition
class Worker:
def __init__(self, settings: Settings, *, owner: str | None = None) -> None:
self.settings = settings
self.owner = owner or f"worker-{uuid4()}"
self._stopping = asyncio.Event()
self.engine = create_engine(settings)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
async def startup(self) -> int:
async with self.sessions() as db:
return await recover_stale(db)
async def run_once(self) -> bool:
async with self.sessions() as db:
execution_id = await db.scalar(
select(Execution.id)
.where(Execution.state == "queued")
.order_by(Execution.created_at)
.limit(1)
)
if execution_id is None:
return False
execution = await claim(db, execution_id, self.owner)
if execution is None:
return False
# M6 supplies source/repository work; this slice proves durable ownership.
if execution.state == "cancelling":
await complete_cancellation(db, execution.id, self.owner)
return True
now = datetime.now(UTC)
await db.execute(
update(Execution)
.where(
Execution.id == execution.id,
Execution.lease_owner == self.owner,
Execution.lease_expires_at >= now,
Execution.state == "preparing",
)
.values(state=transition("preparing", "running"), started_at=now)
)
await db.commit()
await heartbeat(db, execution.id, self.owner)
return True
async def run(self) -> None:
await self.startup()
while not self._stopping.is_set():
if not await self.run_once():
try:
await asyncio.wait_for(self._stopping.wait(), timeout=0.25)
except TimeoutError:
pass
await self.engine.dispose()
def stop(self) -> None:
self._stopping.set()
def run_worker(settings: Settings) -> int:
worker = Worker(settings)
loop = asyncio.new_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, worker.stop)
except NotImplementedError:
pass
try:
loop.run_until_complete(worker.run())
finally:
loop.close()
return 0