"""Pipeline and stage state machines with explicit transitions and idempotency.""" from __future__ import annotations import enum import hashlib from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from uuid import UUID, uuid4 class PipelineState(str, enum.Enum): """Top-level pipeline run states.""" PENDING = "pending" SEGMENTING = "segmenting" EXTRACTING = "extracting" RESOLVING = "resolving" VERIFYING = "verifying" ROUTING = "routing" ADJUDICATING = "adjudicating" IMPACT = "impact" PERSISTING = "persisting" COMPLETED = "completed" FAILED = "failed" DEAD_LETTER = "dead_letter" class StageState(str, enum.Enum): """Per-stage execution states.""" QUEUED = "queued" LEASED = "leased" RUNNING = "running" SUCCEEDED = "succeeded" RETRYING = "retrying" FAILED = "failed" SKIPPED = "skipped" # Valid transitions for the pipeline state machine _PIPELINE_TRANSITIONS: dict[PipelineState, set[PipelineState]] = { PipelineState.PENDING: {PipelineState.SEGMENTING, PipelineState.FAILED}, PipelineState.SEGMENTING: {PipelineState.EXTRACTING, PipelineState.FAILED}, PipelineState.EXTRACTING: {PipelineState.RESOLVING, PipelineState.FAILED}, PipelineState.RESOLVING: {PipelineState.VERIFYING, PipelineState.FAILED}, PipelineState.VERIFYING: {PipelineState.ROUTING, PipelineState.FAILED}, PipelineState.ROUTING: { PipelineState.ADJUDICATING, PipelineState.IMPACT, PipelineState.FAILED, }, PipelineState.ADJUDICATING: {PipelineState.IMPACT, PipelineState.FAILED}, PipelineState.IMPACT: {PipelineState.PERSISTING, PipelineState.FAILED}, PipelineState.PERSISTING: {PipelineState.COMPLETED, PipelineState.FAILED}, PipelineState.COMPLETED: set(), PipelineState.FAILED: {PipelineState.DEAD_LETTER, PipelineState.PENDING}, PipelineState.DEAD_LETTER: set(), } # Valid transitions for stage states _STAGE_TRANSITIONS: dict[StageState, set[StageState]] = { StageState.QUEUED: {StageState.LEASED, StageState.SKIPPED}, StageState.LEASED: {StageState.RUNNING, StageState.QUEUED}, StageState.RUNNING: {StageState.SUCCEEDED, StageState.RETRYING, StageState.FAILED}, StageState.SUCCEEDED: set(), StageState.RETRYING: {StageState.QUEUED, StageState.FAILED}, StageState.FAILED: set(), StageState.SKIPPED: set(), } @dataclass(frozen=True) class StateTransition: """Immutable record of a state transition.""" transition_id: UUID run_id: UUID from_state: PipelineState | StageState to_state: PipelineState | StageState timestamp: datetime reason: str idempotency_key: str def _compute_idempotency_key( document_id: str, stage: str, attempt: int ) -> str: """Deterministic idempotency key from document, stage, and attempt.""" raw = f"{document_id}:{stage}:{attempt}" return hashlib.sha256(raw.encode()).hexdigest()[:32] @dataclass class PipelineStateMachine: """Manages state transitions for a single pipeline run. Enforces valid transitions, records history, and generates idempotency keys for each stage attempt. """ run_id: UUID = field(default_factory=uuid4) document_id: str = "" state: PipelineState = PipelineState.PENDING stage_states: dict[str, StageState] = field(default_factory=dict) stage_attempts: dict[str, int] = field(default_factory=dict) history: list[StateTransition] = field(default_factory=list) max_retries: int = 3 metadata: dict[str, Any] = field(default_factory=dict) def transition_pipeline( self, to_state: PipelineState, reason: str = "" ) -> StateTransition: """Advance the pipeline to a new state. Raises ValueError if the transition is invalid. """ allowed = _PIPELINE_TRANSITIONS.get(self.state, set()) if to_state not in allowed: raise ValueError( f"Invalid pipeline transition: {self.state.value} -> {to_state.value}" ) transition = StateTransition( transition_id=uuid4(), run_id=self.run_id, from_state=self.state, to_state=to_state, timestamp=datetime.now(timezone.utc), reason=reason, idempotency_key=_compute_idempotency_key( self.document_id, to_state.value, 0 ), ) self.state = to_state self.history.append(transition) return transition def transition_stage( self, stage: str, to_state: StageState, reason: str = "" ) -> StateTransition: """Advance a stage to a new state. Raises ValueError if the transition is invalid. """ current = self.stage_states.get(stage, StageState.QUEUED) allowed = _STAGE_TRANSITIONS.get(current, set()) if to_state not in allowed: raise ValueError( f"Invalid stage transition for '{stage}': " f"{current.value} -> {to_state.value}" ) attempt = self.stage_attempts.get(stage, 0) if to_state == StageState.RETRYING: attempt += 1 self.stage_attempts[stage] = attempt transition = StateTransition( transition_id=uuid4(), run_id=self.run_id, from_state=current, to_state=to_state, timestamp=datetime.now(timezone.utc), reason=reason, idempotency_key=_compute_idempotency_key( self.document_id, stage, attempt ), ) self.stage_states[stage] = to_state self.history.append(transition) return transition def can_retry(self, stage: str) -> bool: """Check whether the stage has retries remaining.""" return self.stage_attempts.get(stage, 0) < self.max_retries def should_dead_letter(self) -> bool: """Check if the pipeline run should move to dead letter.""" if self.state != PipelineState.FAILED: return False # Dead-letter if any stage exceeded max retries for stage, attempts in self.stage_attempts.items(): if attempts >= self.max_retries: return True return False