190 lines
7.6 KiB
Python
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
|