concurrent foreach preparation, types, validation, refactors
This commit is contained in:
@@ -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})
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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": [],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user