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:
@@ -0,0 +1,37 @@
|
||||
"""run_node.cached_from
|
||||
|
||||
Which run a restored node's values came from. A cache hit replays no emissions,
|
||||
so the series stays where it was recorded and the run that reused it points at
|
||||
it rather than copying a few thousand rows per reuse.
|
||||
|
||||
Revision ID: d1f7a3c8b204
|
||||
Revises: c9a5d1e73b48
|
||||
Create Date: 2026-08-25
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d1f7a3c8b204"
|
||||
down_revision = "c9a5d1e73b48"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"run_node",
|
||||
sa.Column(
|
||||
"cached_from",
|
||||
sqlmodel.sql.sqltypes.AutoString(length=64),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("run_node", "cached_from")
|
||||
@@ -12,9 +12,10 @@ from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from fluksio.api.deps import CurrentUser, SessionDep, get_current_user
|
||||
from fluksio.flow.messages import requalify
|
||||
from fluksio.flow.runs import RunRejected, RunService, new_run_id
|
||||
from fluksio.flow.store import FlowNotFound
|
||||
from fluksio.models import Run, RunArtifact, RunMetric, RunNode
|
||||
@@ -58,6 +59,9 @@ class RunNodeRow(BaseModel):
|
||||
#: What this node's result was looked up by. Empty when it may not be
|
||||
#: reused; `status` is "cached" when it was.
|
||||
cache_key: str = ""
|
||||
#: Which run it was restored from, when it was. That run is also where this
|
||||
#: node's series was recorded.
|
||||
cached_from: str = ""
|
||||
|
||||
|
||||
class ArtifactRow(BaseModel):
|
||||
@@ -284,6 +288,59 @@ async def cancel_run(run_id: str, request: Request, session: SessionDep) -> Any:
|
||||
return run
|
||||
|
||||
|
||||
def _series(session: Session, run_id: str, name: str = "") -> list[RunMetric]:
|
||||
"""A run's numbers, including the ones a cached node points at.
|
||||
|
||||
A cache hit replays no emissions, so a node restored from an earlier run has
|
||||
no rows of its own — it carries that run's id instead, and its series is read
|
||||
from there. Names are re-qualified on the way out, because the same node
|
||||
reached through two flows publishes under two names and the caller asked for
|
||||
this run's.
|
||||
"""
|
||||
statement = select(RunMetric).where(col(RunMetric.run_id) == run_id)
|
||||
if name:
|
||||
statement = statement.where(col(RunMetric.name) == name)
|
||||
rows = list(session.exec(statement))
|
||||
|
||||
restored = session.exec(
|
||||
select(RunNode).where(
|
||||
col(RunNode.run_id) == run_id, col(RunNode.cached_from) != ""
|
||||
)
|
||||
).all()
|
||||
if restored:
|
||||
run = session.get(Run, run_id)
|
||||
flow = run.flow if run is not None else ""
|
||||
for node_row in restored:
|
||||
source = session.get(Run, node_row.cached_from)
|
||||
if source is None:
|
||||
# The run it came from is gone — deleted with its flow. The
|
||||
# outputs are still on this run; the curve is not recoverable.
|
||||
continue
|
||||
source_node = requalify(node_row.node, flow, source.flow)
|
||||
for row in session.exec(
|
||||
select(RunMetric).where(
|
||||
col(RunMetric.run_id) == node_row.cached_from,
|
||||
col(RunMetric.node) == source_node,
|
||||
)
|
||||
):
|
||||
renamed = requalify(row.name, source.flow, flow)
|
||||
if name and renamed != name:
|
||||
continue
|
||||
rows.append(
|
||||
RunMetric(
|
||||
run_id=run_id,
|
||||
name=renamed,
|
||||
step=row.step,
|
||||
node=node_row.node,
|
||||
ts=row.ts,
|
||||
value=row.value,
|
||||
)
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: (row.name, row.step))
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/{run_id}/metrics", response_model=list[MetricPoint])
|
||||
def read_metrics(
|
||||
run_id: str, session: SessionDep, name: str = "", stride: int = 1
|
||||
@@ -293,11 +350,7 @@ def read_metrics(
|
||||
``stride`` thins a long curve down: 3000 steps drawn on a 400-pixel chart
|
||||
is 3000 points nobody can see.
|
||||
"""
|
||||
statement = select(RunMetric).where(col(RunMetric.run_id) == run_id)
|
||||
if name:
|
||||
statement = statement.where(col(RunMetric.name) == name)
|
||||
statement = statement.order_by(col(RunMetric.name), col(RunMetric.step))
|
||||
rows = list(session.exec(statement))
|
||||
rows = _series(session, run_id, name)
|
||||
if stride > 1:
|
||||
rows = rows[:: max(1, stride)]
|
||||
return rows
|
||||
@@ -323,11 +376,7 @@ def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
|
||||
run = runs.get(run_id)
|
||||
if run is None:
|
||||
continue
|
||||
rows = session.exec(
|
||||
select(RunMetric)
|
||||
.where(col(RunMetric.run_id) == run_id, col(RunMetric.name) == metric)
|
||||
.order_by(col(RunMetric.step))
|
||||
).all()
|
||||
rows = _series(session, run_id, metric)
|
||||
label = run_id
|
||||
if run.seed is not None:
|
||||
label = f"{run_id} (seed {run.seed})"
|
||||
|
||||
@@ -286,3 +286,16 @@ def qualify(flow: str, name: str) -> str:
|
||||
def flow_of(qualified: str) -> str:
|
||||
"""The flow a qualified message name belongs to."""
|
||||
return qualified.split(".", 1)[0]
|
||||
|
||||
|
||||
def requalify(qualified: str, source_flow: str, target_flow: str) -> str:
|
||||
"""A name owned by one flow, read as the same name in another.
|
||||
|
||||
What a node published as ``train.loss`` is ``quick.loss`` when the same node
|
||||
is reached through ``quick``: the value is the same, only the namespace it
|
||||
hangs in differs. A name the source flow does not own is left alone — a node
|
||||
deliberately publishing into another flow's namespace keeps doing so.
|
||||
"""
|
||||
if source_flow == target_flow or not qualified.startswith(f"{source_flow}."):
|
||||
return qualified
|
||||
return f"{target_flow}{qualified[len(source_flow) :]}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ Three things make a run different from a cascade, and each is deliberate:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -46,8 +47,8 @@ from sqlmodel import Session, col, select
|
||||
from fluksio.core.db import engine as db_engine
|
||||
from fluksio.flow.artifacts import ArtifactStore, is_reference, valid_digest
|
||||
from fluksio.flow.controller import FlowController, RunContext
|
||||
from fluksio.flow.messages import DType, qualify
|
||||
from fluksio.flow.pipeline import NodeOutcome, Pipeline
|
||||
from fluksio.flow.messages import qualify
|
||||
from fluksio.flow.pipeline import CacheHit, NodeOutcome, Pipeline
|
||||
from fluksio.flow.queue import WorkItem, WorkQueue
|
||||
from fluksio.flow.schemas import FlowDef
|
||||
from fluksio.flow.state import MemoryState, StateBackend
|
||||
@@ -152,25 +153,27 @@ RUN_REF_PREFIX = "@run:"
|
||||
def resolve_references(
|
||||
flow: FlowDef, params: dict[str, Any], artifacts: ArtifactStore | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Turn the text spellings of an artifact input into the reference itself.
|
||||
"""Turn the text spelling of a run's output into the value itself.
|
||||
|
||||
A python caller hands one run's output straight to the next, because it has
|
||||
the reference in its hand. A shell does not, and pasting the whole object
|
||||
is not a command anyone wants to type — so an artifact input also takes
|
||||
``@run:<id>.<output>``, naming what a run produced, or a bare
|
||||
``sha256:...`` digest naming the bytes. Resolved here rather than in each
|
||||
client, so the CLI, the browser and a python caller all mean the same
|
||||
thing by the same string.
|
||||
the value in its hand. A shell does not, and pasting a config object or an
|
||||
artifact reference is not a command anyone wants to type — so any declared
|
||||
input also takes ``@run:<id>.<output>``, naming what a run produced, and a
|
||||
bare ``sha256:...`` digest naming bytes. Resolved here rather than in each
|
||||
client, so the CLI, the browser and a python caller all mean the same thing
|
||||
by the same string.
|
||||
|
||||
Both spellings are therefore reserved on every input, ``str`` included: an
|
||||
input that has to carry one of them literally is asking for a value this
|
||||
engine reads as a name.
|
||||
"""
|
||||
wanted = {
|
||||
declared.spec.name
|
||||
for declared in flow.inputs
|
||||
if declared.spec.dtype is DType.ARTIFACT
|
||||
}
|
||||
declared = {one.spec.name for one in flow.inputs}
|
||||
pending = {
|
||||
key: value
|
||||
for key, value in params.items()
|
||||
if key in wanted and isinstance(value, str)
|
||||
if key in declared
|
||||
and isinstance(value, str)
|
||||
and (value.startswith(RUN_REF_PREFIX) or valid_digest(value))
|
||||
}
|
||||
if not pending:
|
||||
return params
|
||||
@@ -180,15 +183,13 @@ def resolve_references(
|
||||
for key, text in pending.items():
|
||||
if text.startswith(RUN_REF_PREFIX):
|
||||
resolved[key] = _from_run(session, key, text[len(RUN_REF_PREFIX) :])
|
||||
elif valid_digest(text):
|
||||
else:
|
||||
resolved[key] = _from_digest(session, key, text, artifacts)
|
||||
# Anything else is left alone: the type check names it better than
|
||||
# a guess about what was meant would.
|
||||
return resolved
|
||||
|
||||
|
||||
def _from_run(session: Session, key: str, spelling: str) -> dict[str, Any]:
|
||||
"""``<run id>.<output>`` as the reference that run produced."""
|
||||
def _from_run(session: Session, key: str, spelling: str) -> Any:
|
||||
"""``<run id>.<output>`` as the value that run produced."""
|
||||
run_id, _, output = spelling.partition(".")
|
||||
if not run_id or not output:
|
||||
raise RunRejected(
|
||||
@@ -199,13 +200,16 @@ def _from_run(session: Session, key: str, spelling: str) -> dict[str, Any]:
|
||||
if run is None:
|
||||
raise RunRejected(f"Parameter '{key}': there is no run '{run_id}'")
|
||||
|
||||
# The run's own result first: that is the reference as its producer made
|
||||
# it, file name and all. The rows are the fallback, and they carry the
|
||||
# message name instead — which loads the same bytes either way.
|
||||
candidate = (run.result or {}).get(output)
|
||||
if is_reference(candidate):
|
||||
return dict(candidate)
|
||||
# The run's own result first: that is the value as its producer made it,
|
||||
# an artifact's file name and all. Copied, because what comes back is
|
||||
# handed on as this run's parameter and must not alias the other run's row.
|
||||
result = run.result or {}
|
||||
if output in result:
|
||||
return copy.deepcopy(result[output])
|
||||
|
||||
# The artifact rows are the fallback: bytes a node made that the flow never
|
||||
# declared as an output. They carry the message name instead, which loads
|
||||
# the same bytes either way.
|
||||
rows = session.exec(
|
||||
select(RunArtifact).where(col(RunArtifact.run_id) == run_id)
|
||||
).all()
|
||||
@@ -217,10 +221,9 @@ def _from_run(session: Session, key: str, spelling: str) -> dict[str, Any]:
|
||||
"media_type": row.media_type or "application/octet-stream",
|
||||
"name": row.name,
|
||||
}
|
||||
known = ", ".join(sorted(row.name for row in rows)) or "none"
|
||||
known = ", ".join(sorted({*result, *(row.name for row in rows)})) or "none"
|
||||
raise RunRejected(
|
||||
f"Parameter '{key}': run '{run_id}' has no artifact '{output}' "
|
||||
f"(it made: {known})"
|
||||
f"Parameter '{key}': run '{run_id}' has no output '{output}' (it made: {known})"
|
||||
)
|
||||
|
||||
|
||||
@@ -428,9 +431,9 @@ class RunCache:
|
||||
def __init__(self, artifacts: ArtifactStore | None = None) -> None:
|
||||
self.artifacts = artifacts
|
||||
|
||||
def lookup(self, key: str) -> tuple[bool, dict[str, Any] | None]:
|
||||
def lookup(self, key: str) -> CacheHit | None:
|
||||
if not key:
|
||||
return False, None
|
||||
return None
|
||||
try:
|
||||
with Session(db_engine) as session:
|
||||
rows = session.exec(
|
||||
@@ -450,11 +453,18 @@ class RunCache:
|
||||
outputs = json.loads(row.outputs or "null")
|
||||
if outputs and self._unrestorable(outputs):
|
||||
continue
|
||||
return True, outputs
|
||||
source = session.get(Run, row.run_id)
|
||||
return CacheHit(
|
||||
flow=source.flow if source is not None else "",
|
||||
outputs=outputs,
|
||||
# A row that was itself restored holds no series, so
|
||||
# follow its pointer rather than adding a hop to it.
|
||||
metrics_run=row.cached_from or row.run_id,
|
||||
)
|
||||
except Exception:
|
||||
# A cache that cannot answer is a cache miss, never a failed run.
|
||||
logger.exception("Cache lookup failed; running the node instead")
|
||||
return False, None
|
||||
return None
|
||||
|
||||
def _unrestorable(self, outputs: dict[str, Any]) -> bool:
|
||||
return any(
|
||||
@@ -842,6 +852,7 @@ class RunService:
|
||||
duration_ms=outcome.duration_ms,
|
||||
error=outcome.error[:ERROR_CAP],
|
||||
logs=outcome.logs[:LOG_CAP],
|
||||
cached_from=outcome.cached_from,
|
||||
# Together or not at all: a row carrying a key must be one a
|
||||
# lookup can actually restore from.
|
||||
cache_key=outcome.cache_key if outputs is not None else "",
|
||||
|
||||
@@ -397,6 +397,10 @@ class RunNode(SQLModel, table=True):
|
||||
#: Everything this node's output depends on, hashed: its source, its
|
||||
#: settings and the values it read. What a later run looks itself up by.
|
||||
cache_key: str = Field(default="", index=True, max_length=64)
|
||||
#: The run this node's values were restored from, when it was not executed.
|
||||
#: Its series lives there too, which is what makes a cached run's curve
|
||||
#: readable without copying a few thousand rows per reuse.
|
||||
cached_from: str = Field(default="", max_length=64)
|
||||
#: What it returned, as canonical JSON, so a node with this key can be
|
||||
#: skipped and its outputs restored. None when it may not be reused —
|
||||
#: opted out, too large, or a value JSON cannot carry. Written with
|
||||
|
||||
@@ -282,6 +282,12 @@ def cmd_sync(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _coerce(value: str, dtype: str) -> Any:
|
||||
if value.startswith("@run:") or value.startswith("sha256:"):
|
||||
# A run's output, named rather than typed out. Sent as it stands: the
|
||||
# engine turns it into the value itself, whatever type that is. Passing
|
||||
# the whole thing as JSON still works, and is what a script that
|
||||
# already has it in hand would do.
|
||||
return value
|
||||
if dtype == "int":
|
||||
return int(value)
|
||||
if dtype == "float":
|
||||
@@ -290,13 +296,6 @@ def _coerce(value: str, dtype: str) -> Any:
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
if dtype == "str":
|
||||
return value
|
||||
if dtype == "artifact" and (
|
||||
value.startswith("@run:") or value.startswith("sha256:")
|
||||
):
|
||||
# The engine turns these into the reference itself. Passing the whole
|
||||
# object as JSON still works, and is what a script that already has one
|
||||
# would do.
|
||||
return value
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user