docs: complete run step budget slice
This commit is contained in:
@@ -38,6 +38,7 @@ from .run_codec import (
|
||||
load_run_state,
|
||||
load_run_state_with_upgrade,
|
||||
)
|
||||
from .run_limits import RunLimits
|
||||
from .run_state import (
|
||||
ExecutionFrame,
|
||||
ForeachContext,
|
||||
@@ -65,7 +66,7 @@ from .runtime import (
|
||||
step_workflow,
|
||||
step_workflow_async,
|
||||
)
|
||||
from .runtime.limits import RunLimits, admit_step_attempt, remaining_step_attempts
|
||||
from .runtime.limits import admit_step_attempt, remaining_step_attempts
|
||||
from .tokens import END, START
|
||||
from .validation import (
|
||||
ValidationIssue,
|
||||
|
||||
+23
-12
@@ -5,8 +5,8 @@ from typing import Any, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
from .run_limits import RunLimits
|
||||
from .run_state import ROOT_SCOPE_ID, RunState
|
||||
from .runtime.limits import RunLimits
|
||||
|
||||
|
||||
class PersistedRunState(BaseModel):
|
||||
@@ -57,7 +57,13 @@ def _check_step_number(value: object, *, steps_executed: int, what: str) -> None
|
||||
"""
|
||||
if value is None:
|
||||
return
|
||||
if not _is_strict_int(value) or not 1 <= value <= steps_executed: # type: ignore[operator]
|
||||
if not _is_strict_int(value):
|
||||
raise ValueError(
|
||||
"invalid persisted workflow run state: "
|
||||
f"{what} has incoherent step number {value!r}"
|
||||
)
|
||||
number = cast(int, value)
|
||||
if not 1 <= number <= steps_executed:
|
||||
raise ValueError(
|
||||
"invalid persisted workflow run state: "
|
||||
f"{what} has incoherent step number {value!r}"
|
||||
@@ -101,27 +107,32 @@ def _require_v2_budget_fields(state: dict[str, Any]) -> None:
|
||||
corruption, not another request for defaults. Values are validated
|
||||
strictly on the raw envelope (exact ints, ranges, coherence) because lax
|
||||
coercion would otherwise accept bools, numeric strings, negatives, or
|
||||
future frame numbers and silently inflate or distort the budget. Trace
|
||||
and interrupt entries always carry the key (``None`` only for unadmitted
|
||||
or upgraded pre-budget history), so a missing key is likewise corrupt
|
||||
even though the dataclass default would otherwise mask it.
|
||||
future frame numbers and silently inflate or distort the budget. The
|
||||
limits object holds exactly ``max_steps``: unknown fields are corrupt
|
||||
rather than silently dropped, since no stored-data contract emits them.
|
||||
Trace and interrupt entries always carry the key (``None`` only for
|
||||
unadmitted or upgraded pre-budget history), so a missing key is likewise
|
||||
corrupt even though the dataclass default would otherwise mask it.
|
||||
"""
|
||||
limits = state.get("limits")
|
||||
if not isinstance(limits, dict) or "max_steps" not in limits:
|
||||
if not isinstance(limits, dict) or set(limits.keys()) != {"max_steps"}:
|
||||
raise ValueError("invalid persisted workflow run state: missing step budget")
|
||||
max_steps = limits["max_steps"]
|
||||
if not _is_strict_int(max_steps) or max_steps < 1: # type: ignore[operator]
|
||||
if not _is_strict_int(max_steps):
|
||||
raise ValueError(
|
||||
"invalid persisted workflow run state: corrupt step budget limit"
|
||||
)
|
||||
max_steps_value = cast(int, max_steps)
|
||||
if max_steps_value < 1:
|
||||
raise ValueError(
|
||||
"invalid persisted workflow run state: corrupt step budget limit"
|
||||
)
|
||||
steps_executed = state.get("steps_executed")
|
||||
if not _is_strict_int(steps_executed):
|
||||
raise ValueError("invalid persisted workflow run state: missing step budget")
|
||||
if not 0 <= steps_executed <= max_steps: # type: ignore[operator]
|
||||
raise ValueError(
|
||||
"invalid persisted workflow run state: corrupt step counter"
|
||||
)
|
||||
exec_count = cast(int, steps_executed)
|
||||
if not 0 <= exec_count <= max_steps_value:
|
||||
raise ValueError("invalid persisted workflow run state: corrupt step counter")
|
||||
frames = state.get("frames")
|
||||
if not isinstance(frames, dict):
|
||||
raise ValueError("invalid persisted workflow run state: missing frames")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Neutral run-wide step budget value model.
|
||||
|
||||
`RunLimits` is immutable policy captured when a run is created. It lives
|
||||
here (next to `run_state`, not under `runtime`) so `run_state` can import
|
||||
it at module top without executing the `wf_core.runtime` package whose
|
||||
engine imports `run_state` back. Admission policy stays in
|
||||
`wf_core.runtime.limits`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunLimits:
|
||||
"""Immutable step budget captured when a run is created."""
|
||||
|
||||
max_steps: int = 10_000
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if isinstance(self.max_steps, bool) or not isinstance(self.max_steps, int):
|
||||
raise TypeError("max_steps must be an integer")
|
||||
if self.max_steps < 1:
|
||||
raise ValueError("max_steps must be positive")
|
||||
|
||||
|
||||
__all__ = ["RunLimits"]
|
||||
@@ -2,14 +2,12 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from wf_core.models.reducers import ReducerRef
|
||||
from wf_core.models.workflow_refs import WorkflowRef
|
||||
from wf_core.paths import StatePath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wf_core.runtime.limits import RunLimits
|
||||
from wf_core.run_limits import RunLimits
|
||||
|
||||
ROOT_SCOPE_ID = "root"
|
||||
ROOT_LINEAGE_ID = "root"
|
||||
@@ -193,23 +191,12 @@ class InterruptRequest:
|
||||
step_number: int | None = None
|
||||
|
||||
|
||||
def _default_run_limits() -> RunLimits:
|
||||
"""Build the default budget without a top-level runtime import.
|
||||
|
||||
Importing ``wf_core.runtime.limits`` at module top would execute the
|
||||
``wf_core.runtime`` package, whose engine imports this module back.
|
||||
"""
|
||||
from wf_core.runtime.limits import RunLimits
|
||||
|
||||
return RunLimits()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunState:
|
||||
"""Mutable execution state for one workflow run.
|
||||
|
||||
``limits``/``steps_executed`` form the persisted run-wide step budget (see
|
||||
``wf_core.runtime.limits``); ``steps_remaining`` is computed from them.
|
||||
``wf_core.run_limits``); ``steps_remaining`` is computed from them.
|
||||
"""
|
||||
|
||||
workflow_name: str
|
||||
@@ -229,7 +216,7 @@ class RunState:
|
||||
activated_incoming_edge: str | None = None
|
||||
error: str | None = None
|
||||
interrupt: InterruptRequest | None = None
|
||||
limits: RunLimits = field(default_factory=_default_run_limits)
|
||||
limits: RunLimits = field(default_factory=RunLimits)
|
||||
steps_executed: int = 0
|
||||
|
||||
@property
|
||||
@@ -257,9 +244,3 @@ class RunState:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# Deferred runtime import: binding ``RunLimits`` here (after every class is
|
||||
# defined) lets ``wf_core.runtime`` engine modules import this module back
|
||||
# without a cycle, and gives pydantic a resolvable annotation for the codec.
|
||||
from wf_core.runtime.limits import RunLimits # noqa: E402
|
||||
|
||||
@@ -5,8 +5,8 @@ from typing import Any
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.models.workflow import Workflow
|
||||
from wf_core.run_limits import RunLimits
|
||||
from wf_core.run_state import ROOT_SCOPE_ID, RunState, RunStatus
|
||||
from wf_core.runtime.limits import RunLimits
|
||||
from wf_core.runtime.ops.flow import finalize_run
|
||||
from wf_core.runtime.ops.merges import ReducerDefinition
|
||||
from wf_core.runtime.ops.nodes import AsyncNodeHandler, NodeHandler
|
||||
|
||||
@@ -10,7 +10,6 @@ checkpoints; the counter is persisted inside the existing stopped-run envelope.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from wf_core.errors import WorkflowStepLimitExceeded
|
||||
@@ -19,19 +18,6 @@ if TYPE_CHECKING:
|
||||
from wf_core.run_state import ExecutionFrame, RunState
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunLimits:
|
||||
"""Immutable step budget captured when a run is created."""
|
||||
|
||||
max_steps: int = 10_000
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if isinstance(self.max_steps, bool) or not isinstance(self.max_steps, int):
|
||||
raise TypeError("max_steps must be an integer")
|
||||
if self.max_steps < 1:
|
||||
raise ValueError("max_steps must be positive")
|
||||
|
||||
|
||||
def admit_step_attempt(run: RunState, frame: ExecutionFrame, node_id: str) -> int:
|
||||
"""Admit one step attempt for ``frame`` about to dispatch ``node_id``.
|
||||
|
||||
@@ -50,4 +36,4 @@ def admit_step_attempt(run: RunState, frame: ExecutionFrame, node_id: str) -> in
|
||||
|
||||
def remaining_step_attempts(run: RunState) -> int:
|
||||
"""Return the unspent budget, floored at zero (never negative)."""
|
||||
return max(run.limits.max_steps - run.steps_executed, 0)
|
||||
return run.steps_remaining
|
||||
|
||||
@@ -4,6 +4,7 @@ from copy import deepcopy
|
||||
|
||||
from wf_core.models.workflow import Workflow
|
||||
from wf_core.paths import set_nested_value
|
||||
from wf_core.run_limits import RunLimits
|
||||
from wf_core.run_state import (
|
||||
ROOT_FRAME_ID,
|
||||
ROOT_LINEAGE_ID,
|
||||
@@ -15,7 +16,6 @@ from wf_core.run_state import (
|
||||
RunStatus,
|
||||
RuntimeScope,
|
||||
)
|
||||
from wf_core.runtime.limits import RunLimits
|
||||
from wf_core.runtime.scheduler import add_frame
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user