feat: prepare ARR for controlled public deployment
This commit is contained in:
237
arr_processing/ledger.py
Normal file
237
arr_processing/ledger.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Thread-safe reference ledger for job/run and callback correlation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Protocol, Tuple
|
||||
|
||||
from arr_processing.contracts import ProcessingRequest, ProcessingResult
|
||||
from arr_processing.errors import ProcessingError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessingRecord:
|
||||
request: ProcessingRequest
|
||||
request_sha256: str
|
||||
idempotency_key: str
|
||||
remote_run_id: Optional[str]
|
||||
remote_status: str
|
||||
result: Optional[ProcessingResult] = None
|
||||
failure_code: Optional[str] = None
|
||||
|
||||
|
||||
class ProcessingLedger(Protocol):
|
||||
def reserve(
|
||||
self,
|
||||
request: ProcessingRequest,
|
||||
request_sha256: str,
|
||||
idempotency_key: str,
|
||||
) -> ProcessingRecord:
|
||||
...
|
||||
|
||||
def get(self, job_id: str, attempt_no: int) -> Optional[ProcessingRecord]:
|
||||
...
|
||||
|
||||
def bind_run(
|
||||
self,
|
||||
job_id: str,
|
||||
attempt_no: int,
|
||||
remote_run_id: str,
|
||||
remote_status: str,
|
||||
) -> ProcessingRecord:
|
||||
...
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
job_id: str,
|
||||
attempt_no: int,
|
||||
remote_status: str,
|
||||
) -> ProcessingRecord:
|
||||
...
|
||||
|
||||
def fail_start(
|
||||
self,
|
||||
job_id: str,
|
||||
attempt_no: int,
|
||||
failure_code: str,
|
||||
) -> ProcessingRecord:
|
||||
...
|
||||
|
||||
def attach_result(self, result: ProcessingResult) -> Tuple[ProcessingRecord, bool]:
|
||||
...
|
||||
|
||||
def accept_callback(self, delivery_id: str, signed_sha256: str) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class InMemoryProcessingLedger:
|
||||
"""Reference implementation; production should map this protocol to PostgreSQL."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._records: Dict[Tuple[str, int], ProcessingRecord] = {}
|
||||
self._runs: Dict[str, Tuple[str, int]] = {}
|
||||
self._callbacks: Dict[str, str] = {}
|
||||
|
||||
def reserve(
|
||||
self,
|
||||
request: ProcessingRequest,
|
||||
request_sha256: str,
|
||||
idempotency_key: str,
|
||||
) -> ProcessingRecord:
|
||||
key = (request.job_id, request.attempt_no)
|
||||
with self._lock:
|
||||
existing = self._records.get(key)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.request != request
|
||||
or existing.request_sha256 != request_sha256
|
||||
or existing.idempotency_key != idempotency_key
|
||||
):
|
||||
raise ProcessingError(
|
||||
"PROCESSING_JOB_CONFLICT", "processing attempt identity conflicts"
|
||||
)
|
||||
return copy.deepcopy(existing)
|
||||
record = ProcessingRecord(
|
||||
request=request,
|
||||
request_sha256=request_sha256,
|
||||
idempotency_key=idempotency_key,
|
||||
remote_run_id=None,
|
||||
remote_status="reserved",
|
||||
)
|
||||
self._records[key] = record
|
||||
return copy.deepcopy(record)
|
||||
|
||||
def get(self, job_id: str, attempt_no: int) -> Optional[ProcessingRecord]:
|
||||
with self._lock:
|
||||
record = self._records.get((job_id, attempt_no))
|
||||
return copy.deepcopy(record) if record is not None else None
|
||||
|
||||
def bind_run(
|
||||
self,
|
||||
job_id: str,
|
||||
attempt_no: int,
|
||||
remote_run_id: str,
|
||||
remote_status: str,
|
||||
) -> ProcessingRecord:
|
||||
key = (job_id, attempt_no)
|
||||
with self._lock:
|
||||
record = self._required(key)
|
||||
run_owner = self._runs.get(remote_run_id)
|
||||
if run_owner is not None and run_owner != key:
|
||||
raise ProcessingError(
|
||||
"PROCESSING_RUN_CONFLICT", "remote run is already correlated"
|
||||
)
|
||||
if record.remote_run_id is not None and record.remote_run_id != remote_run_id:
|
||||
raise ProcessingError(
|
||||
"PROCESSING_RUN_CONFLICT", "processing attempt has a different remote run"
|
||||
)
|
||||
updated = ProcessingRecord(
|
||||
request=record.request,
|
||||
request_sha256=record.request_sha256,
|
||||
idempotency_key=record.idempotency_key,
|
||||
remote_run_id=remote_run_id,
|
||||
remote_status=remote_status,
|
||||
result=record.result,
|
||||
failure_code=record.failure_code,
|
||||
)
|
||||
self._records[key] = updated
|
||||
self._runs[remote_run_id] = key
|
||||
return copy.deepcopy(updated)
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
job_id: str,
|
||||
attempt_no: int,
|
||||
remote_status: str,
|
||||
) -> ProcessingRecord:
|
||||
key = (job_id, attempt_no)
|
||||
with self._lock:
|
||||
record = self._required(key)
|
||||
updated = ProcessingRecord(
|
||||
request=record.request,
|
||||
request_sha256=record.request_sha256,
|
||||
idempotency_key=record.idempotency_key,
|
||||
remote_run_id=record.remote_run_id,
|
||||
remote_status=remote_status,
|
||||
result=record.result,
|
||||
failure_code=record.failure_code,
|
||||
)
|
||||
self._records[key] = updated
|
||||
return copy.deepcopy(updated)
|
||||
|
||||
def fail_start(
|
||||
self,
|
||||
job_id: str,
|
||||
attempt_no: int,
|
||||
failure_code: str,
|
||||
) -> ProcessingRecord:
|
||||
key = (job_id, attempt_no)
|
||||
with self._lock:
|
||||
record = self._required(key)
|
||||
if record.remote_run_id is not None:
|
||||
raise ProcessingError(
|
||||
"PROCESSING_RUN_CONFLICT",
|
||||
"processing attempt already has a remote run",
|
||||
)
|
||||
if record.remote_status == "failed":
|
||||
return copy.deepcopy(record)
|
||||
updated = ProcessingRecord(
|
||||
request=record.request,
|
||||
request_sha256=record.request_sha256,
|
||||
idempotency_key=record.idempotency_key,
|
||||
remote_run_id=None,
|
||||
remote_status="failed",
|
||||
result=record.result,
|
||||
failure_code=failure_code,
|
||||
)
|
||||
self._records[key] = updated
|
||||
return copy.deepcopy(updated)
|
||||
|
||||
def attach_result(self, result: ProcessingResult) -> Tuple[ProcessingRecord, bool]:
|
||||
key = (result.job_id, result.attempt_no)
|
||||
with self._lock:
|
||||
record = self._required(key)
|
||||
if record.remote_run_id != result.remote_run_id:
|
||||
raise ProcessingError(
|
||||
"PROCESSING_RESULT_MISMATCH", "result remote run does not match its attempt"
|
||||
)
|
||||
if record.result is not None:
|
||||
if record.result != result:
|
||||
raise ProcessingError(
|
||||
"PROCESSING_RESULT_CONFLICT", "processing attempt has a different result"
|
||||
)
|
||||
return copy.deepcopy(record), True
|
||||
updated = ProcessingRecord(
|
||||
request=record.request,
|
||||
request_sha256=record.request_sha256,
|
||||
idempotency_key=record.idempotency_key,
|
||||
remote_run_id=record.remote_run_id,
|
||||
remote_status="delivered",
|
||||
result=result,
|
||||
failure_code=None,
|
||||
)
|
||||
self._records[key] = updated
|
||||
return copy.deepcopy(updated), False
|
||||
|
||||
def accept_callback(self, delivery_id: str, signed_sha256: str) -> bool:
|
||||
with self._lock:
|
||||
existing = self._callbacks.get(delivery_id)
|
||||
if existing is None:
|
||||
self._callbacks[delivery_id] = signed_sha256
|
||||
return False
|
||||
if existing != signed_sha256:
|
||||
raise ProcessingError(
|
||||
"PROCESSING_CALLBACK_CONFLICT", "delivery identifier conflicts"
|
||||
)
|
||||
return True
|
||||
|
||||
def _required(self, key: Tuple[str, int]) -> ProcessingRecord:
|
||||
try:
|
||||
return self._records[key]
|
||||
except KeyError:
|
||||
raise ProcessingError(
|
||||
"PROCESSING_JOB_NOT_FOUND", "processing attempt is not registered"
|
||||
) from None
|
||||
Reference in New Issue
Block a user