A cached node keeps its curve, and any input can name a run's output
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m41s
pre-commit / pre-commit (push) Failing after 2m53s
Test Backend / test-backend (push) Successful in 2m21s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Failing after 1m6s
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m41s
pre-commit / pre-commit (push) Failing after 2m53s
Test Backend / test-backend (push) Successful in 2m21s
Compose Smoke Test / test-compose (push) Successful in 31s
Playwright Tests / merge-reports (push) Failing after 1m6s
A cache hit still replays no emissions — those values were the story of an
execution that is not happening — but the run they were recorded in is now
written on the row (`run_node.cached_from`), and the metrics endpoints read the
series back from there. So a reused run answers `run.metrics("train.loss")`
with the same points the run that trained did, rather than looking like a run
that produced no numbers at all. Pointed at rather than copied: a sweep of 500
reusing one frozen node would otherwise duplicate its curve 500 times.
That needed the cross-flow restore fixed first. The cache key has no flow in
it while the stored outputs are named for the flow that produced them, so
`quick.prepare` getting a hit from `train` wrote `train.dataset` into `quick`'s
state and the next node was called without its argument. One rule now covers
both halves: `requalify` reads a name owned by one flow as the same name in
another, applied to the restored outputs, to the node id behind the pointer,
and to the series names on the way out. Reuse across flows is kept.
Also: `@run:<id>.<output>` and a bare `sha256:` digest resolve on every input,
not only artifacts. Chaining a run's json config into the next one from a shell
meant pasting the whole object inline, and the CLI could not even send the
spelling — `_coerce` died in `json.loads` before the engine saw it. Both
spellings are reserved on every input now, `str` included, and `_from_run`
returns whatever the run's result holds rather than only a reference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp9L6gakMVro1K2C5zdtBE
This commit is contained in:
@@ -30,7 +30,7 @@ from pydantic import BaseModel
|
||||
from fluksio.flow import logs
|
||||
from fluksio.flow.artifacts import is_reference
|
||||
from fluksio.flow.events import EventBus
|
||||
from fluksio.flow.messages import flow_of
|
||||
from fluksio.flow.messages import flow_of, requalify
|
||||
from fluksio.flow.nodes import Node
|
||||
from fluksio.flow.queue import WorkQueue
|
||||
from fluksio.flow.state import MemoryState, StateBackend
|
||||
@@ -103,6 +103,9 @@ class NodeOutcome(BaseModel):
|
||||
artifacts: dict[str, dict[str, Any]] = {}
|
||||
#: Restored from an earlier run rather than executed.
|
||||
cached: bool = False
|
||||
#: Which run the restored values came from — and, because a restored row
|
||||
#: holds no series of its own, which run this node's metrics live in.
|
||||
cached_from: str = ""
|
||||
#: What an equal execution of this node would be looked up by. Empty when
|
||||
#: the node is not cacheable at all.
|
||||
cache_key: str = ""
|
||||
@@ -111,6 +114,20 @@ class NodeOutcome(BaseModel):
|
||||
output_values: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CacheHit(BaseModel):
|
||||
"""An earlier run of a node, as the thing restoring it needs to see."""
|
||||
|
||||
#: The flow whose namespace the stored outputs are named in. A node reached
|
||||
#: through two flows publishes the same values under two names.
|
||||
flow: str = ""
|
||||
#: What it published. None means it published nothing, which is a result
|
||||
#: worth restoring and has to be distinguishable from a miss.
|
||||
outputs: dict[str, Any] | None = None
|
||||
#: The run holding this node's series. Not necessarily the run the outputs
|
||||
#: were read from: a row that was itself restored has no series of its own.
|
||||
metrics_run: str = ""
|
||||
|
||||
|
||||
class RunCacheLookup(Protocol):
|
||||
"""Where a pipeline asks whether a node has already been run.
|
||||
|
||||
@@ -118,8 +135,8 @@ class RunCacheLookup(Protocol):
|
||||
hands it one of these, a test hands it a dict.
|
||||
"""
|
||||
|
||||
def lookup(self, key: str) -> tuple[bool, dict[str, Any] | None]:
|
||||
"""(hit, outputs). Outputs None on a hit means it published nothing."""
|
||||
def lookup(self, key: str) -> CacheHit | None:
|
||||
"""What an equal execution produced, or None when there is no entry."""
|
||||
|
||||
|
||||
def run_cache_key(fingerprint: str, inputs: dict[str, Any]) -> str:
|
||||
@@ -817,19 +834,32 @@ class Pipeline:
|
||||
The outputs go into state as if the node had just returned them, which
|
||||
is what everything downstream reads — a run's state namespace is its
|
||||
own, so a skipped node leaves nothing behind for the next one to find.
|
||||
What it emitted on the way is not restored: those values were the
|
||||
story of an execution that is not happening this time.
|
||||
Named for *this* node's flow rather than the one that produced them: the
|
||||
same node reached through two flows publishes the same values under two
|
||||
names, and downstream here looks for the ones this flow owns.
|
||||
|
||||
What it emitted on the way is not restored either — those values were
|
||||
the story of an execution that is not happening. The run it came from is
|
||||
recorded instead, and that is where its series is read from.
|
||||
"""
|
||||
assert self.run_cache is not None
|
||||
try:
|
||||
hit, outputs = self.run_cache.lookup(key)
|
||||
hit = self.run_cache.lookup(key)
|
||||
except Exception:
|
||||
# A cache that cannot answer is a cache miss, never a failed node.
|
||||
logger.exception("Cache lookup failed for '%s'", node.id)
|
||||
return False, None
|
||||
if not hit:
|
||||
if hit is None:
|
||||
return False, None
|
||||
|
||||
outputs = (
|
||||
{
|
||||
requalify(name, hit.flow, node.flow): value
|
||||
for name, value in hit.outputs.items()
|
||||
}
|
||||
if hit.outputs
|
||||
else hit.outputs
|
||||
)
|
||||
if outputs:
|
||||
self._record_outputs(node, outputs, state)
|
||||
self._publish(
|
||||
@@ -848,6 +878,7 @@ class Pipeline:
|
||||
node=node.id,
|
||||
ok=True,
|
||||
cached=True,
|
||||
cached_from=hit.metrics_run,
|
||||
cache_key=key,
|
||||
outputs=len(outputs or {}),
|
||||
output_values=outputs,
|
||||
|
||||
Reference in New Issue
Block a user