feat: identify dynamic foreach activations
This commit is contained in:
@@ -19,7 +19,11 @@ from wf_core import (
|
||||
execute_workflow,
|
||||
)
|
||||
from wf_core.run_state import ExecutionFrame, RunState, RuntimeContext
|
||||
from wf_core.runtime.foreach_state import ForeachBarrierState
|
||||
from wf_core.runtime.foreach_state import (
|
||||
ForeachItemOwner,
|
||||
item_frame_owner,
|
||||
load_or_begin_foreach_activation,
|
||||
)
|
||||
from wf_core.runtime.scheduler import ForeachIterationMetadata
|
||||
|
||||
|
||||
@@ -143,7 +147,11 @@ def test_concurrent_foreach_item_frames_use_distinct_lineages() -> None:
|
||||
assert run.frames["root"].lineage_id == "root"
|
||||
assert run.frames["root"].parent_lineage_id is None
|
||||
assert len(item_frames) == 2
|
||||
assert item_lineage_ids == {"root/each[0]", "root/each[1]"}
|
||||
assert item_lineage_ids == {"root:each#0[0]", "root:each#0[1]"}
|
||||
for frame in item_frames:
|
||||
owner = item_frame_owner(frame)
|
||||
assert isinstance(owner, ForeachItemOwner)
|
||||
assert owner.activation_id == "root:each#0"
|
||||
assert set(context_lineage_ids) == item_lineage_ids
|
||||
assert all(frame.scope_id == "root" for frame in item_frames)
|
||||
assert all(frame.parent_lineage_id == "root" for frame in item_frames)
|
||||
@@ -162,15 +170,27 @@ def test_nested_concurrent_foreach_records_parent_child_lineages() -> None:
|
||||
inner_frames = _foreach_frames(run, "inner_each")
|
||||
|
||||
assert {frame.lineage_id for frame in outer_frames} == {
|
||||
"root/outer_each[0]",
|
||||
"root/outer_each[1]",
|
||||
"root:outer_each#0[0]",
|
||||
"root:outer_each#0[1]",
|
||||
}
|
||||
assert all(frame.parent_lineage_id == "root" for frame in outer_frames)
|
||||
assert {(frame.parent_lineage_id, frame.lineage_id) for frame in inner_frames} == {
|
||||
("root/outer_each[0]", "root/outer_each[0]/inner_each[0]"),
|
||||
("root/outer_each[0]", "root/outer_each[0]/inner_each[1]"),
|
||||
("root/outer_each[1]", "root/outer_each[1]/inner_each[0]"),
|
||||
("root/outer_each[1]", "root/outer_each[1]/inner_each[1]"),
|
||||
(
|
||||
"root:outer_each#0[0]",
|
||||
"root:outer_each#0:0:inner_each#0[0]",
|
||||
),
|
||||
(
|
||||
"root:outer_each#0[0]",
|
||||
"root:outer_each#0:0:inner_each#0[1]",
|
||||
),
|
||||
(
|
||||
"root:outer_each#0[1]",
|
||||
"root:outer_each#0:1:inner_each#0[0]",
|
||||
),
|
||||
(
|
||||
"root:outer_each#0[1]",
|
||||
"root:outer_each#0:1:inner_each#0[1]",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -264,12 +284,14 @@ def test_sync_concurrent_foreach_barrier_replays_add_reducer_inputs() -> None:
|
||||
|
||||
assert run.state["number"] == 6
|
||||
assert run.output["number"] == 6
|
||||
assert run.lineages["root/each[0]"].writes[0].incoming_value == 3
|
||||
assert run.lineages["root/each[1]"].writes[0].incoming_value == 1
|
||||
barrier = ForeachBarrierState.from_frame(run.frames["root"], "each")
|
||||
assert barrier is not None
|
||||
assert barrier.pending_results[0].lineage_id == "root/each[0]"
|
||||
assert barrier.pending_results[0].patch.writes == []
|
||||
assert run.lineages["root:each#0[0]"].writes[0].incoming_value == 3
|
||||
assert run.lineages["root:each#0[1]"].writes[0].incoming_value == 1
|
||||
active = load_or_begin_foreach_activation(
|
||||
run.frames["root"], "each", mode="concurrent"
|
||||
)
|
||||
assert active.id == "root:each#0"
|
||||
assert active.barrier.pending_results[0].lineage_id == "root:each#0[0]"
|
||||
assert active.barrier.pending_results[0].patch.writes == []
|
||||
foreach_entries = [entry for entry in run.trace if entry.step_type == "foreach"]
|
||||
assert foreach_entries[-1].state_changes["state.number"] == 6
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ def test_concurrent_foreach_skip_emits_completed_with_errors() -> None:
|
||||
)
|
||||
|
||||
assert run.state["seen"] == ["a", "c"]
|
||||
assert run.frames["root:each:1"].status == "failed"
|
||||
assert run.frames["root:each#0:1"].status == "failed"
|
||||
foreach_entries = [entry for entry in run.trace if entry.step_type == "foreach"]
|
||||
assert foreach_entries[-1].outcome == "completed_with_errors"
|
||||
assert foreach_entries[-1].resolved_input["failed_items"] == 1
|
||||
@@ -47,7 +47,7 @@ def test_concurrent_foreach_collect_writes_ordered_error_records() -> None:
|
||||
assert len(run.state["errors"]) == 1
|
||||
error = run.state["errors"][0]
|
||||
assert error["index"] == 1
|
||||
assert error["frame_id"] == "root:each:1"
|
||||
assert error["frame_id"] == "root:each#0:1"
|
||||
assert error["node_id"] == "record"
|
||||
assert error["error_type"] == "ValueError"
|
||||
assert error["message"] == "bad item"
|
||||
|
||||
@@ -31,8 +31,8 @@ async def test_concurrent_foreach_interrupt_returns_before_refill() -> None:
|
||||
assert run.status is RunStatus.INTERRUPTED
|
||||
assert run.interrupt is not None
|
||||
assert run.interrupt.payload["item"] == "b"
|
||||
assert run.frames["root:each:1"].status == "interrupted"
|
||||
assert "root:each:2" not in run.frames
|
||||
assert run.frames["root:each#0:1"].status == "interrupted"
|
||||
assert "root:each#0:2" not in run.frames
|
||||
assert "seen" not in run.state
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ async def test_resume_prioritizes_interrupted_item_before_siblings() -> None:
|
||||
|
||||
assert resumed.status is RunStatus.COMPLETED
|
||||
assert resumed.state["seen"] == ["a", "b", "c"]
|
||||
assert resumed.trace[interrupted_trace_len].frame_id == "root:each:1"
|
||||
assert resumed.trace[interrupted_trace_len].frame_id == "root:each#0:1"
|
||||
assert resumed.trace[interrupted_trace_len].step_type == "interrupt"
|
||||
assert resumed.trace[interrupted_trace_len].outcome == "submitted"
|
||||
foreach_entries = [entry for entry in resumed.trace if entry.step_type == "foreach"]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
from wf_core.run_state import ExecutionFrame
|
||||
from wf_core.runtime.foreach_state import (
|
||||
close_foreach_activation,
|
||||
item_frame_owner,
|
||||
load_or_begin_foreach_activation,
|
||||
save_foreach_activation,
|
||||
)
|
||||
from wf_core.runtime.scheduler import ForeachIterationMetadata
|
||||
|
||||
|
||||
def _frame() -> ExecutionFrame:
|
||||
return ExecutionFrame(id="root", kind="workflow", node_id="each")
|
||||
|
||||
|
||||
def test_activation_lifecycle_reuses_active_then_fresh_after_close() -> None:
|
||||
frame = _frame()
|
||||
|
||||
first = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
save_foreach_activation(frame, first)
|
||||
restored = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
|
||||
assert restored.id == first.id
|
||||
|
||||
close_foreach_activation(frame, restored)
|
||||
second = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
|
||||
assert second.id != first.id
|
||||
assert second.barrier.next_index == 0
|
||||
|
||||
|
||||
def test_activation_rejects_malformed_metadata() -> None:
|
||||
frame = ExecutionFrame(
|
||||
id="root",
|
||||
kind="workflow",
|
||||
node_id="each",
|
||||
metadata={"foreach_activations": "corrupt"},
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="activation"):
|
||||
load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
|
||||
|
||||
def test_activation_rejects_mode_mismatch() -> None:
|
||||
frame = _frame()
|
||||
activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
save_foreach_activation(frame, activation)
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="mode"):
|
||||
load_or_begin_foreach_activation(frame, "each", mode="concurrent")
|
||||
|
||||
|
||||
def test_closing_stale_activation_fails_closed() -> None:
|
||||
frame = _frame()
|
||||
first = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
save_foreach_activation(frame, first)
|
||||
close_foreach_activation(frame, first)
|
||||
second = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
save_foreach_activation(frame, second)
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="stale|closed|active"):
|
||||
close_foreach_activation(frame, first)
|
||||
|
||||
|
||||
def test_activation_json_round_trip_through_frame_metadata() -> None:
|
||||
frame = _frame()
|
||||
activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
activation.barrier.next_index = 2
|
||||
save_foreach_activation(frame, activation)
|
||||
|
||||
dumped = dict(frame.metadata)
|
||||
restored_frame = ExecutionFrame(
|
||||
id="root", kind="workflow", node_id="each", metadata=dumped
|
||||
)
|
||||
restored = load_or_begin_foreach_activation(restored_frame, "each", mode="serial")
|
||||
|
||||
assert restored.id == activation.id
|
||||
assert restored.barrier.next_index == 2
|
||||
|
||||
|
||||
def test_item_metadata_requires_activation_identity() -> None:
|
||||
frame = ExecutionFrame(
|
||||
id="root:each#0:0",
|
||||
kind="foreach_iteration",
|
||||
node_id="work",
|
||||
parent_frame_id="root",
|
||||
metadata={
|
||||
"foreach_node_id": "each",
|
||||
"loop_index": 0,
|
||||
"loop_item": "a",
|
||||
"loop_alias": "item",
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="activation"):
|
||||
ForeachIterationMetadata.from_frame(frame)
|
||||
with pytest.raises(WorkflowExecutionError, match="activation"):
|
||||
item_frame_owner(frame)
|
||||
@@ -8,9 +8,13 @@ from wf_core.paths import StatePath
|
||||
from wf_core.run_state import ExecutionFrame, RunState, RunStatus, StateWrite
|
||||
from wf_core.runtime.foreach_state import (
|
||||
ForeachBarrierState,
|
||||
ForeachItemOwner,
|
||||
ItemErrorRecord,
|
||||
PendingItemResult,
|
||||
_state_write_from_metadata,
|
||||
item_frame_owner,
|
||||
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
|
||||
@@ -134,20 +138,28 @@ def test_lineage_state_view_materializes_visible_values_without_mutating_base()
|
||||
|
||||
def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> 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]"
|
||||
child = ExecutionFrame(
|
||||
id="root:each:0",
|
||||
id=f"{activation.id}:0",
|
||||
kind="foreach_iteration",
|
||||
node_id="work",
|
||||
parent_frame_id="root",
|
||||
lineage_id="root/each[0]",
|
||||
lineage_id=child_lineage_id,
|
||||
parent_lineage_id="root",
|
||||
metadata={
|
||||
"foreach_node_id": "each",
|
||||
"activation_id": activation.id,
|
||||
"loop_index": 0,
|
||||
"loop_item": "a",
|
||||
"loop_alias": "item",
|
||||
},
|
||||
)
|
||||
# Ownership is named, not positional.
|
||||
owner = item_frame_owner(child)
|
||||
assert isinstance(owner, ForeachItemOwner)
|
||||
assert owner.activation_id == activation.id
|
||||
assert owner.item_index == 0
|
||||
patch = StatePatch(
|
||||
writes=[
|
||||
StateWrite(
|
||||
@@ -158,19 +170,14 @@ def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None
|
||||
)
|
||||
]
|
||||
)
|
||||
barrier = ForeachBarrierState(
|
||||
mode="concurrent",
|
||||
pending_results={
|
||||
0: PendingItemResult(
|
||||
index=0,
|
||||
frame_id=child.id,
|
||||
status="succeeded",
|
||||
lineage_id=child.lineage_id,
|
||||
patch=patch,
|
||||
)
|
||||
},
|
||||
activation.barrier.pending_results[0] = PendingItemResult(
|
||||
index=0,
|
||||
frame_id=child.id,
|
||||
status="succeeded",
|
||||
lineage_id=child.lineage_id,
|
||||
patch=patch,
|
||||
)
|
||||
barrier.save_to_frame(parent, "each")
|
||||
save_foreach_activation(parent, activation)
|
||||
run = RunState(
|
||||
workflow_name="lineage",
|
||||
status=RunStatus.PENDING,
|
||||
@@ -188,12 +195,13 @@ def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None
|
||||
|
||||
def test_lineage_writes_for_frame_rejects_missing_compatibility_parent_frame() -> None:
|
||||
child = ExecutionFrame(
|
||||
id="missing:each:0",
|
||||
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",
|
||||
|
||||
@@ -163,8 +163,19 @@ def test_child_completion_wakes_blocked_parent() -> None:
|
||||
|
||||
|
||||
def test_wake_parent_when_child_finishes_for_refill() -> None:
|
||||
from wf_core.runtime.foreach_state import (
|
||||
ForeachItemOwner,
|
||||
item_frame_owner,
|
||||
load_or_begin_foreach_activation,
|
||||
save_foreach_activation,
|
||||
)
|
||||
|
||||
run = _run()
|
||||
add_frame(run, ExecutionFrame(id="parent", kind="root", node_id="foreach"))
|
||||
activation = load_or_begin_foreach_activation(
|
||||
run.frames["parent"], "foreach", mode="serial"
|
||||
)
|
||||
save_foreach_activation(run.frames["parent"], activation)
|
||||
add_frame(
|
||||
run,
|
||||
ExecutionFrame(
|
||||
@@ -172,11 +183,22 @@ def test_wake_parent_when_child_finishes_for_refill() -> None:
|
||||
kind="foreach_iteration",
|
||||
node_id="__end__",
|
||||
parent_frame_id="parent",
|
||||
metadata={
|
||||
"foreach_node_id": "foreach",
|
||||
"activation_id": activation.id,
|
||||
"loop_index": 0,
|
||||
"loop_item": "a",
|
||||
"loop_alias": "item",
|
||||
},
|
||||
),
|
||||
)
|
||||
block_frame_on_children(run, "parent", ("child", "other"))
|
||||
run.frames["child"].status = FrameStatus.COMPLETED
|
||||
|
||||
owner = item_frame_owner(run.frames["child"])
|
||||
assert isinstance(owner, ForeachItemOwner)
|
||||
assert owner.activation_id == activation.id
|
||||
|
||||
wake_parent_for_child_progress(run, "child")
|
||||
|
||||
assert run.frames["parent"].status == FrameStatus.PENDING
|
||||
|
||||
Reference in New Issue
Block a user