Stage caching for batch runs, and an engine that lives in the command
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
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
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>
This commit is contained in:
@@ -10,7 +10,7 @@ import pytest
|
||||
|
||||
from fluksio.flow.messages import DType, MessageSpec
|
||||
from fluksio.flow.nodes import Node
|
||||
from fluksio.flow.pipeline import NodeOutcome, Pipeline
|
||||
from fluksio.flow.pipeline import NodeOutcome, Pipeline, run_cache_key
|
||||
from fluksio.flow.runs import (
|
||||
MetricSink,
|
||||
RunRejected,
|
||||
@@ -333,3 +333,105 @@ def test_a_runs_seed_fills_an_input_of_that_name():
|
||||
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 == ""
|
||||
|
||||
Reference in New Issue
Block a user