Files
app/backend/tests/flow/test_runs.py
T
stroblmeandClaude Opus 5 400d7d9c5c
Docs / docs (push) Successful in 19s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m5s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Successful in 2m7s
Compose Smoke Test / test-compose (push) Failing after 20s
Playwright Tests / merge-reports (push) Failing after 1m3s
Publish / publish (push) Failing after 12s
Stage caching for batch runs, and an engine that lives in the command
A code node in a batch run is now fingerprinted by its source, its raw
settings and the values it reads — an artifact input counting as its digest,
which is what the content addressing was always for. A run that finds the key
restores what the earlier one returned and skips the node, recorded as
`cached`. The run history is the cache: `run_node.outputs` beside the
`cache_key` the schema already had, no second store. On for code nodes, never
for the built-in and connector types that have side effects; off per node with
`@node(cache=False)` and per run with `--no-cache`.

Emissions are not replayed on a hit, so a cached training node returns its
result without redrawing its curve. Recorded in NOTEPAD.md with the two other
deliberate limits.

`fluksio run --local` boots the real app in the command's own process and
drives it through its ASGI interface behind the ordinary client, so a run no
longer needs a `serve` terminal beside it — same data directory, same history,
and the cache carries between the two. It always waits, because the engine it
starts lives exactly as long as the command.

Also: `fluksio sweep --param lr=0.1,0.01` for the product of the lists,
`run --follow` for a run's numbers as they arrive, Ctrl-C cancelling a waited
run rather than abandoning it, coloured statuses on a terminal, and `name`
made optional on the metrics endpoint so a follower can ask for every series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:31:31 +02:00

438 lines
14 KiB
Python

"""What a run is made of: isolation, the per-node record, and what it reports.
The service itself needs a database, so what is checked here is the part that
decides whether a run is correct — that two runs of one flow cannot see each
other's messages, that every node executed is reported once, and that the
parameters a caller sends are refused before anything runs if they are wrong.
"""
import pytest
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node
from fluksio.flow.pipeline import NodeOutcome, Pipeline, run_cache_key
from fluksio.flow.runs import (
MetricSink,
RunRejected,
batch_issues,
collect_result,
digest_of,
required_labels,
seed_values,
)
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
from fluksio.flow.state import MemoryState
def spec(name: str, dtype: DType = DType.FLOAT, **kwargs) -> MessageSpec:
return MessageSpec(name=name, dtype=dtype, **kwargs)
def make_node(node_id: str, flow: str, f, requires=(), provides=()) -> Node:
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
node.assign_flow(flow, node_id)
return node
def double_flow() -> FlowDef:
"""A flow with one input, one node and one declared output."""
return FlowDef(
name="study",
mode="batch",
inputs=[FlowInput(spec=spec("lr"), initial=0.1)],
outputs=["loss"],
nodes=[
NodeDef(
id="train",
requires=[spec("lr")],
provides=[spec("loss")],
)
],
)
def build(flow: FlowDef, state: MemoryState, observer=None) -> Pipeline:
"""The pipeline a run drives, without the controller that normally builds it."""
node = make_node(
"train",
flow.name,
lambda lr, params: {"loss": lr * 2},
requires=[spec("lr")],
provides=[spec("loss")],
)
return Pipeline(nodes=[node], state=state, observer=observer)
# -----------------------------------------------------------------------------
# Isolation — the reason a run has a state backend of its own
# -----------------------------------------------------------------------------
def test_two_runs_of_one_flow_do_not_see_each_other():
flow = double_flow()
first, second = MemoryState(), MemoryState()
build(flow, first).run(seed_values(flow, {"lr": 0.5}))
build(flow, second).run(seed_values(flow, {"lr": 4.0}))
assert collect_result(flow, first) == {"loss": 1.0}
assert collect_result(flow, second) == {"loss": 8.0}
def test_result_falls_back_to_everything_the_flow_holds():
flow = double_flow()
flow.outputs = []
state = MemoryState()
build(flow, state).run(seed_values(flow, {"lr": 1.0}))
# The input is part of what the flow ended up holding; the engine's own
# bookkeeping keys are not.
assert collect_result(flow, state) == {"lr": 1.0, "loss": 2.0}
# -----------------------------------------------------------------------------
# The record — every node a run executed, reported once
# -----------------------------------------------------------------------------
def test_observer_sees_every_node_once():
flow = double_flow()
seen = []
build(flow, MemoryState(), observer=seen.append).run(seed_values(flow, {"lr": 1.0}))
assert [(o.node, o.ok) for o in seen] == [("study.train", True)]
assert seen[0].outputs == 1
def test_observer_reports_a_failing_node_with_its_error():
seen = []
def boom(params):
raise ValueError("no convergence")
node = make_node("train", "study", boom, provides=[spec("loss")])
Pipeline(nodes=[node], state=MemoryState(), observer=seen.append).run()
assert len(seen) == 1
assert not seen[0].ok
assert "no convergence" in seen[0].error
def test_a_failing_observer_does_not_take_the_node_down():
def refuse(_outcome):
raise RuntimeError("the database is gone")
node = make_node(
"train", "study", lambda params: {"loss": 1.0}, provides=[spec("loss")]
)
pipeline = Pipeline(nodes=[node], state=MemoryState(), observer=refuse)
pipeline.run()
assert pipeline.state["study.loss"] == 1.0
# -----------------------------------------------------------------------------
# Producing values before returning
#
# A number worth keeping is an output, not a log. A node that produces over
# time is a generator, and each yield is published on the port it names — so
# the run's metrics are its streaming outputs rather than something recorded
# beside them.
# -----------------------------------------------------------------------------
def training_flow() -> FlowDef:
return FlowDef(
name="study",
mode="batch",
inputs=[FlowInput(spec=spec("steps", DType.INT), initial=3)],
outputs=["final_loss"],
nodes=[
NodeDef(
id="train",
requires=[spec("steps", DType.INT)],
provides=[
spec("loss", stream=True),
spec("final_loss"),
],
)
],
)
def training_node(flow: str = "study") -> Node:
def train(steps, params):
loss = 1.0
for _ in range(steps):
loss = loss / 2
yield {"loss": loss}
return {"final_loss": loss}
return make_node(
"train",
flow,
train,
requires=[spec("steps", DType.INT)],
provides=[spec("loss", stream=True), spec("final_loss")],
)
def test_each_yield_is_published_and_the_return_is_the_result():
flow = training_flow()
state = MemoryState()
seen: list[tuple[str, dict]] = []
pipeline = Pipeline(
nodes=[training_node()],
state=state,
emission_observer=lambda node, outputs: seen.append((node, outputs)),
)
pipeline.run(seed_values(flow, {"steps": 3}))
# It returned something, so every yield was a value produced on the way —
# each published on `loss` the moment it happened.
assert [outputs["study.loss"] for _node, outputs in seen] == [0.5, 0.25, 0.125]
assert all(node == "study.train" for node, _ in seen)
# The latest of them is what the message holds, as for any producer.
assert state["study.loss"] == 0.125
# And what it returned is the node's output, and so the run's result.
assert collect_result(flow, state) == {"final_loss": 0.125}
def test_without_a_return_the_last_yield_is_the_result():
def train(params):
yield {"loss": 1.0}
yield {"loss": 0.5}
node = make_node("train", "study", train, provides=[spec("loss", stream=True)])
seen: list[tuple[str, dict]] = []
pipeline = Pipeline(
nodes=[node],
state=MemoryState(),
emission_observer=lambda n, o: seen.append((n, o)),
)
pipeline.run()
# The last yield is the node's output rather than an emission, so it is
# not counted twice.
assert [outputs["study.loss"] for _node, outputs in seen] == [1.0]
assert pipeline.state["study.loss"] == 0.5
def test_emissions_are_checked_against_the_port_they_name():
def wrong(params):
yield {"loss": "not a number"}
return {"final_loss": 1.0}
node = make_node(
"train",
"study",
wrong,
provides=[spec("loss", stream=True), spec("final_loss")],
)
seen: list[NodeOutcome] = []
Pipeline(nodes=[node], state=MemoryState(), observer=seen.append).run()
# A wrong type is a failed node, exactly as it is for a return value —
# which is the point of emissions going out through declared ports.
assert not seen[0].ok
assert "loss" in seen[0].error
def test_an_emission_that_nothing_declares_is_ignored():
def stray(params):
yield {"undeclared": 1.0}
return {"final_loss": 2.0}
node = make_node("train", "study", stray, provides=[spec("final_loss")])
state = MemoryState()
Pipeline(nodes=[node], state=state).run()
assert "study.undeclared" not in state
assert state["study.final_loss"] == 2.0
def test_emissions_reach_the_run_as_a_series_with_a_step_each():
sink = MetricSink("run-1", batch=1)
sink.handle("study.train", {"study.loss": 1.0, "study.tag": "ignored"})
sink.handle("study.train", {"study.loss": 0.5})
# Numbers become the run's series; anything else is on the run some other
# way — as its result, or as an artifact.
assert sink._steps == {"study.loss": 1}
# -----------------------------------------------------------------------------
# What a caller may ask for
# -----------------------------------------------------------------------------
def test_parameters_must_be_declared_inputs():
flow = double_flow()
with pytest.raises(RunRejected, match="not an input"):
seed_values(flow, {"learning_rate": 0.1})
def test_parameters_are_type_checked_before_anything_runs():
flow = double_flow()
with pytest.raises(RunRejected, match="lr"):
seed_values(flow, {"lr": "fast"})
def test_a_rate_limited_port_cannot_be_run_as_a_batch():
flow = double_flow()
flow.nodes[0].provides = [spec("loss", interval=30)]
# Without a queue there is no timer to release what an interval holds, so
# the value would be dropped rather than delayed.
assert batch_issues(flow)
assert not batch_issues(double_flow())
def test_a_streaming_port_may_thin_itself_out():
flow = double_flow()
flow.nodes[0].provides = [spec("loss", interval=0.5, stream=True)]
# On a curve, an interval is asking for the canvas not to be flooded —
# the run's history still keeps every value.
assert not batch_issues(flow)
def test_the_digest_identifies_the_inputs_not_their_order():
assert digest_of({"a": 1, "b": 2}, 3) == digest_of({"b": 2, "a": 1}, 3)
assert digest_of({"a": 1}, 3) != digest_of({"a": 1}, 4)
def test_labels_come_from_the_nodes_that_ask_for_a_device():
flow = double_flow()
assert required_labels(flow) == []
flow.nodes[0].device = "gpu"
assert required_labels(flow) == ["gpu"]
def test_a_preferred_device_does_not_hold_a_run_back():
flow = double_flow()
flow.nodes[0].device = "gpu"
flow.nodes[0].device_policy = "prefer"
# It runs on the engine when no such worker is attached, so waiting for
# one would be waiting for something the run does not need.
assert required_labels(flow) == []
def test_a_runs_seed_fills_an_input_of_that_name():
flow = double_flow()
flow.inputs.append(FlowInput(spec=spec("seed", DType.INT), initial=0))
# Otherwise the field that tells two runs of one configuration apart would
# only look like the number the flow draws from.
assert seed_values(flow, {}, seed=7)["study.seed"] == 7
# An explicit parameter still wins.
assert seed_values(flow, {"seed": 3}, seed=7)["study.seed"] == 3
def test_a_flow_without_a_seed_input_ignores_the_runs_seed():
flow = double_flow()
assert seed_values(flow, {"lr": 1.0}, seed=7) == {"study.lr": 1.0}
# -----------------------------------------------------------------------------
# The stage cache — a node whose inputs have not changed is not run again
# -----------------------------------------------------------------------------
class FakeCache:
"""A stage cache with no database behind it, and a record of what it was asked."""
def __init__(self, entries: dict[str, dict | None] | None = None) -> None:
self.entries = entries or {}
self.asked: list[str] = []
def lookup(self, key: str):
self.asked.append(key)
if key in self.entries:
return True, self.entries[key]
return False, None
def counting_node(flow: str = "study") -> tuple[Node, list[int]]:
"""A node that says how many times it actually ran."""
calls: list[int] = []
def train(lr, params):
calls.append(1)
return {"loss": lr * 2}
node = make_node(
"train", flow, train, requires=[spec("lr")], provides=[spec("loss")]
)
node.fingerprint = "fp-train"
return node, calls
def test_a_cache_hit_restores_the_outputs_without_running_the_node():
flow = double_flow()
node, calls = counting_node()
state = MemoryState()
seen: list[NodeOutcome] = []
key = run_cache_key("fp-train", {"study.lr": 0.5})
cache = FakeCache({key: {"study.loss": 99.0}})
Pipeline(nodes=[node], state=state, observer=seen.append, run_cache=cache).run(
seed_values(flow, {"lr": 0.5})
)
assert calls == []
# Restored into this run's own state, which is where everything
# downstream of it looks — its namespace holds nothing otherwise.
assert collect_result(flow, state) == {"loss": 99.0}
assert seen[0].cached and seen[0].cache_key == key
def test_a_miss_runs_the_node_and_carries_what_would_be_stored():
flow = double_flow()
node, calls = counting_node()
seen: list[NodeOutcome] = []
cache = FakeCache()
Pipeline(
nodes=[node], state=MemoryState(), observer=seen.append, run_cache=cache
).run(seed_values(flow, {"lr": 0.5}))
assert calls == [1]
assert cache.asked == [run_cache_key("fp-train", {"study.lr": 0.5})]
assert not seen[0].cached
assert seen[0].cache_key and seen[0].output_values == {"study.loss": 1.0}
def test_the_key_follows_the_inputs():
first = run_cache_key("fp", {"lr": 0.5})
assert first != run_cache_key("fp", {"lr": 0.6})
assert first != run_cache_key("other", {"lr": 0.5})
assert first == run_cache_key("fp", {"lr": 0.5})
def test_an_artifact_input_counts_as_its_digest():
digest = "sha256:" + "0" * 64
# The same bytes under another name, of a size recorded differently, are
# the same input — the reference is a handle, the digest is the content.
assert run_cache_key(
"fp", {"data": {"digest": digest, "name": "a.csv", "size": 3}}
) == run_cache_key("fp", {"data": {"digest": digest, "name": "b.csv", "size": 3}})
def test_a_node_with_no_fingerprint_is_never_looked_up():
"""Built-in and connector nodes, and anything declared `cache=False`."""
flow = double_flow()
node, calls = counting_node()
node.fingerprint = ""
seen: list[NodeOutcome] = []
cache = FakeCache()
Pipeline(
nodes=[node], state=MemoryState(), observer=seen.append, run_cache=cache
).run(seed_values(flow, {"lr": 0.5}))
assert calls == [1]
assert cache.asked == []
assert seen[0].cache_key == ""