"""PostgreSQL repository for durable leased tasks.""" from __future__ import annotations import json import uuid from dataclasses import dataclass from typing import Any import asyncpg # type: ignore[import-untyped] # noqa: F401 from asyncpg import Pool, Record from app.db.primitives import ConflictError, require_affected, transaction @dataclass(frozen=True, slots=True) class Task: id: uuid.UUID upid: str task_type: str status: str payload: dict[str, Any] progress: int cancel_requested: bool attempt: int def _task(row: Record) -> Task: return Task( id=row["id"], upid=str(row["upid"]), task_type=str(row["task_type"]), status=str(row["status"]), payload=json.loads(row["payload"]) if isinstance(row["payload"], str) else dict(row["payload"]), progress=int(row["progress"]), cancel_requested=bool(row["cancel_requested"]), attempt=int(row["attempt"]), ) @dataclass(frozen=True, slots=True) class TaskRepository: pool: Pool async def create( self, *, upid: str, task_type: str, payload: dict[str, Any], resource_key: str | None = None, idempotency_key: str | None = None, ) -> Task: task_id = uuid.uuid4() async with transaction(self.pool) as connection: if idempotency_key is not None: existing = await connection.fetchrow( "SELECT * FROM tasks WHERE idempotency_key=$1", idempotency_key ) if existing is not None: return _task(existing) row = await connection.fetchrow( """INSERT INTO tasks(id, upid, task_type, status, payload, idempotency_key) VALUES($1,$2,$3,'queued',$4::jsonb,$5) RETURNING *""", task_id, upid, task_type, json.dumps(payload), idempotency_key, ) if resource_key is not None: try: await connection.execute( "INSERT INTO resource_locks(resource_key, task_id) VALUES($1,$2)", resource_key, task_id, ) except Exception as error: raise ConflictError(f"resource is locked: {resource_key}") from error await connection.execute( "INSERT INTO task_events(task_id, kind) VALUES($1,'created')", task_id ) if row is None: raise RuntimeError("task insert returned no row") return _task(row) async def claim(self, worker_id: str, lease_seconds: float) -> Task | None: async with transaction(self.pool) as connection: row = await connection.fetchrow( """WITH candidate AS ( SELECT id FROM tasks WHERE status='queued' OR (status='running' AND lease_expires_at < now()) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1 ) UPDATE tasks SET status='running', worker_id=$1, lease_expires_at=now() + $2 * interval '1 second', attempt=attempt+1, updated_at=now() WHERE id=(SELECT id FROM candidate) RETURNING *""", worker_id, lease_seconds, ) if row is None: return None await connection.execute( "INSERT INTO task_events(task_id, kind, data) VALUES($1,'claimed',$2::jsonb)", row["id"], json.dumps({"worker": worker_id}), ) return _task(row) async def heartbeat(self, task_id: uuid.UUID, worker_id: str, lease_seconds: float) -> None: status = await self.pool.execute( """UPDATE tasks SET lease_expires_at=now()+$3*interval '1 second', updated_at=now() WHERE id=$1 AND worker_id=$2 AND status='running'""", task_id, worker_id, lease_seconds, ) require_affected(status) async def progress(self, task_id: uuid.UUID, worker_id: str, value: int) -> None: status = await self.pool.execute( """UPDATE tasks SET progress=$3, updated_at=now() WHERE id=$1 AND worker_id=$2 AND status='running'""", task_id, worker_id, value, ) require_affected(status) async def append_log(self, task_id: uuid.UUID, message: str) -> None: await self.pool.execute( "INSERT INTO task_logs(task_id, message) VALUES($1,$2)", task_id, message ) async def request_cancel(self, task_id: uuid.UUID) -> None: status = await self.pool.execute( """UPDATE tasks SET cancel_requested=true, updated_at=now() WHERE id=$1 AND status IN ('queued','running')""", task_id, ) require_affected(status) async def finish( self, task_id: uuid.UUID, worker_id: str, *, status: str, result: dict[str, Any] | None = None, error: str | None = None, ) -> None: if status not in {"success", "error", "cancelled"}: raise ValueError("invalid terminal task status") async with transaction(self.pool) as connection: command = await connection.execute( """UPDATE tasks SET status=$3, result=$4::jsonb, error=$5, progress=CASE WHEN $3='success' THEN 100 ELSE progress END, lease_expires_at=NULL, updated_at=now() WHERE id=$1 AND worker_id=$2 AND status='running'""", task_id, worker_id, status, json.dumps(result) if result is not None else None, error, ) require_affected(command) await connection.execute("DELETE FROM resource_locks WHERE task_id=$1", task_id) await connection.execute( "INSERT INTO task_events(task_id, kind, data) VALUES($1,$2,$3::jsonb)", task_id, status, json.dumps({"error": error} if error else {}), ) async def get(self, task_id: uuid.UUID) -> Task | None: row = await self.pool.fetchrow("SELECT * FROM tasks WHERE id=$1", task_id) return _task(row) if row is not None else None async def get_by_upid(self, upid: str) -> Task | None: row = await self.pool.fetchrow("SELECT * FROM tasks WHERE upid=$1", upid) return _task(row) if row is not None else None async def list_for_node(self, node: str) -> tuple[Task, ...]: rows = await self.pool.fetch( """SELECT * FROM tasks WHERE payload->>'node'=$1 ORDER BY created_at DESC LIMIT 1000""", node, ) return tuple(_task(row) for row in rows) async def logs(self, task_id: uuid.UUID) -> tuple[str, ...]: rows = await self.pool.fetch( "SELECT message FROM task_logs WHERE task_id=$1 ORDER BY sequence", task_id ) return tuple(str(row["message"]) for row in rows)