Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import shutil | |
| import time | |
| from collections.abc import Callable, Mapping, Sequence | |
| from dataclasses import dataclass, field | |
| from enum import StrEnum | |
| from pathlib import Path | |
| from typing import TYPE_CHECKING, Protocol | |
| from redstack.domain.errors import ArtifactContractError, DomainError | |
| from redstack.pipelines.offline.graph import OfflineExecutionGraph | |
| from redstack.pipelines.offline.registry import ( | |
| OfflineArtifactRegistry, | |
| ValidationOutcome, | |
| ) | |
| if TYPE_CHECKING: | |
| from redstack.pipelines.offline.context import OfflinePipelineContext | |
| __all__: tuple[str, ...] = ( | |
| "StageStatus", | |
| "ArtifactPayload", | |
| "StageResult", | |
| "StageCallable", | |
| "StageReceipt", | |
| "OfflinePipelineRunner", | |
| "RunnerError", | |
| ) | |
| class RunnerError(DomainError): | |
| """A runner-level orchestration failure (checkpoint corruption, IO).""" | |
| class StageStatus(StrEnum): | |
| """The terminal disposition of a stage in a single ``run()`` invocation.""" | |
| EXECUTED = "executed" | |
| SKIPPED = "skipped" | |
| QUARANTINED = "quarantined" | |
| FAILED = "failed" | |
| # An artifact payload is the parsed, registry-validatable view of one produced | |
| # artifact: for JSON/YAML the decoded mapping; for npy/parquet the header/schema | |
| # metadata the stage reports. The runner never re-reads bytes to validate — | |
| # stages hand back exactly what the registry validator inspects, plus the raw | |
| # bytes' sha256 the stage already computed when it wrote the artifact. | |
| class ArtifactPayload: | |
| """One artifact produced by a stage, ready for registry validation + hashing. | |
| Attributes: | |
| key: The registry key this artifact satisfies. | |
| sha256: The hex sha256 the stage computed over the written bytes (the | |
| value that lands in ``MANIFEST.json``). The runner trusts the stage's | |
| hash for the receipt; O17 re-streams it during packaging. | |
| bytes_written: Size on disk, recorded for the manifest/report. | |
| validation_view: The structural payload the registry validator inspects | |
| (decoded mapping or extracted npy/parquet metadata). | |
| """ | |
| key: str | |
| sha256: str | |
| bytes_written: int | |
| validation_view: Mapping[str, object] | |
| class StageResult: | |
| """What a stage callable returns: its produced artifacts + metrics. | |
| Attributes: | |
| artifacts: The artifacts the stage produced this run, each registry-keyed. | |
| metrics: Deterministic stage metrics for the build report (census stats, | |
| honeypot counts, NDCG, cluster quality, …). Must be JSON-serializable | |
| and reproducible; excludes wall-clock. | |
| """ | |
| artifacts: tuple[ArtifactPayload, ...] | |
| metrics: Mapping[str, object] = field(default_factory=dict) | |
| class StageCallable(Protocol): | |
| """The injected stage contract the runner orchestrates (Part 1). | |
| A stage is ``f(ctx, upstream) -> StageResult``: pure given the context's | |
| ports + injected entropy/clock. ``upstream`` exposes the receipts of the | |
| stage's dependencies so a stage can read its inputs' identities (hashes) | |
| without the runner knowing stage internals. ``stage_version`` is the stage's | |
| own semver, part of its staleness key — bump it to force recompute on a | |
| behavior change. | |
| """ | |
| def stage_id(self) -> str: ... | |
| def stage_version(self) -> str: ... | |
| def __call__( | |
| self, | |
| ctx: OfflinePipelineContext, | |
| upstream: Mapping[str, StageReceipt], | |
| ) -> StageResult: ... | |
| class StageReceipt: | |
| stage_id: str | |
| stage_version: str | |
| staleness_key: str | |
| artifact_hashes: Mapping[str, str] | |
| artifact_bytes: Mapping[str, int] | |
| metrics: Mapping[str, object] = field(default_factory=dict) | |
| status: StageStatus = StageStatus.EXECUTED | |
| wall_ms: float = 0.0 | |
| def reproducible_view(self) -> dict[str, object]: | |
| """Return the deterministic subset for checkpoint serialization.""" | |
| return { | |
| "stage_id": self.stage_id, | |
| "stage_version": self.stage_version, | |
| "staleness_key": self.staleness_key, | |
| "artifact_hashes": dict(sorted(self.artifact_hashes.items())), | |
| "artifact_bytes": dict(sorted(self.artifact_bytes.items())), | |
| "metrics": _canonical_jsonable(self.metrics), | |
| } | |
| def _canonical_jsonable(value: object) -> object: | |
| """Round-trip ``value`` through canonical JSON to normalize for hashing.""" | |
| return json.loads(json.dumps(value, sort_keys=True, default=str)) | |
| def _sha256_hex(text: str) -> str: | |
| """Return the hex sha256 of a UTF-8 string.""" | |
| return hashlib.sha256(text.encode("utf-8")).hexdigest() | |
| MonotonicClock = Callable[[], float] | |
| class OfflinePipelineRunner: | |
| """Executes an offline plan with resume, checkpointing, and quarantine. | |
| Stateless across invocations except for the on-disk checkpoint store under | |
| ``ctx.checkpoints_root``; two runs with identical inputs produce identical | |
| receipts' reproducible views (the audit ``wall_ms`` differs and is ignored). | |
| Attributes: | |
| graph: The dependency DAG providing topo order + downstream closure. | |
| registry: The artifact catalog; every produced artifact is validated | |
| against it before its receipt is written. | |
| continue_on_error: If ``True``, a *non-critical* stage failure is | |
| flagged and the build continues; a critical failure always aborts. | |
| clock: Injected monotonic clock for stage timing (audit only). | |
| Defaults to ``time.perf_counter``, the highest-resolution monotonic | |
| clock available, consistent with :class:`~redstack.observability | |
| .timing.StageTimer` and the online pipeline's per-stage timing. | |
| """ | |
| graph: OfflineExecutionGraph | |
| registry: OfflineArtifactRegistry | |
| continue_on_error: bool = False | |
| clock: MonotonicClock = time.perf_counter | |
| # ------------------------------------------------------------------ # | |
| # Staleness # | |
| # ------------------------------------------------------------------ # | |
| def _staleness_key( | |
| self, | |
| stage: StageCallable, | |
| upstream: Mapping[str, StageReceipt], | |
| config_hash: str, | |
| ) -> str: | |
| """Compute ``hash(input_hashes + stage_version + config_slice)`` (Part 11). | |
| Input hashes are the produced-artifact sha256s of *this stage's direct | |
| dependencies*, taken in deterministic id order, so the key is stable and | |
| order-independent. The ``config_slice`` is the context's canonical | |
| ``config_hash`` — a config change invalidates dependent stages. | |
| """ | |
| parts: list[str] = [stage.stage_id, stage.stage_version, config_hash] | |
| for dep_id in sorted(self.graph.dependencies(stage.stage_id)): | |
| receipt = upstream.get(dep_id) | |
| if receipt is None: | |
| # Dependency produced no receipt (e.g. skipped non-critical | |
| # branch): contribute a stable sentinel so the key still differs | |
| # from a present-dependency key. | |
| parts.append(f"{dep_id}=∅") | |
| continue | |
| for key, digest in sorted(receipt.artifact_hashes.items()): | |
| parts.append(f"{dep_id}:{key}={digest}") | |
| return _sha256_hex("\u0000".join(parts)) | |
| # ------------------------------------------------------------------ # | |
| # Checkpoint persistence # | |
| # ------------------------------------------------------------------ # | |
| def _checkpoint_path(self, ctx: OfflinePipelineContext, stage_id: str) -> Path: | |
| return ctx.checkpoints_root / f"{stage_id}.receipt.json" | |
| def _load_checkpoint( | |
| self, ctx: OfflinePipelineContext, stage_id: str | |
| ) -> StageReceipt | None: | |
| """Load a persisted receipt, or ``None`` if absent/corrupt. | |
| Checkpoint corruption is treated as "recompute" (Part 11: "checkpoint | |
| corruption → recompute"), not a hard failure: a malformed sidecar simply | |
| yields ``None`` so the stage re-runs. | |
| """ | |
| path = self._checkpoint_path(ctx, stage_id) | |
| if not path.is_file(): | |
| return None | |
| try: | |
| raw = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| return None | |
| if not isinstance(raw, dict): | |
| return None | |
| try: | |
| return StageReceipt( | |
| stage_id=str(raw["stage_id"]), | |
| stage_version=str(raw["stage_version"]), | |
| staleness_key=str(raw["staleness_key"]), | |
| artifact_hashes=dict(raw["artifact_hashes"]), | |
| artifact_bytes=dict(raw["artifact_bytes"]), | |
| metrics=dict(raw.get("metrics", {})), | |
| status=StageStatus.SKIPPED, | |
| wall_ms=0.0, | |
| ) | |
| except (KeyError, TypeError, ValueError): | |
| return None | |
| def _persist_checkpoint( | |
| self, ctx: OfflinePipelineContext, receipt: StageReceipt | |
| ) -> None: | |
| """Atomically persist a receipt's reproducible view as a sidecar.""" | |
| ctx.checkpoints_root.mkdir(parents=True, exist_ok=True) | |
| path = self._checkpoint_path(ctx, receipt.stage_id) | |
| tmp = path.with_suffix(".json.tmp") | |
| payload = json.dumps( | |
| receipt.reproducible_view(), | |
| sort_keys=True, | |
| separators=(",", ":"), | |
| ensure_ascii=False, | |
| ) | |
| try: | |
| tmp.write_text(payload, encoding="utf-8") | |
| tmp.replace(path) | |
| except OSError as exc: | |
| msg = f"failed to persist checkpoint for {receipt.stage_id!r}: {exc}" | |
| raise RunnerError(msg) from exc | |
| # ------------------------------------------------------------------ # | |
| # Validation + quarantine # | |
| # ------------------------------------------------------------------ # | |
| def _validate_outputs(self, result: StageResult) -> None: | |
| """Validate every produced artifact against the registry; raise on breach. | |
| A validator rejection is an invariant breach (Offline-testing §8: | |
| "validated against the registry before manifesting"), so it raises | |
| ``ArtifactContractError`` naming the offending key + reason — the hard | |
| fail path. The happy path returns ``None``. | |
| """ | |
| for payload in result.artifacts: | |
| outcome: ValidationOutcome = self.registry.validate( | |
| payload.key, payload.validation_view | |
| ) | |
| if not outcome.ok: | |
| msg = ( | |
| f"artifact {payload.key!r} failed registry validation: " | |
| f"{outcome.reason}" | |
| ) | |
| raise ArtifactContractError(msg) | |
| def _quarantine( | |
| self, ctx: OfflinePipelineContext, stage_id: str | |
| ) -> None: | |
| """Move a failed stage's partial outputs out of the manifested tree. | |
| Best-effort: any artifact whose relative path is owned by ``stage_id`` is | |
| moved under ``quarantine_root/<stage_id>/`` so it can never be hashed | |
| into ``MANIFEST.json`` (Part 11: "partial/failed outputs are | |
| quarantined"). Missing files are ignored — a stage may have failed before | |
| writing anything. | |
| """ | |
| owned = [ | |
| spec | |
| for spec in self.registry.specs | |
| if stage_id in spec.owner_stages | |
| ] | |
| if not owned: | |
| return | |
| dest_root = ctx.quarantine_root / stage_id | |
| dest_root.mkdir(parents=True, exist_ok=True) | |
| for spec in owned: | |
| src = ctx.artifacts_root / spec.relative_path | |
| if not src.exists(): | |
| continue | |
| dest = dest_root / Path(spec.relative_path).name | |
| try: | |
| shutil.move(str(src), str(dest)) | |
| except OSError: | |
| # Quarantine is defence-in-depth; never let it mask the original | |
| # stage error. A failed move leaves the partial file, which O17's | |
| # required-key/coherence pass will still reject. | |
| continue | |
| # ------------------------------------------------------------------ # | |
| # Plan + run # | |
| # ------------------------------------------------------------------ # | |
| def plan(self, only: Sequence[str] | None = None) -> tuple[str, ...]: | |
| """Return the deterministic execution order, optionally restricted. | |
| With ``only`` given, the plan is the topo-ordered downstream closure of | |
| those stages (their transitive dependents), so a forced re-run of an | |
| upstream stage drags its closure (Part 11). Without it, the full topo | |
| order is returned. Cycle detection already happened at graph construction. | |
| """ | |
| full = self.graph.topo_order() | |
| if only is None: | |
| return full | |
| closure = self.graph.downstream_closure(only) | |
| return tuple(sid for sid in full if sid in closure) | |
| def run( | |
| self, | |
| ctx: OfflinePipelineContext, | |
| stages: Mapping[str, StageCallable], | |
| *, | |
| force: Sequence[str] | None = None, | |
| ) -> dict[str, StageReceipt]: | |
| """Execute the plan, skipping up-to-date stages, and return receipts. | |
| Args: | |
| ctx: The immutable build context (ports + roots + provenance). | |
| stages: ``stage_id -> callable`` for every stage in the plan. A plan | |
| stage absent from this mapping is a programming error. | |
| force: Stage ids to recompute regardless of checkpoint freshness; | |
| their downstream closure is forced stale too. | |
| Returns: | |
| ``stage_id -> StageReceipt`` for every planned stage (executed, | |
| skipped, or — under ``continue_on_error`` — flagged failed). | |
| Raises: | |
| RunnerError: a planned stage has no registered callable, or | |
| checkpoint IO failed. | |
| ArtifactContractError: a produced artifact failed registry validation. | |
| Exception: re-raised from a *critical* stage after quarantine. | |
| """ | |
| order = self.plan() | |
| forced = self.graph.downstream_closure(force) if force else frozenset() | |
| receipts: dict[str, StageReceipt] = {} | |
| reran: set[str] = set() | |
| for stage_id in order: | |
| stage = stages.get(stage_id) | |
| if stage is None: | |
| msg = f"no stage callable registered for planned stage {stage_id!r}" | |
| raise RunnerError(msg) | |
| upstream = { | |
| dep: receipts[dep] | |
| for dep in self.graph.dependencies(stage_id) | |
| if dep in receipts | |
| } | |
| staleness_key = self._staleness_key(stage, upstream, ctx.config_hash) | |
| checkpoint = self._load_checkpoint(ctx, stage_id) | |
| upstream_reran = any( | |
| dep in reran for dep in self.graph.dependencies(stage_id) | |
| ) | |
| is_forced = stage_id in forced | |
| is_fresh = ( | |
| checkpoint is not None | |
| and checkpoint.staleness_key == staleness_key | |
| and checkpoint.stage_version == stage.stage_version | |
| and not upstream_reran | |
| and not is_forced | |
| ) | |
| if is_fresh and checkpoint is not None: | |
| receipts[stage_id] = checkpoint # status == SKIPPED | |
| continue | |
| receipts[stage_id] = self._execute_stage( | |
| ctx, stage, upstream, staleness_key | |
| ) | |
| if receipts[stage_id].status is StageStatus.EXECUTED: | |
| reran.add(stage_id) | |
| return receipts | |
| def _execute_stage( | |
| self, | |
| ctx: OfflinePipelineContext, | |
| stage: StageCallable, | |
| upstream: Mapping[str, StageReceipt], | |
| staleness_key: str, | |
| ) -> StageReceipt: | |
| """Run one stage, validate + checkpoint on success, quarantine on failure. | |
| Encapsulates the per-stage failure policy: validator breach and critical | |
| stage exceptions propagate (after quarantine); a non-critical failure | |
| under ``continue_on_error`` is captured as a ``FAILED`` receipt and the | |
| build continues. | |
| """ | |
| started = self.clock() | |
| try: | |
| result = stage(ctx, upstream) | |
| self._validate_outputs(result) | |
| except ArtifactContractError: | |
| self._quarantine(ctx, stage.stage_id) | |
| raise | |
| except Exception: | |
| self._quarantine(ctx, stage.stage_id) | |
| node = self.graph.node(stage.stage_id) | |
| if node.critical or not self.continue_on_error: | |
| raise | |
| return StageReceipt( | |
| stage_id=stage.stage_id, | |
| stage_version=stage.stage_version, | |
| staleness_key=staleness_key, | |
| artifact_hashes={}, | |
| artifact_bytes={}, | |
| metrics={}, | |
| status=StageStatus.FAILED, | |
| wall_ms=(self.clock() - started) * 1000.0, | |
| ) | |
| receipt = StageReceipt( | |
| stage_id=stage.stage_id, | |
| stage_version=stage.stage_version, | |
| staleness_key=staleness_key, | |
| artifact_hashes={a.key: a.sha256 for a in result.artifacts}, | |
| artifact_bytes={a.key: a.bytes_written for a in result.artifacts}, | |
| metrics=result.metrics, | |
| status=StageStatus.EXECUTED, | |
| wall_ms=(self.clock() - started) * 1000.0, | |
| ) | |
| self._persist_checkpoint(ctx, receipt) | |
| return receipt |