audit: fix concurrent subgraph loss, fail-closed ownership, drop barrier compat
This commit is contained in:
@@ -642,6 +642,106 @@ def test_subgraph_end_returns_to_subgraph_node_then_foreach_owner() -> None:
|
||||
assert run.state["seen"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_concurrent_subgraph_item_returns_through_owner() -> None:
|
||||
from wf_core import PreparedSubgraph
|
||||
|
||||
child = Workflow(
|
||||
name="child",
|
||||
input_schema=SchemaRef(type="object", properties={"value": {}}),
|
||||
state_schema=StateSchema.from_field_map({"seen": StateField(type="string")}),
|
||||
output_schema=SchemaRef(type="object", properties={"seen": {}}),
|
||||
node_defs=[
|
||||
NodeDef(
|
||||
name="inner_record",
|
||||
input_schema=SchemaRef(
|
||||
type="object", properties={"value": {}}, required=["value"]
|
||||
),
|
||||
output_schema=SchemaRef(
|
||||
type="object", properties={"seen": {}}, required=["seen"]
|
||||
),
|
||||
outcomes=["ok"],
|
||||
)
|
||||
],
|
||||
start="inner_record",
|
||||
nodes=[
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "inner_record",
|
||||
"type": "node",
|
||||
"node": "inner_record",
|
||||
"input": [{"target": "value", "path": "input.value"}],
|
||||
"output": [{"source": "seen", "target": "state.seen"}],
|
||||
}
|
||||
)
|
||||
],
|
||||
edges=[
|
||||
Edge.model_validate({"from": "inner_record", "outcome": "ok", "to": END})
|
||||
],
|
||||
)
|
||||
foreach = ForeachNode.model_validate(
|
||||
{
|
||||
"id": "each",
|
||||
"type": "foreach",
|
||||
"over": "state.items",
|
||||
"as": "item",
|
||||
"mode": "concurrent",
|
||||
"concurrent": {"max_active": 2, "max_outstanding": 2},
|
||||
}
|
||||
)
|
||||
workflow = Workflow(
|
||||
name="foreach_concurrent_subgraph",
|
||||
input_schema=SchemaRef(type="object", properties={"items": {"type": "array"}}),
|
||||
state_schema=StateSchema.from_field_map(
|
||||
{
|
||||
"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=[],
|
||||
start="each",
|
||||
nodes=[
|
||||
foreach,
|
||||
SubgraphNode.model_validate(
|
||||
{
|
||||
"id": "child",
|
||||
"type": "subgraph",
|
||||
"workflow": "child.workflow",
|
||||
"input_schema": {"type": "object", "properties": {"value": {}}},
|
||||
"output_schema": {"type": "object", "properties": {"seen": {}}},
|
||||
"input": [{"target": "value", "path": "context.item"}],
|
||||
"output": [{"source": "seen", "target": "state.seen"}],
|
||||
"outcomes": ["ok"],
|
||||
}
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
Edge.model_validate({"from": "each", "outcome": "loop", "to": "child"}),
|
||||
Edge.model_validate({"from": "child", "outcome": "ok", "to": "each"}),
|
||||
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
|
||||
],
|
||||
)
|
||||
|
||||
run = execute_workflow(
|
||||
workflow,
|
||||
{"items": ["a", "b"]},
|
||||
{},
|
||||
subgraphs={
|
||||
"child.workflow": PreparedSubgraph(
|
||||
workflow=child,
|
||||
registry={
|
||||
"inner_record": lambda payload, _ctx: {"seen": payload["value"]}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert run.status == RunStatus.COMPLETED
|
||||
assert sorted(run.state["seen"]) == ["a", "b"]
|
||||
|
||||
|
||||
def test_nonlocal_runtime_return_fails_closed_when_validation_is_bypassed() -> None:
|
||||
run = RunState(
|
||||
workflow_name="nonlocal",
|
||||
|
||||
@@ -5,107 +5,77 @@ import pytest
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.models.reducers import ReducerRef
|
||||
from wf_core.paths import StatePath
|
||||
from wf_core.run_state import ExecutionFrame, RunState, RunStatus, StateWrite
|
||||
from wf_core.run_state import (
|
||||
ExecutionFrame,
|
||||
LineageState,
|
||||
RunState,
|
||||
RunStatus,
|
||||
RuntimeScope,
|
||||
StateWrite,
|
||||
)
|
||||
from wf_core.runtime.foreach_state import (
|
||||
ForeachBarrierState,
|
||||
ForeachItemOwner,
|
||||
ItemErrorRecord,
|
||||
PendingItemResult,
|
||||
_state_write_from_metadata,
|
||||
item_frame_owner,
|
||||
load_foreach_activation,
|
||||
load_or_begin_foreach_activation,
|
||||
save_foreach_activation,
|
||||
)
|
||||
from wf_core.runtime.lineage import LineageStateView, lineage_writes_for_frame
|
||||
from wf_core.runtime.ops.state import StatePatch
|
||||
from wf_core.runtime.lineage import (
|
||||
LineageStateView,
|
||||
add_lineage,
|
||||
append_lineage_writes,
|
||||
lineage_writes_for_frame,
|
||||
)
|
||||
|
||||
|
||||
def test_foreach_barrier_state_round_trips_through_frame_metadata() -> None:
|
||||
def test_foreach_barrier_state_round_trips_through_activation_metadata() -> None:
|
||||
frame = ExecutionFrame(id="root", kind="root", node_id="each")
|
||||
barrier = ForeachBarrierState(
|
||||
next_index=2,
|
||||
active_frame_ids=("child-1",),
|
||||
outstanding_frame_ids=("child-1", "child-2"),
|
||||
pending_results={
|
||||
1: PendingItemResult(
|
||||
index=1,
|
||||
frame_id="child-1",
|
||||
status="failed",
|
||||
patch=StatePatch(changes={"state.count": 1}),
|
||||
error=ItemErrorRecord(
|
||||
index=1,
|
||||
frame_id="child-1",
|
||||
node_id="work",
|
||||
error_type="ValueError",
|
||||
message="bad item",
|
||||
item={"id": "a"},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
barrier.save_to_frame(frame, "each")
|
||||
loaded = ForeachBarrierState.from_frame(frame, "each")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.next_index == 2
|
||||
assert loaded.active_frame_ids == ("child-1",)
|
||||
assert loaded.outstanding_frame_ids == ("child-1", "child-2")
|
||||
assert loaded.pending_results[1].patch.changes["state.count"] == 1
|
||||
assert loaded.pending_results[1].error is not None
|
||||
assert loaded.pending_results[1].error.message == "bad item"
|
||||
|
||||
|
||||
def test_foreach_barrier_state_round_trips_reducer_write_records() -> None:
|
||||
frame = ExecutionFrame(id="root", kind="root", node_id="each")
|
||||
barrier = ForeachBarrierState(
|
||||
next_index=1,
|
||||
mode="concurrent",
|
||||
pending_results={
|
||||
0: PendingItemResult(
|
||||
index=0,
|
||||
frame_id="child-0",
|
||||
status="succeeded",
|
||||
lineage_id="root/each[0]",
|
||||
patch=StatePatch(
|
||||
writes=[
|
||||
StateWrite(
|
||||
path=StatePath(("count",)),
|
||||
incoming_value=3,
|
||||
visible_value=5,
|
||||
reducer=ReducerRef(name="wf.std.add"),
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
barrier.save_to_frame(frame, "each")
|
||||
loaded = ForeachBarrierState.from_frame(frame, "each")
|
||||
|
||||
assert loaded is not None
|
||||
write = loaded.pending_results[0].patch.writes[0]
|
||||
assert loaded.pending_results[0].lineage_id == "root/each[0]"
|
||||
assert write.path == StatePath(("count",))
|
||||
assert write.incoming_value == 3
|
||||
assert write.visible_value == 5
|
||||
assert write.reducer.name == "wf.std.add"
|
||||
|
||||
|
||||
def test_pending_write_metadata_preserves_invalid_reducer_detail() -> None:
|
||||
with pytest.raises(WorkflowExecutionError, match="mutually exclusive"):
|
||||
_state_write_from_metadata(
|
||||
{
|
||||
"path": {"root": "state", "parts": ["count"]},
|
||||
"incoming_value": 1,
|
||||
"visible_value": 1,
|
||||
"reducer": {
|
||||
"name": "wf.std.add",
|
||||
"ref": {"source": "wf.std", "capability_key": "add"},
|
||||
},
|
||||
}
|
||||
activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
activation.barrier.next_index = 2
|
||||
activation.barrier.start_child("child-1")
|
||||
activation.barrier.start_child("child-2")
|
||||
activation.barrier.finish_child("child-2")
|
||||
activation.barrier.add_failure(
|
||||
error=ItemErrorRecord(
|
||||
index=1,
|
||||
frame_id="child-1",
|
||||
node_id="work",
|
||||
error_type="ValueError",
|
||||
message="bad item",
|
||||
item={"id": "a"},
|
||||
)
|
||||
)
|
||||
save_foreach_activation(frame, activation)
|
||||
|
||||
loaded = load_foreach_activation(frame, "each", activation.id)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.barrier.next_index == 2
|
||||
assert loaded.barrier.active_frame_ids == ("child-1",)
|
||||
assert loaded.barrier.outstanding_frame_ids == ("child-1",)
|
||||
assert loaded.barrier.pending_results[1].status == "failed"
|
||||
assert loaded.barrier.pending_results[1].error is not None
|
||||
assert loaded.barrier.pending_results[1].error.message == "bad item"
|
||||
|
||||
|
||||
def test_concurrent_success_round_trips_lineage_identity() -> None:
|
||||
frame = ExecutionFrame(id="root", kind="root", node_id="each")
|
||||
activation = load_or_begin_foreach_activation(frame, "each", mode="concurrent")
|
||||
activation.barrier.add_success_patch(
|
||||
index=0,
|
||||
frame_id="child-0",
|
||||
lineage_id="root:each#0[0]",
|
||||
)
|
||||
save_foreach_activation(frame, activation)
|
||||
|
||||
loaded = load_foreach_activation(frame, "each", activation.id)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.barrier.pending_results[0].lineage_id == "root:each#0[0]"
|
||||
assert loaded.barrier.pending_results[0].status == "succeeded"
|
||||
|
||||
|
||||
def test_lineage_state_view_materializes_visible_values_without_mutating_base() -> None:
|
||||
@@ -136,7 +106,7 @@ def test_lineage_state_view_materializes_visible_values_without_mutating_base()
|
||||
assert base_state["nested"]["value"] == "old"
|
||||
|
||||
|
||||
def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None:
|
||||
def test_lineage_writes_for_frame_reads_item_lineage_store() -> None:
|
||||
parent = ExecutionFrame(id="root", kind="workflow", node_id="each")
|
||||
activation = load_or_begin_foreach_activation(parent, "each", mode="concurrent")
|
||||
child_lineage_id = f"{activation.id}[0]"
|
||||
@@ -160,23 +130,6 @@ def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None
|
||||
assert isinstance(owner, ForeachItemOwner)
|
||||
assert owner.activation_id == activation.id
|
||||
assert owner.item_index == 0
|
||||
patch = StatePatch(
|
||||
writes=[
|
||||
StateWrite(
|
||||
path=StatePath(("count",)),
|
||||
incoming_value=3,
|
||||
visible_value=5,
|
||||
reducer=ReducerRef(name="wf.std.add"),
|
||||
)
|
||||
]
|
||||
)
|
||||
activation.barrier.pending_results[0] = PendingItemResult(
|
||||
index=0,
|
||||
frame_id=child.id,
|
||||
status="succeeded",
|
||||
lineage_id=child.lineage_id,
|
||||
patch=patch,
|
||||
)
|
||||
save_foreach_activation(parent, activation)
|
||||
run = RunState(
|
||||
workflow_name="lineage",
|
||||
@@ -185,6 +138,27 @@ def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None
|
||||
state={"count": 2},
|
||||
frames={parent.id: parent, child.id: child},
|
||||
)
|
||||
run.scopes["root"] = RuntimeScope(
|
||||
id="root",
|
||||
workflow_name="lineage",
|
||||
workflow_input={},
|
||||
committed_state=run.state,
|
||||
)
|
||||
run.lineages["root"] = LineageState(id="root", scope_id="root")
|
||||
add_lineage(run, scope_id="root", lineage_id=child_lineage_id, parent_id="root")
|
||||
append_lineage_writes(
|
||||
run,
|
||||
scope_id="root",
|
||||
lineage_id=child_lineage_id,
|
||||
writes=[
|
||||
StateWrite(
|
||||
path=StatePath(("count",)),
|
||||
incoming_value=3,
|
||||
visible_value=5,
|
||||
reducer=ReducerRef(name="wf.std.add"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
writes = lineage_writes_for_frame(run, child)
|
||||
|
||||
@@ -193,48 +167,16 @@ def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None
|
||||
assert writes[0].visible_value == 5
|
||||
|
||||
|
||||
def test_lineage_writes_for_frame_rejects_missing_compatibility_parent_frame() -> None:
|
||||
child = ExecutionFrame(
|
||||
id="missing:each#0:0",
|
||||
kind="foreach_iteration",
|
||||
node_id="work",
|
||||
parent_frame_id="missing",
|
||||
metadata={
|
||||
"foreach_node_id": "each",
|
||||
"activation_id": "missing:each#0",
|
||||
"loop_index": 0,
|
||||
"loop_item": "a",
|
||||
"loop_alias": "item",
|
||||
},
|
||||
)
|
||||
run = RunState(
|
||||
workflow_name="lineage",
|
||||
status=RunStatus.PENDING,
|
||||
workflow_input={},
|
||||
state={},
|
||||
frames={child.id: child},
|
||||
)
|
||||
def test_load_activation_returns_none_when_missing() -> None:
|
||||
from wf_core.runtime.foreach_state import close_foreach_activation
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="missing parent frame"):
|
||||
lineage_writes_for_frame(run, child)
|
||||
|
||||
|
||||
def test_foreach_barrier_state_returns_none_when_missing() -> None:
|
||||
frame = ExecutionFrame(id="root", kind="root", node_id="each")
|
||||
activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
save_foreach_activation(frame, activation)
|
||||
close_foreach_activation(frame, activation)
|
||||
|
||||
assert ForeachBarrierState.from_frame(frame, "each") is None
|
||||
|
||||
|
||||
def test_foreach_barrier_state_rejects_malformed_metadata() -> None:
|
||||
frame = ExecutionFrame(
|
||||
id="root",
|
||||
kind="root",
|
||||
node_id="each",
|
||||
metadata={"foreach_barriers": {"each": {"next_index": "bad"}}},
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="next_index"):
|
||||
ForeachBarrierState.from_frame(frame, "each")
|
||||
assert load_foreach_activation(frame, "each", activation.id) is None
|
||||
assert load_foreach_activation(frame, "each", "root:each#99") is None
|
||||
|
||||
|
||||
def test_foreach_barrier_tracks_active_and_outstanding_children() -> None:
|
||||
@@ -269,39 +211,44 @@ def test_foreach_barrier_rejects_finishing_unknown_child() -> None:
|
||||
barrier.finish_child("child-0")
|
||||
|
||||
|
||||
def test_foreach_barrier_accumulates_multiple_patches_for_one_item() -> None:
|
||||
def test_foreach_barrier_success_registration_is_idempotent_for_same_lineage() -> None:
|
||||
barrier = ForeachBarrierState(mode="concurrent")
|
||||
patch = StatePatch(changes={"state.count": 1})
|
||||
second_patch = StatePatch(changes={"state.name": "a"})
|
||||
|
||||
barrier.add_success_patch(
|
||||
index=0,
|
||||
frame_id="child-0",
|
||||
patch=patch,
|
||||
lineage_id="root/each[0]",
|
||||
lineage_id="root:each#0[0]",
|
||||
)
|
||||
barrier.add_success_patch(
|
||||
index=0,
|
||||
frame_id="child-0",
|
||||
patch=second_patch,
|
||||
lineage_id="root/each[0]",
|
||||
lineage_id="root:each#0[0]",
|
||||
)
|
||||
|
||||
result = barrier.pending_results[0]
|
||||
assert result.lineage_id == "root/each[0]"
|
||||
assert result.patch.changes["state.count"] == 1
|
||||
assert result.patch.changes["state.name"] == "a"
|
||||
assert len(result.patch.writes) == 2
|
||||
assert result.status == "succeeded"
|
||||
assert result.lineage_id == "root:each#0[0]"
|
||||
|
||||
|
||||
def test_foreach_barrier_rejects_success_lineage_mismatch() -> None:
|
||||
barrier = ForeachBarrierState(mode="concurrent")
|
||||
barrier.add_success_patch(index=0, frame_id="child-0", lineage_id="root:each#0[0]")
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="belongs to lineage"):
|
||||
barrier.add_success_patch(
|
||||
index=0, frame_id="child-0", lineage_id="root:each#0[1]"
|
||||
)
|
||||
|
||||
|
||||
def test_foreach_barrier_rejects_item_result_frame_mismatch() -> None:
|
||||
barrier = ForeachBarrierState(mode="concurrent")
|
||||
patch = StatePatch(changes={"state.count": 1})
|
||||
|
||||
barrier.add_success_patch(index=0, frame_id="child-0", patch=patch)
|
||||
barrier.add_success_patch(index=0, frame_id="child-0", lineage_id="root:each#0[0]")
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="belongs to frame"):
|
||||
barrier.add_success_patch(index=0, frame_id="child-1", patch=patch)
|
||||
barrier.add_success_patch(
|
||||
index=0, frame_id="child-1", lineage_id="root:each#0[0]"
|
||||
)
|
||||
|
||||
|
||||
def test_item_error_record_rejects_negative_index() -> None:
|
||||
@@ -333,16 +280,13 @@ def test_pending_item_result_rejects_negative_index() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_save_to_frame_rejects_corrupt_table_without_mutating() -> None:
|
||||
frame = ExecutionFrame(
|
||||
id="root",
|
||||
kind="root",
|
||||
node_id="each",
|
||||
metadata={"foreach_barriers": "corrupt"},
|
||||
)
|
||||
barrier = ForeachBarrierState(next_index=1)
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="barrier table"):
|
||||
barrier.save_to_frame(frame, "each")
|
||||
|
||||
assert frame.metadata["foreach_barriers"] == "corrupt"
|
||||
def test_pending_item_result_requires_lineage_for_success() -> None:
|
||||
with pytest.raises(WorkflowExecutionError, match="lineage"):
|
||||
PendingItemResult.from_metadata(
|
||||
{
|
||||
"index": 0,
|
||||
"frame_id": "child",
|
||||
"status": "succeeded",
|
||||
"lineage_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user