concurrent foreach preparation, types, validation, refactors

This commit is contained in:
lda
2026-05-22 12:11:51 +07:00 Verified
parent afafe40109
commit d8d5770c9f
17 changed files with 712 additions and 15 deletions
+40 -1
View File
@@ -17,7 +17,11 @@ from wf_core import (
from wf_core.models.steps import OutputBinding
from wf_core.runtime.engine import resume_workflow
from wf_core.runtime.ops.runs import create_run_state
from wf_core.runtime.ops.state import apply_output_bindings
from wf_core.runtime.ops.state import (
apply_output_bindings,
build_output_patch,
commit_state_patch,
)
def test_output_bindings_commit_patch_atomically_when_source_is_missing() -> None:
@@ -171,6 +175,41 @@ def test_full_workflow_execution_writes_canonical_output_bindings() -> None:
assert run.trace[0].state_changes["state.person.name"] == "Ada"
def test_build_output_patch_does_not_mutate_until_commit() -> None:
workflow = _workflow(fields={"person.name": StateField(type="string")})
state = {"person": {"name": "old"}}
patch = build_output_patch(
workflow,
[_binding("person.name", "state.person.name")],
{"person": {"name": "Ada"}},
state,
)
assert state["person"]["name"] == "old"
assert patch.changes["state.person.name"] == "Ada"
committed = commit_state_patch(state, patch)
assert committed["state.person.name"] == "Ada"
assert state["person"]["name"] == "Ada"
def test_build_and_commit_patch_matches_apply_output_bindings() -> None:
workflow = _workflow(fields={"person.name": StateField(type="string")})
state_from_apply = {"person": {"name": "old"}}
state_from_patch = {"person": {"name": "old"}}
bindings = [_binding("person.name", "state.person.name")]
output = {"person": {"name": "Ada"}}
applied = apply_output_bindings(workflow, bindings, output, state_from_apply)
patch = build_output_patch(workflow, bindings, output, state_from_patch)
committed = commit_state_patch(state_from_patch, patch)
assert applied["state.person.name"] == committed["state.person.name"]
assert state_from_apply["person"]["name"] == state_from_patch["person"]["name"]
def _binding(source: str, target: str) -> OutputBinding:
return OutputBinding.model_validate({"source": source, "target": target})
+66
View File
@@ -0,0 +1,66 @@
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 (
ForeachBarrierState,
ItemErrorRecord,
PendingItemResult,
)
from wf_core.runtime.ops.state import StatePatch
def test_foreach_barrier_state_round_trips_through_frame_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_returns_none_when_missing() -> None:
frame = ExecutionFrame(id="root", kind="root", node_id="each")
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")
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from wf_core import END, Workflow, validate_workflow
from wf_core.models.steps import ForeachNode
def test_serial_foreach_defaults_to_fail_item_policy() -> None:
node = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": {"root": "state", "parts": ["items"]},
"as": "item",
}
)
assert node.mode == "serial"
assert node.item_error.action == "fail"
assert node.item_error.collect_to is None
assert node.concurrent is None
def test_deprecated_on_item_error_parses_to_nested_policy() -> None:
node = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"on_item_error": "skip",
}
)
dumped = node.model_dump(mode="json", by_alias=True)
assert node.item_error.action == "skip"
assert "on_item_error" not in dumped
assert dumped["item_error"]["action"] == "skip"
def test_collect_item_policy_requires_collect_to() -> None:
with pytest.raises(ValidationError, match="collect_to"):
ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": {"root": "state", "parts": ["items"]},
"as": "item",
"item_error": {"action": "collect"},
}
)
def test_concurrent_policy_requires_concurrent_mode() -> None:
with pytest.raises(ValidationError, match="concurrent policy"):
ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": {"root": "state", "parts": ["items"]},
"as": "item",
"concurrent": {"max_active": 4, "max_outstanding": 20},
}
)
def test_concurrent_policy_validates_capacity_order() -> None:
with pytest.raises(ValidationError, match="max_outstanding"):
ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": {"root": "state", "parts": ["items"]},
"as": "item",
"mode": "concurrent",
"concurrent": {"max_active": 10, "max_outstanding": 4},
}
)
def test_deprecated_parallel_policy_parses_to_concurrent_policy() -> None:
node = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "parallel",
"parallel": {"max_active": 2, "max_outstanding": 5},
}
)
dumped = node.model_dump(mode="json", by_alias=True)
assert node.mode == "concurrent"
assert node.concurrent is not None
assert node.concurrent.max_active == 2
assert "parallel" not in dumped
assert dumped["mode"] == "concurrent"
assert dumped["concurrent"]["max_outstanding"] == 5
def test_collect_policy_requires_completed_with_errors_edge() -> None:
workflow = _workflow(
item_error={"action": "collect", "collect_to": "state.item_errors"},
edges=[
{"from": "each", "outcome": "loop", "to": END},
{"from": "each", "outcome": "done", "to": END},
],
)
report = validate_workflow(workflow)
assert report.errors
assert report.errors[0].code == "missing_outcome_edge"
assert "completed_with_errors" in report.errors[0].message
def test_collect_policy_destination_must_be_declared_array_field() -> None:
workflow = _workflow(
item_error={"action": "collect", "collect_to": "state.not_array"},
state_schema={
"type": "object",
"properties": {
"items": {"type": "array"},
"not_array": {"type": "string"},
},
},
)
report = validate_workflow(workflow)
matching = [
issue
for issue in report.errors
if issue.code == "invalid_foreach_collect_destination"
]
assert matching
assert "array state field" in matching[0].message
def _workflow(
*,
item_error: dict[str, object] | None = None,
state_schema: dict[str, object] | None = None,
edges: list[dict[str, str]] | None = None,
) -> Workflow:
return Workflow.model_validate(
{
"name": "foreach_policy",
"input_schema": {"type": "object", "properties": {}},
"state_schema": state_schema
or {
"type": "object",
"properties": {
"items": {"type": "array"},
"item_errors": {"type": "array"},
},
},
"output_schema": {"type": "object", "properties": {}},
"start": "each",
"nodes": [
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"item_error": item_error or {"action": "fail"},
}
],
"edges": edges
or [
{"from": "each", "outcome": "loop", "to": END},
{"from": "each", "outcome": "done", "to": END},
],
"node_defs": [],
}
)