audit: walk serial owners in write routing, harden result identity

This commit is contained in:
lda
2026-09-04 11:43:41 +07:00 Verified
parent 8a0737fe8b
commit 08cd4e1e5e
6 changed files with 278 additions and 35 deletions
@@ -434,7 +434,14 @@ may independently return and complete the item.
Back-edge return changes control representation, not state semantics. Back-edge return changes control representation, not state semantics.
Iteration writes remain buffered in the item lineage. Serial behavior and the Iteration writes remain buffered in the item lineage. Serial behavior and the
concurrent barrier continue to commit or merge those writes according to the concurrent barrier continue to commit or merge those writes according to the
accepted concurrent-foreach ADR and declared reducers. The completed item is accepted concurrent-foreach ADR and declared reducers. One shared helper
routes every item write: it climbs through each serial owner to the scope
root, where it commits, or stops at the first concurrent item boundary,
where it buffers for that barrier to merge (the concurrent barrier finish
routes its combined patch through the same helper, so nested serial owners
cannot strand it). Parent cycles, missing parents, and orphaned item frames
fail closed. Buffered failure records must carry an error whose index and
frame match the enclosing result. The completed item is
registered with its barrier at the owner back-edge, keyed by the returning registered with its barrier at the owner back-edge, keyed by the returning
frame rather than by whichever operation ran last, so node, subgraph, and frame rather than by whichever operation ran last, so node, subgraph, and
nested-control endings all count. A return naming a closed or superseded nested-control endings all count. A return naming a closed or superseded
+18 -7
View File
@@ -138,16 +138,21 @@ class PendingItemResult:
raise WorkflowExecutionError( raise WorkflowExecutionError(
"malformed pending foreach result lineage id" "malformed pending foreach result lineage id"
) )
error = (
ItemErrorRecord.from_metadata(raw_error) if raw_error is not None else None
)
if error is not None and (error.index != index or error.frame_id != frame_id):
raise WorkflowExecutionError(
"malformed pending foreach result: error identity "
f"(index {error.index!r}, frame {error.frame_id!r}) does not "
f"match enclosing result (index {index!r}, frame {frame_id!r})"
)
return cls( return cls(
index=index, index=index,
frame_id=frame_id, frame_id=frame_id,
status=status, status=status,
lineage_id=lineage_id, lineage_id=lineage_id,
error=( error=error,
ItemErrorRecord.from_metadata(raw_error)
if raw_error is not None
else None
),
) )
def to_metadata(self) -> dict[str, Any]: def to_metadata(self) -> dict[str, Any]:
@@ -443,10 +448,16 @@ def item_frame_owner(frame: ExecutionFrame) -> ForeachItemOwner | None:
"""Return the named foreach ownership record for item frames. """Return the named foreach ownership record for item frames.
Malformed item metadata fails closed via ``ForeachIterationMetadata``; Malformed item metadata fails closed via ``ForeachIterationMetadata``;
only non-item frames return ``None``. only genuinely non-item frames return ``None``. An item frame without
a parent is corrupt state and raises rather than masquerading as an
ordinary frame.
""" """
if frame.kind != "foreach_iteration" or frame.parent_frame_id is None: if frame.kind != "foreach_iteration":
return None return None
if frame.parent_frame_id is None:
raise WorkflowExecutionError(
f"foreach item frame {frame.id!r} is missing its parent frame"
)
metadata = ForeachIterationMetadata.from_frame(frame) metadata = ForeachIterationMetadata.from_frame(frame)
if metadata is None: if metadata is None:
return None return None
+34 -25
View File
@@ -94,38 +94,47 @@ def commit_foreach_aware_patch(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Commit one write patch with foreach-aware routing. """Commit one write patch with foreach-aware routing.
Ordinary frames commit (or buffer) through their own lineage. Serial Ordinary frames commit (or buffer) through their own lineage. The walk
item writes commit through the parent scope so they land in root state; climbs through every serial item owner until it reaches either the
concurrent item writes stay buffered in the item lineage for the barrier workflow/subgraph scope root, where it commits, or a concurrent item
to merge. Malformed ownership, missing parents, and closed or boundary, where it buffers in that item lineage for the barrier to
superseded activations fail closed. merge. Malformed ownership, missing parents, parent cycles, and closed
or superseded activations fail closed.
""" """
from wf_core.runtime.foreach_state import ( from wf_core.runtime.foreach_state import (
item_frame_owner, item_frame_owner,
require_foreach_activation, require_foreach_activation,
) )
owner = item_frame_owner(frame) current = frame
if owner is None: seen: set[str] = set()
return commit_patch_for_frame(run, frame, patch) while True:
parent_frame = run.frames.get(owner.parent_frame_id) owner = item_frame_owner(current)
if parent_frame is None: if owner is None:
raise WorkflowExecutionError( return commit_patch_for_frame(run, current, patch)
"foreach item state references missing parent frame " if current.id in seen:
f"{owner.parent_frame_id!r} for child frame {frame.id!r}" raise WorkflowExecutionError(
f"cycle detected in foreach parent chain at frame {current.id!r}"
)
seen.add(current.id)
parent_frame = run.frames.get(owner.parent_frame_id)
if parent_frame is None:
raise WorkflowExecutionError(
"foreach item state references missing parent frame "
f"{owner.parent_frame_id!r} for child frame {current.id!r}"
)
activation = require_foreach_activation(
parent_frame, owner.foreach_node_id, owner.activation_id
) )
activation = require_foreach_activation( if activation.barrier.mode == "concurrent":
parent_frame, owner.foreach_node_id, owner.activation_id append_lineage_writes(
) run,
if activation.barrier.mode == "concurrent": scope_id=current.scope_id,
append_lineage_writes( lineage_id=current.lineage_id,
run, writes=patch.writes,
scope_id=frame.scope_id, )
lineage_id=frame.lineage_id, return {}
writes=patch.writes, current = parent_frame
)
return {}
return commit_patch_for_frame(run, parent_frame, patch)
def scope_state_for_frame(run: RunState, frame: ExecutionFrame) -> dict[str, Any]: def scope_state_for_frame(run: RunState, frame: ExecutionFrame) -> dict[str, Any]:
+2 -2
View File
@@ -18,7 +18,7 @@ from wf_core.runtime.foreach_state import (
) )
from wf_core.runtime.lineage import ( from wf_core.runtime.lineage import (
add_lineage, add_lineage,
commit_patch_for_frame, commit_foreach_aware_patch,
lineage_patch, lineage_patch,
scope_input_for_frame, scope_input_for_frame,
) )
@@ -379,7 +379,7 @@ def _finish_concurrent_foreach(
state_view_for_frame(run, frame), state_view_for_frame(run, frame),
reducers=reducers, reducers=reducers,
) )
state_changes = commit_patch_for_frame(run, frame, combined) state_changes = commit_foreach_aware_patch(run, frame, combined)
append_step_result_trace( append_step_result_trace(
run, run,
frame_id=frame.id, frame_id=frame.id,
+168
View File
@@ -427,6 +427,174 @@ def test_nested_foreach_returns_inner_then_outer() -> None:
assert run.state["seen"][:3] == [1, 2, "a"] assert run.state["seen"][:3] == [1, 2, "a"]
def _nested_mode_workflow(*, outer_mode: str, inner_mode: str) -> Workflow:
def _foreach(node_id: str, *, over: str, alias: str, mode: str) -> ForeachNode:
payload: dict[str, Any] = {
"id": node_id,
"type": "foreach",
"over": over,
"as": alias,
"mode": mode,
}
if mode == "concurrent":
payload["concurrent"] = {"max_active": 2, "max_outstanding": 2}
return ForeachNode.model_validate(payload)
return Workflow(
name="nested_foreach_modes",
input_schema=SchemaRef(type="object", properties={}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"inner_items": StateField(type="array"),
"seen": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}),
node_defs=[
NodeDef(
name="work",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["ok"],
)
],
start="outer",
nodes=[
_foreach("outer", over="state.items", alias="outer_item", mode=outer_mode),
_foreach(
"inner",
over="state.inner_items",
alias="inner_item",
mode=inner_mode,
),
NodeUse.model_validate(
{
"id": "work",
"type": "node",
"node": "work",
"input": [{"target": "value", "path": "context.inner_item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
],
edges=[
Edge.model_validate({"from": "outer", "outcome": "loop", "to": "inner"}),
Edge.model_validate({"from": "inner", "outcome": "loop", "to": "work"}),
Edge.model_validate({"from": "work", "outcome": "ok", "to": "inner"}),
# No intermediate writer: the inner barrier (or serial return)
# must route inner writes to the scope root on its own.
Edge.model_validate({"from": "inner", "outcome": "done", "to": "outer"}),
Edge.model_validate({"from": "outer", "outcome": "done", "to": END}),
],
)
@pytest.mark.parametrize(
("outer_mode", "inner_mode"),
[
("serial", "serial"),
("serial", "concurrent"),
("concurrent", "serial"),
("concurrent", "concurrent"),
],
)
def test_nested_foreach_preserves_inner_writes_in_all_modes(
outer_mode: str, inner_mode: str
) -> None:
"""Inner writes must reach root state whatever the nesting modes are.
Serial owners commit through the scope root; concurrent owners buffer
for their barrier. Every inner write (1, 2 per outer item) must survive
even with no intermediate writer to replay-rescue stranded lineages.
"""
workflow = _nested_mode_workflow(outer_mode=outer_mode, inner_mode=inner_mode)
run = execute_workflow(
workflow,
{"items": ["a", "b"], "inner_items": [1, 2]},
{
"work": lambda payload, _ctx: {
"outcome": "ok",
"output": {"seen": payload["value"]},
}
},
)
assert run.status == RunStatus.COMPLETED
assert sorted(run.state.get("seen") or [], key=repr) == sorted(
[1, 2, 1, 2], key=repr
)
def test_item_frame_owner_rejects_missing_parent_frame() -> None:
"""A foreach_iteration frame without a parent is malformed, not ordinary."""
frame = ExecutionFrame(
id="orphan",
kind="foreach_iteration",
node_id="work",
parent_frame_id=None,
metadata={
"foreach_node_id": "each",
"activation_id": "root:each#0",
"loop_index": 0,
"loop_item": "a",
"loop_alias": "item",
},
)
with pytest.raises(WorkflowExecutionError, match="parent"):
item_frame_owner(frame)
def test_foreach_aware_patch_rejects_parent_cycle() -> None:
"""A cyclic item-parent chain fails closed instead of looping forever."""
from wf_core.runtime.foreach_state import load_or_begin_foreach_activation
from wf_core.runtime.lineage import commit_foreach_aware_patch
from wf_core.runtime.ops.state import StatePatch
frame_a = ExecutionFrame(id="frame-a", kind="foreach_iteration", node_id="work")
frame_b = ExecutionFrame(id="frame-b", kind="foreach_iteration", node_id="work")
activation_on_b = load_or_begin_foreach_activation(frame_b, "each", mode="serial")
activation_on_a = load_or_begin_foreach_activation(frame_a, "each", mode="serial")
frame_a.parent_frame_id = "frame-b"
frame_a.metadata.update(
{
"foreach_node_id": "each",
"activation_id": activation_on_b.id,
"loop_index": 0,
"loop_item": "a",
"loop_alias": "item",
}
)
frame_b.parent_frame_id = "frame-a"
frame_b.metadata.update(
{
"foreach_node_id": "each",
"activation_id": activation_on_a.id,
"loop_index": 0,
"loop_item": "a",
"loop_alias": "item",
}
)
run = RunState(
workflow_name="parent_cycle",
status=RunStatus.RUNNING,
workflow_input={},
state={},
frames={"frame-a": frame_a, "frame-b": frame_b},
)
with pytest.raises(WorkflowExecutionError, match="cycle"):
commit_foreach_aware_patch(run, frame_a, StatePatch(changes={}))
def test_reentering_foreach_uses_fresh_activation_and_item_frames() -> None: def test_reentering_foreach_uses_fresh_activation_and_item_frames() -> None:
workflow = Workflow( workflow = Workflow(
name="foreach_reentry", name="foreach_reentry",
+48
View File
@@ -340,3 +340,51 @@ def test_pending_item_result_rejects_index_key_mismatch() -> None:
with pytest.raises(WorkflowExecutionError, match="index mismatch"): with pytest.raises(WorkflowExecutionError, match="index mismatch"):
ForeachBarrierState.from_metadata(raw["each"]["active"]["barrier"]) ForeachBarrierState.from_metadata(raw["each"]["active"]["barrier"])
def _failed_result(
*, index: int, frame_id: str, error_index: int, error_frame: str
) -> dict:
return {
"index": index,
"frame_id": frame_id,
"status": "failed",
"lineage_id": None,
"error": {
"index": error_index,
"frame_id": error_frame,
"node_id": "work",
"error_type": "ValueError",
"message": "bad",
},
}
def test_pending_item_result_rejects_error_index_mismatch() -> None:
with pytest.raises(WorkflowExecutionError, match="error.*index|index.*error"):
PendingItemResult.from_metadata(
_failed_result(
index=0, frame_id="child-0", error_index=7, error_frame="child-0"
)
)
def test_pending_item_result_rejects_error_frame_mismatch() -> None:
with pytest.raises(WorkflowExecutionError, match="error.*frame|frame.*error"):
PendingItemResult.from_metadata(
_failed_result(
index=0, frame_id="child-0", error_index=0, error_frame="other"
)
)
def test_pending_item_result_accepts_matching_error_identity() -> None:
result = PendingItemResult.from_metadata(
_failed_result(
index=0, frame_id="child-0", error_index=0, error_frame="child-0"
)
)
assert result.error is not None
assert result.error.index == 0
assert result.error.frame_id == "child-0"