Files
gyxx-flow/src/gyxx_flow/workflow/model.py
T

190 lines
7.6 KiB
Python

"""Validated workflow and step definitions."""
from __future__ import annotations
import heapq
import math
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Protocol
if TYPE_CHECKING:
from gyxx_flow.core.context import RunContext
from .steps import StepExecution
_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
_RESOURCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]*$")
# ``repeatable`` is reserved for production actions that intentionally submit
# on every invocation. Unlike ``guarded``, it does not use the business-date
# keyed effect ledger; unlike ``idempotent``, it does not claim that the
# external action itself is idempotent.
ReplayPolicy = Literal["guarded", "idempotent", "repeatable"]
class WorkflowValidationError(ValueError):
"""Raised when a workflow definition is unsafe or inconsistent."""
class ExecutableStep(Protocol):
"""Action contract consumed by the workflow engine."""
def execute(
self,
*,
context: RunContext,
timeout_seconds: float | None,
dry_run: bool,
) -> StepExecution: ...
@dataclass(frozen=True, slots=True)
class StepDefinition:
"""Execution policy and dependency metadata for one workflow step."""
step_id: str
action: ExecutableStep
depends_on: tuple[str, ...] = ()
timeout_seconds: float | None = None
max_attempts: int = 1
retry_delay_seconds: float = 0.0
critical: bool = True
resources: tuple[str, ...] = ()
production_sink: bool = False
official_notification: bool = False
replay_policy: ReplayPolicy = "guarded"
run_after_failure: bool = False
resource_lock_timeout_seconds: float = 30.0
def __post_init__(self) -> None:
object.__setattr__(self, "depends_on", tuple(self.depends_on))
object.__setattr__(self, "resources", tuple(self.resources))
if not isinstance(self.step_id, str) or not _ID.fullmatch(self.step_id):
raise WorkflowValidationError(f"invalid step id: {self.step_id!r}")
if not callable(getattr(self.action, "execute", None)):
raise WorkflowValidationError(f"step {self.step_id!r} has no executable action")
if len(set(self.depends_on)) != len(self.depends_on):
raise WorkflowValidationError(f"duplicate dependency for step {self.step_id!r}")
for dependency in self.depends_on:
if not isinstance(dependency, str) or not _ID.fullmatch(dependency):
raise WorkflowValidationError(
f"invalid dependency for step {self.step_id!r}: {dependency!r}"
)
if dependency == self.step_id:
raise WorkflowValidationError(f"step {self.step_id!r} depends on itself")
if self.timeout_seconds is not None and (
isinstance(self.timeout_seconds, bool)
or not isinstance(self.timeout_seconds, (int, float))
or not math.isfinite(self.timeout_seconds)
or self.timeout_seconds <= 0
):
raise WorkflowValidationError(
f"timeout_seconds must be positive for step {self.step_id!r}"
)
if (
isinstance(self.resource_lock_timeout_seconds, bool)
or not isinstance(self.resource_lock_timeout_seconds, (int, float))
or not math.isfinite(self.resource_lock_timeout_seconds)
or self.resource_lock_timeout_seconds <= 0
):
raise WorkflowValidationError(
"resource_lock_timeout_seconds must be positive for step "
f"{self.step_id!r}"
)
if (
isinstance(self.max_attempts, bool)
or not isinstance(self.max_attempts, int)
or self.max_attempts < 1
):
raise WorkflowValidationError(
f"max_attempts must be a positive integer for step {self.step_id!r}"
)
if isinstance(self.retry_delay_seconds, bool) or self.retry_delay_seconds < 0:
raise WorkflowValidationError(
f"retry_delay_seconds must be non-negative for step {self.step_id!r}"
)
if len(set(self.resources)) != len(self.resources):
raise WorkflowValidationError(f"duplicate resource for step {self.step_id!r}")
for resource in self.resources:
if not isinstance(resource, str) or not _RESOURCE.fullmatch(resource):
raise WorkflowValidationError(
f"invalid resource for step {self.step_id!r}: {resource!r}"
)
if not isinstance(self.run_after_failure, bool):
raise WorkflowValidationError(
f"run_after_failure must be boolean for step {self.step_id!r}"
)
if self.replay_policy not in {"guarded", "idempotent", "repeatable"}:
raise WorkflowValidationError(
f"invalid replay_policy for step {self.step_id!r}: "
f"{self.replay_policy!r}"
)
if self.replay_policy == "idempotent" and not self.production_sink:
raise WorkflowValidationError(
f"idempotent replay_policy requires production_sink for step "
f"{self.step_id!r}"
)
@dataclass(frozen=True, slots=True)
class WorkflowDefinition:
"""A validated DAG with deterministic execution ordering."""
workflow_id: str
steps: tuple[StepDefinition, ...]
def __post_init__(self) -> None:
object.__setattr__(self, "steps", tuple(self.steps))
if not isinstance(self.workflow_id, str) or not _ID.fullmatch(self.workflow_id):
raise WorkflowValidationError(f"invalid workflow id: {self.workflow_id!r}")
ids = [step.step_id for step in self.steps]
duplicate = _first_duplicate(ids)
if duplicate is not None:
raise WorkflowValidationError(f"duplicate step id: {duplicate}")
known = set(ids)
for step in self.steps:
for dependency in step.depends_on:
if dependency not in known:
raise WorkflowValidationError(
f"unknown dependency {dependency!r} for step {step.step_id!r}"
)
self.ordered_steps()
def ordered_steps(self) -> tuple[StepDefinition, ...]:
"""Return a stable topological order or reject dependency cycles."""
positions = {step.step_id: index for index, step in enumerate(self.steps)}
indegrees = {step.step_id: len(step.depends_on) for step in self.steps}
dependents: dict[str, list[str]] = {step.step_id: [] for step in self.steps}
for step in self.steps:
for dependency in step.depends_on:
dependents[dependency].append(step.step_id)
ready = [positions[step.step_id] for step in self.steps if not step.depends_on]
heapq.heapify(ready)
ordered: list[StepDefinition] = []
while ready:
position = heapq.heappop(ready)
step = self.steps[position]
ordered.append(step)
for dependent in dependents[step.step_id]:
indegrees[dependent] -= 1
if indegrees[dependent] == 0:
heapq.heappush(ready, positions[dependent])
if len(ordered) != len(self.steps):
cyclic = sorted(step_id for step_id, degree in indegrees.items() if degree)
raise WorkflowValidationError(
f"dependency cycle detected: {', '.join(cyclic)}"
)
return tuple(ordered)
def _first_duplicate(values: list[str]) -> str | None:
seen: set[str] = set()
for value in values:
if value in seen:
return value
seen.add(value)
return None