466 lines
16 KiB
Python
466 lines
16 KiB
Python
"""Dedicated reliable outbox worker for automatic monthly publication."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import signal
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Mapping, Optional, Protocol, Sequence
|
|
|
|
from arr_database import controlled_connect
|
|
from monthly_reports.contracts import ErrorCode
|
|
from arr_web.processing_runtime import ObjectStoreRuntime, compose_object_store
|
|
from monthly_reports.publishing import OpenpyxlWorkbookBuilder, AtomicReportPublisher
|
|
from monthly_reports.repository import (
|
|
DatabaseConfig,
|
|
DerivedMonthlyRequest,
|
|
PostgresReportRepository,
|
|
RepositoryError,
|
|
)
|
|
from monthly_reports.service import MonthlyReportService, RunRequest, RunResult
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
EVENT_TYPE = "arr.daily_version_committed"
|
|
DEFAULT_LEASE_SECONDS = 300
|
|
DEFAULT_MAX_ATTEMPTS = 8
|
|
|
|
|
|
CLAIM_EVENT_SQL = """
|
|
WITH candidate AS (
|
|
SELECT event.id
|
|
FROM ingestion.outbox_events AS event
|
|
WHERE event.event_type = %s
|
|
AND event.aggregate_type = 'processing_run'
|
|
AND event.available_at <= now()
|
|
AND event.publish_status IN ('pending', 'publishing')
|
|
ORDER BY event.created_at, event.id
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT 1
|
|
)
|
|
UPDATE ingestion.outbox_events AS event
|
|
SET publish_status = 'publishing',
|
|
publish_attempts = event.publish_attempts + 1,
|
|
available_at = now() + (%s * interval '1 second'),
|
|
last_error_code = NULL
|
|
FROM candidate
|
|
WHERE event.id = candidate.id
|
|
RETURNING
|
|
event.id,
|
|
event.event_key,
|
|
event.payload,
|
|
event.publish_attempts
|
|
""".strip()
|
|
|
|
|
|
PUBLISH_EVENT_SQL = """
|
|
UPDATE ingestion.outbox_events AS event
|
|
SET publish_status = 'published',
|
|
published_at = now(),
|
|
available_at = now(),
|
|
last_error_code = NULL
|
|
WHERE event.id = %s
|
|
AND event.publish_status = 'publishing'
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM reporting.monthly_runs AS run
|
|
JOIN ingestion.artifacts AS workbook
|
|
ON workbook.id = run.workbook_artifact_id
|
|
JOIN ingestion.artifacts AS result
|
|
ON result.id = run.result_artifact_id
|
|
WHERE run.id = %s
|
|
AND run.report_status IN ('active', 'superseded')
|
|
AND workbook.artifact_kind = 'monthly_xlsx'
|
|
AND result.artifact_kind = 'result_json'
|
|
)
|
|
""".strip()
|
|
|
|
|
|
FAIL_EVENT_SQL = """
|
|
UPDATE ingestion.outbox_events
|
|
SET publish_status = %s,
|
|
available_at = CASE
|
|
WHEN %s = 'pending' THEN now() + (%s * interval '1 second')
|
|
ELSE now()
|
|
END,
|
|
published_at = NULL,
|
|
last_error_code = %s
|
|
WHERE id = %s
|
|
AND publish_status = 'publishing'
|
|
""".strip()
|
|
|
|
|
|
class WorkerError(RuntimeError):
|
|
def __init__(self, code: str, *, retryable: bool):
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.retryable = retryable
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OutboxEvent:
|
|
event_id: int
|
|
event_key: str
|
|
payload: Mapping[str, Any]
|
|
publish_attempts: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorkerOutcome:
|
|
status: str
|
|
event_id: Optional[int] = None
|
|
report_id: Optional[int] = None
|
|
error_code: Optional[str] = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {"status": self.status}
|
|
if self.event_id is not None:
|
|
payload["event_id"] = self.event_id
|
|
if self.report_id is not None:
|
|
payload["report_id"] = self.report_id
|
|
if self.error_code is not None:
|
|
payload["error_code"] = self.error_code
|
|
return payload
|
|
|
|
|
|
class OutboxRepository(Protocol):
|
|
def claim_next(self) -> Optional[OutboxEvent]:
|
|
...
|
|
|
|
def mark_published(self, event_id: int, report_id: int) -> None:
|
|
...
|
|
|
|
def mark_failed(self, event: OutboxEvent, code: str, *, retryable: bool) -> str:
|
|
...
|
|
|
|
|
|
class MonthlyRequestRepository(Protocol):
|
|
def derive_monthly_request(
|
|
self,
|
|
daily_version_id: int,
|
|
) -> Optional[DerivedMonthlyRequest]:
|
|
...
|
|
|
|
|
|
def _default_connect(dsn: str) -> Any:
|
|
try:
|
|
import psycopg # type: ignore[import-not-found]
|
|
except ImportError:
|
|
raise WorkerError("MONTHLY_WORKER_DATABASE_DRIVER_UNAVAILABLE", retryable=False) from None
|
|
try:
|
|
return psycopg.connect(dsn, autocommit=False)
|
|
except Exception:
|
|
raise WorkerError("MONTHLY_WORKER_DATABASE_UNAVAILABLE", retryable=True) from None
|
|
|
|
|
|
class PostgresOutboxRepository:
|
|
def __init__(
|
|
self,
|
|
config: DatabaseConfig,
|
|
*,
|
|
connect: Optional[Callable[[str], Any]] = None,
|
|
lease_seconds: int = DEFAULT_LEASE_SECONDS,
|
|
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
) -> None:
|
|
if lease_seconds < 30 or lease_seconds > 3600:
|
|
raise ValueError("worker lease must be between 30 and 3600 seconds")
|
|
if max_attempts < 1 or max_attempts > 100:
|
|
raise ValueError("worker max attempts is invalid")
|
|
self._config = config
|
|
self._connect = connect or _default_connect
|
|
self._lease_seconds = lease_seconds
|
|
self._max_attempts = max_attempts
|
|
|
|
def _open(self) -> Any:
|
|
try:
|
|
return self._connect(self._config.dsn)
|
|
except WorkerError:
|
|
raise
|
|
except Exception:
|
|
raise WorkerError("MONTHLY_WORKER_DATABASE_UNAVAILABLE", retryable=True) from None
|
|
|
|
@staticmethod
|
|
def _begin(cursor: Any) -> None:
|
|
cursor.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
|
|
cursor.execute("SET LOCAL lock_timeout = '10s'")
|
|
cursor.execute("SET LOCAL statement_timeout = '30s'")
|
|
cursor.execute("SELECT current_database()")
|
|
row = cursor.fetchone()
|
|
if not row or row[0] != "booking_test":
|
|
raise WorkerError("MONTHLY_WORKER_DATABASE_TARGET_INVALID", retryable=False)
|
|
|
|
def claim_next(self) -> Optional[OutboxEvent]:
|
|
connection = self._open()
|
|
try:
|
|
with connection.transaction():
|
|
with connection.cursor() as cursor:
|
|
self._begin(cursor)
|
|
cursor.execute(CLAIM_EVENT_SQL, (EVENT_TYPE, self._lease_seconds))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
payload = row[2]
|
|
if isinstance(payload, str):
|
|
payload = json.loads(payload)
|
|
if not isinstance(payload, Mapping):
|
|
payload = {}
|
|
return OutboxEvent(
|
|
event_id=int(row[0]),
|
|
event_key=str(row[1]),
|
|
payload=payload,
|
|
publish_attempts=int(row[3]),
|
|
)
|
|
except WorkerError:
|
|
raise
|
|
except Exception:
|
|
raise WorkerError("MONTHLY_WORKER_CLAIM_FAILED", retryable=True) from None
|
|
finally:
|
|
connection.close()
|
|
|
|
def mark_published(self, event_id: int, report_id: int) -> None:
|
|
connection = self._open()
|
|
try:
|
|
with connection.transaction():
|
|
with connection.cursor() as cursor:
|
|
self._begin(cursor)
|
|
cursor.execute(PUBLISH_EVENT_SQL, (event_id, report_id))
|
|
if cursor.rowcount != 1:
|
|
raise WorkerError("MONTHLY_WORKER_PUBLISH_ACK_FAILED", retryable=True)
|
|
except WorkerError:
|
|
raise
|
|
except Exception:
|
|
raise WorkerError("MONTHLY_WORKER_PUBLISH_ACK_FAILED", retryable=True) from None
|
|
finally:
|
|
connection.close()
|
|
|
|
def mark_failed(self, event: OutboxEvent, code: str, *, retryable: bool) -> str:
|
|
terminal = not retryable or event.publish_attempts >= self._max_attempts
|
|
status = "dead" if terminal else "pending"
|
|
delay_seconds = min(300, 2 ** min(event.publish_attempts, 8))
|
|
safe_code = code if code and len(code) <= 128 else "MONTHLY_WORKER_FAILED"
|
|
connection = self._open()
|
|
try:
|
|
with connection.transaction():
|
|
with connection.cursor() as cursor:
|
|
self._begin(cursor)
|
|
cursor.execute(
|
|
FAIL_EVENT_SQL,
|
|
(
|
|
status,
|
|
status,
|
|
delay_seconds,
|
|
safe_code,
|
|
event.event_id,
|
|
),
|
|
)
|
|
if cursor.rowcount != 1:
|
|
raise WorkerError("MONTHLY_WORKER_FAILURE_ACK_FAILED", retryable=True)
|
|
return status
|
|
except WorkerError:
|
|
raise
|
|
except Exception:
|
|
raise WorkerError("MONTHLY_WORKER_FAILURE_ACK_FAILED", retryable=True) from None
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
class MonthlyOutboxWorker:
|
|
def __init__(
|
|
self,
|
|
outbox: OutboxRepository,
|
|
requests: MonthlyRequestRepository,
|
|
service: MonthlyReportService,
|
|
storage_runtime: Optional[ObjectStoreRuntime] = None,
|
|
) -> None:
|
|
self._outbox = outbox
|
|
self._requests = requests
|
|
self._service = service
|
|
self._storage_runtime = storage_runtime
|
|
|
|
def close(self) -> None:
|
|
if self._storage_runtime is not None:
|
|
self._storage_runtime.close()
|
|
self._storage_runtime = None
|
|
|
|
@staticmethod
|
|
def _daily_version_id(event: OutboxEvent) -> int:
|
|
value = event.payload.get("daily_version_id")
|
|
if isinstance(value, bool):
|
|
raise WorkerError("MONTHLY_WORKER_EVENT_INVALID", retryable=False)
|
|
try:
|
|
daily_version_id = int(value)
|
|
except (TypeError, ValueError):
|
|
raise WorkerError("MONTHLY_WORKER_EVENT_INVALID", retryable=False) from None
|
|
if daily_version_id < 1:
|
|
raise WorkerError("MONTHLY_WORKER_EVENT_INVALID", retryable=False)
|
|
return daily_version_id
|
|
|
|
@staticmethod
|
|
def _result_failure(result: RunResult) -> WorkerError:
|
|
code = result.error_code or "MONTHLY_WORKER_REPORT_FAILED"
|
|
retryable = code not in {
|
|
ErrorCode.REQUEST_INVALID,
|
|
ErrorCode.SOURCE_INVALID,
|
|
}
|
|
return WorkerError(code, retryable=retryable)
|
|
|
|
def process_next(self) -> WorkerOutcome:
|
|
event = self._outbox.claim_next()
|
|
if event is None:
|
|
return WorkerOutcome(status="idle")
|
|
try:
|
|
daily_version_id = self._daily_version_id(event)
|
|
derived = self._requests.derive_monthly_request(daily_version_id)
|
|
if derived is None:
|
|
raise WorkerError("MONTHLY_WORKER_ARRIVAL_SCOPE_EMPTY", retryable=False)
|
|
result = self._service.run(
|
|
RunRequest(
|
|
derived.report_year,
|
|
derived.report_month,
|
|
derived.as_of_date,
|
|
)
|
|
)
|
|
if result.status != "success":
|
|
raise self._result_failure(result)
|
|
if (
|
|
result.report_version_id is None
|
|
or result.artifact is None
|
|
or not result.artifact.get("sha256")
|
|
):
|
|
raise WorkerError("MONTHLY_WORKER_REPORT_RECEIPT_INVALID", retryable=True)
|
|
self._outbox.mark_published(event.event_id, result.report_version_id)
|
|
return WorkerOutcome(
|
|
status="published",
|
|
event_id=event.event_id,
|
|
report_id=result.report_version_id,
|
|
)
|
|
except WorkerError as error:
|
|
status = self._outbox.mark_failed(
|
|
event,
|
|
error.code,
|
|
retryable=error.retryable,
|
|
)
|
|
return WorkerOutcome(
|
|
status=status,
|
|
event_id=event.event_id,
|
|
error_code=error.code,
|
|
)
|
|
except RepositoryError as error:
|
|
status = self._outbox.mark_failed(event, error.code, retryable=True)
|
|
return WorkerOutcome(
|
|
status=status,
|
|
event_id=event.event_id,
|
|
error_code=error.code,
|
|
)
|
|
except Exception:
|
|
code = "MONTHLY_WORKER_INTERNAL_ERROR"
|
|
status = self._outbox.mark_failed(event, code, retryable=True)
|
|
return WorkerOutcome(
|
|
status=status,
|
|
event_id=event.event_id,
|
|
error_code=code,
|
|
)
|
|
|
|
|
|
def _parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(prog="arr-monthly-worker")
|
|
parser.add_argument("--db-config", type=Path)
|
|
parser.add_argument("--driver-path", type=Path)
|
|
parser.add_argument("--output-root", type=Path)
|
|
parser.add_argument("--poll-seconds", type=float, default=2.0)
|
|
parser.add_argument("--lease-seconds", type=int, default=DEFAULT_LEASE_SECONDS)
|
|
parser.add_argument("--max-attempts", type=int, default=DEFAULT_MAX_ATTEMPTS)
|
|
parser.add_argument("--once", action="store_true")
|
|
return parser
|
|
|
|
|
|
def _runtime(args: argparse.Namespace) -> MonthlyOutboxWorker:
|
|
connect: Optional[Callable[[str], Any]] = None
|
|
if args.db_config:
|
|
connect = controlled_connect(args.db_config, args.driver_path)
|
|
config = DatabaseConfig("controlled")
|
|
else:
|
|
config = DatabaseConfig.from_environment()
|
|
repository = PostgresReportRepository(config, connect=connect)
|
|
outbox = PostgresOutboxRepository(
|
|
config,
|
|
connect=connect,
|
|
lease_seconds=args.lease_seconds,
|
|
max_attempts=args.max_attempts,
|
|
)
|
|
output_root = (args.output_root or PROJECT_ROOT / "outputs" / "monthly_reports").resolve()
|
|
try:
|
|
output_root.relative_to(PROJECT_ROOT)
|
|
except ValueError:
|
|
raise ValueError("worker output root must be inside the project") from None
|
|
storage_runtime = compose_object_store()
|
|
try:
|
|
service = MonthlyReportService(
|
|
repository,
|
|
OpenpyxlWorkbookBuilder(),
|
|
AtomicReportPublisher(
|
|
PROJECT_ROOT,
|
|
output_root,
|
|
object_store=storage_runtime.object_store,
|
|
),
|
|
output_root / ".staging",
|
|
)
|
|
return MonthlyOutboxWorker(
|
|
outbox,
|
|
repository,
|
|
service,
|
|
storage_runtime=storage_runtime,
|
|
)
|
|
except Exception:
|
|
storage_runtime.close()
|
|
raise
|
|
|
|
|
|
def _emit(outcome: WorkerOutcome) -> None:
|
|
sys.stdout.write(json.dumps(outcome.to_dict(), sort_keys=True) + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
args = _parser().parse_args(argv)
|
|
if args.poll_seconds < 0.1 or args.poll_seconds > 60:
|
|
raise SystemExit("poll interval must be between 0.1 and 60 seconds")
|
|
worker = _runtime(args)
|
|
try:
|
|
if args.once:
|
|
try:
|
|
outcome = worker.process_next()
|
|
except WorkerError as error:
|
|
outcome = WorkerOutcome(status="worker_error", error_code=error.code)
|
|
_emit(outcome)
|
|
return 0 if outcome.status in {"idle", "published"} else 2
|
|
|
|
stopping = False
|
|
|
|
def stop(_signum: int, _frame: object) -> None:
|
|
nonlocal stopping
|
|
stopping = True
|
|
|
|
signal.signal(signal.SIGTERM, stop)
|
|
signal.signal(signal.SIGINT, stop)
|
|
while not stopping:
|
|
try:
|
|
outcome = worker.process_next()
|
|
except WorkerError as error:
|
|
outcome = WorkerOutcome(status="worker_error", error_code=error.code)
|
|
if outcome.status != "idle":
|
|
_emit(outcome)
|
|
if outcome.status in {"idle", "worker_error"} and not stopping:
|
|
time.sleep(args.poll_seconds)
|
|
return 0
|
|
finally:
|
|
worker.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|