Check a generator's yield at the yield that produced it
The worker held each yield one behind, because the last one is the node's result when the generator returns nothing of its own. Only the engine knows what ports a node declared, so the check happened when the *next* yield arrived — a pass late, which for a training loop is however long one epoch takes. The worker now sends every yield as it happens and returns whatever its generator returned; EmitSink holds the last one back and decides at the end of the call what it was. Old "emit" frames are still handled, so a remote agent that has not been restarted keeps working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,10 @@ import pytest
|
||||
from fluksio_worker.worker_main import ARTIFACT_DIR_ENV
|
||||
|
||||
from fluksio.flow.artifacts import ArtifactStore
|
||||
from fluksio.flow.controller import EmitSink
|
||||
from fluksio.flow.messages import DType, MessageSpec
|
||||
from fluksio.flow.nodes import Node
|
||||
from fluksio.flow.nodes.base import NodeOutputError
|
||||
from fluksio.flow.workers import NodeCancelled, NodeTimeout, PythonWorkerPool
|
||||
|
||||
|
||||
@@ -22,6 +25,24 @@ def pool() -> Iterator[PythonWorkerPool]:
|
||||
worker_pool.stop()
|
||||
|
||||
|
||||
def _sink_node(node_id: str, port: str, dtype: DType) -> tuple[Node, list[dict]]:
|
||||
"""A node declaring one streaming port, and the list its emissions land in."""
|
||||
node = Node(
|
||||
f=lambda: None,
|
||||
requires=[],
|
||||
provides=[MessageSpec(name=port, dtype=dtype, stream=True)],
|
||||
name=node_id,
|
||||
)
|
||||
node.assign_flow("demo", node_id)
|
||||
published: list[dict] = []
|
||||
node._pipeline = type(
|
||||
"Recorder",
|
||||
(),
|
||||
{"publish_emission": staticmethod(lambda _node, outputs: published.append(outputs))},
|
||||
)()
|
||||
return node, published
|
||||
|
||||
|
||||
def run(pool: PythonWorkerPool, code: str, node: str = "demo", **kwargs):
|
||||
return pool.run("demo", node, code, kwargs, f"demo.{node}", timeout=5)
|
||||
|
||||
@@ -224,22 +245,30 @@ def test_a_generator_node_publishes_each_yield_and_returns_the_end(pool):
|
||||
|
||||
|
||||
def test_without_a_return_the_last_yield_is_the_result(pool):
|
||||
seen = []
|
||||
result = pool.run(
|
||||
"demo",
|
||||
"count",
|
||||
"def process():\n"
|
||||
" yield {'out': 1}\n"
|
||||
" yield {'out': 2}\n"
|
||||
" yield {'out': 3}\n",
|
||||
{},
|
||||
"demo.count",
|
||||
timeout=5,
|
||||
on_event=seen.append,
|
||||
)
|
||||
"""The worker sends every yield; the engine is what holds the last one back."""
|
||||
node, published = _sink_node("count", "out", DType.INT)
|
||||
sink = EmitSink()
|
||||
sink.node = node
|
||||
|
||||
result = sink.wrap(
|
||||
lambda: pool.run(
|
||||
"demo",
|
||||
"count",
|
||||
"def process():\n"
|
||||
" yield {'out': 1}\n"
|
||||
" yield {'out': 2}\n"
|
||||
" yield {'out': 3}\n",
|
||||
{},
|
||||
"demo.count",
|
||||
timeout=5,
|
||||
on_event=sink.handle,
|
||||
)
|
||||
)()
|
||||
|
||||
# The last yield is the node's output rather than an emission, so it is not
|
||||
# counted twice.
|
||||
assert result == {"out": 3}
|
||||
assert [event["outputs"] for event in seen] == [{"out": 1}, {"out": 2}]
|
||||
assert published == [{"demo.out": 1}, {"demo.out": 2}]
|
||||
|
||||
|
||||
def test_emit_reaches_the_same_ports_from_inside_a_callback(pool):
|
||||
@@ -438,31 +467,30 @@ def test_a_node_with_no_timeout_can_still_be_cancelled(pool):
|
||||
def test_an_emission_on_an_undeclared_port_fails_the_call(pool):
|
||||
"""The engine's sink raises, and that has to reach the node's author.
|
||||
|
||||
A yield is held one behind — the last one is the return value when there is
|
||||
no explicit return — so the mistake surfaces on the loop's second pass
|
||||
rather than its first. Which is what a training loop does in a moment, and
|
||||
a long way short of the hours it used to cost.
|
||||
At the *first* yield, which is where the mistake is: the worker sends each
|
||||
one as it happens, so a mistyped port is checked before the node goes back
|
||||
to work rather than when the yield after it arrives.
|
||||
"""
|
||||
from fluksio.flow.nodes.base import NodeOutputError
|
||||
|
||||
def refuse(event):
|
||||
raise NodeOutputError("'demo.gen' produced 'lss', which no port declares")
|
||||
node, _ = _sink_node("gen", "loss", DType.FLOAT)
|
||||
sink = EmitSink()
|
||||
sink.node = node
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(NodeOutputError, match="lss"):
|
||||
pool.run(
|
||||
"demo",
|
||||
"gen",
|
||||
"import time\n\n\ndef process():\n"
|
||||
" for _ in range(3):\n"
|
||||
" yield {'lss': 1.0}\n"
|
||||
" time.sleep(30)\n"
|
||||
" return {'out': 1}\n",
|
||||
{},
|
||||
"demo.gen",
|
||||
timeout=0,
|
||||
on_event=refuse,
|
||||
)
|
||||
sink.wrap(
|
||||
lambda: pool.run(
|
||||
"demo",
|
||||
"gen",
|
||||
"import time\n\n\ndef process():\n"
|
||||
" yield {'lss': 1.0}\n"
|
||||
" time.sleep(30)\n"
|
||||
" yield {'loss': 0.5}\n",
|
||||
{},
|
||||
"demo.gen",
|
||||
timeout=0,
|
||||
on_event=sink.handle,
|
||||
)
|
||||
)()
|
||||
# It did not wait out the node: the failure stopped the call.
|
||||
assert time.monotonic() - started < 10
|
||||
|
||||
|
||||
Reference in New Issue
Block a user