Un-key stage-cache rows written before the current key shape

CACHE_SHAPE_SINCE marks the last change that moved every cache key. The
run service's periodic sweep clears cache_key on rows of runs created
before it, which nothing can match any more. Their outputs stay.
This commit is contained in:
2026-10-15 10:54:22 +02:00
parent 6fa44bacf3
commit a95be36dba
5 changed files with 69 additions and 4 deletions
+2 -1
View File
@@ -1216,7 +1216,8 @@ class FlowController:
# reaches hash to, which `sync` worked out by following # reaches hash to, which `sync` worked out by following
# its imports. The ports are here because declaring a new # its imports. The ports are here because declaring a new
# one changes what the node answers with, which a hit # one changes what the node answers with, which a hit
# would otherwise restore without. # would otherwise restore without. Changing what goes in
# here changes every key: move `CACHE_SHAPE_SINCE` with it.
node.fingerprint = hashlib.sha256( node.fingerprint = hashlib.sha256(
json.dumps( json.dumps(
{ {
+8
View File
@@ -23,6 +23,7 @@ from collections import deque
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from concurrent.futures import Future, ThreadPoolExecutor, wait from concurrent.futures import Future, ThreadPoolExecutor, wait
from contextlib import contextmanager from contextlib import contextmanager
from datetime import UTC, datetime
from functools import lru_cache from functools import lru_cache
from typing import Any, Literal, Protocol from typing import Any, Literal, Protocol
@@ -166,6 +167,13 @@ class RunCacheLookup(Protocol):
"""What an equal execution produced, or None when there is no entry.""" """What an equal execution produced, or None when there is no entry."""
#: When the current shape of a cache key landed: the last change to what a
#: fingerprint hashes (`FlowController`) or to how the key below reduces its
#: inputs that moved every key. A row written before it matches nothing now,
#: so the run service's sweep un-keys it. Move it forward with such a change.
CACHE_SHAPE_SINCE = datetime(2026, 8, 26, 19, 27, 10, tzinfo=UTC)
def run_cache_key(fingerprint: str, inputs: dict[str, Any], flow: str = "") -> str: def run_cache_key(fingerprint: str, inputs: dict[str, Any], flow: str = "") -> str:
"""What this node, with these inputs, is known by. """What this node, with these inputs, is known by.
+22 -1
View File
@@ -52,7 +52,7 @@ from fluksio.core.db import engine as db_engine
from fluksio.flow.artifacts import ArtifactStore, is_reference, valid_digest from fluksio.flow.artifacts import ArtifactStore, is_reference, valid_digest
from fluksio.flow.controller import NODE_TYPES, FlowController, RunContext from fluksio.flow.controller import NODE_TYPES, FlowController, RunContext
from fluksio.flow.messages import qualify from fluksio.flow.messages import qualify
from fluksio.flow.pipeline import CacheHit, NodeOutcome, Pipeline from fluksio.flow.pipeline import CACHE_SHAPE_SINCE, CacheHit, NodeOutcome, Pipeline
from fluksio.flow.queue import WorkItem, WorkQueue from fluksio.flow.queue import WorkItem, WorkQueue
from fluksio.flow.schemas import FlowDef, NodeDef from fluksio.flow.schemas import FlowDef, NodeDef
from fluksio.flow.state import MemoryState, StateBackend from fluksio.flow.state import MemoryState, StateBackend
@@ -759,6 +759,26 @@ def _cacheable(outcome: NodeOutcome) -> str | None:
return text if len(text) <= OUTPUT_CAP else None return text if len(text) <= OUTPUT_CAP else None
def prune_stale_cache() -> int:
"""Un-key the stage-cache rows written before the current key shape.
Such a row can match no key a node computes any more, so it is a cache
entry in name only. The row and its outputs stay: they are the record of
what that run's node returned. Returns how many rows were un-keyed.
"""
with Session(db_engine) as session:
older = select(Run.id).where(col(Run.created_at) < CACHE_SHAPE_SINCE)
result = session.exec(
update(RunNode)
.where(col(RunNode.cache_key) != "", col(RunNode.run_id).in_(older))
.values(cache_key="")
)
session.commit()
if result.rowcount:
logger.info("Un-keyed %d stage-cache rows of an older shape", result.rowcount)
return int(result.rowcount)
class RunCache: class RunCache:
"""What earlier runs already worked out, looked up by cache key. """What earlier runs already worked out, looked up by cache key.
@@ -1353,6 +1373,7 @@ class RunService:
if time.monotonic() - last_sweep >= SWEEP_INTERVAL_S: if time.monotonic() - last_sweep >= SWEEP_INTERVAL_S:
last_sweep = time.monotonic() last_sweep = time.monotonic()
self._sweep_abandoned(now) self._sweep_abandoned(now)
prune_stale_cache()
except Exception: except Exception:
logger.exception("Could not refresh run leases") logger.exception("Could not refresh run leases")
+34 -1
View File
@@ -19,7 +19,7 @@ from fluksio.core.db import engine as db_engine
from fluksio.flow.artifacts import ArtifactStore from fluksio.flow.artifacts import ArtifactStore
from fluksio.flow.controller import FlowController from fluksio.flow.controller import FlowController
from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.pipeline import NodeOutcome from fluksio.flow.pipeline import CACHE_SHAPE_SINCE, NodeOutcome
from fluksio.flow.runs import ( from fluksio.flow.runs import (
LEASE_STALE_S, LEASE_STALE_S,
OUTPUT_CAP, OUTPUT_CAP,
@@ -28,6 +28,7 @@ from fluksio.flow.runs import (
RunService, RunService,
_cacheable, _cacheable,
new_run_id, new_run_id,
prune_stale_cache,
resolve_references, resolve_references,
seed_values, seed_values,
) )
@@ -106,6 +107,38 @@ def test_what_may_be_stored_as_a_cache_entry():
assert _cacheable(ok.model_copy(update={"output_values": big})) is None assert _cacheable(ok.model_copy(update={"output_values": big})) is None
def test_a_row_keyed_before_the_current_shape_is_unkeyed():
"""It matches nothing any more, but it is still the run's record."""
outputs = json.dumps({"study.loss": 1.0})
with Session(db_engine) as session:
for run_id, made in (
("shape-old", CACHE_SHAPE_SINCE - timedelta(days=1)),
("shape-new", CACHE_SHAPE_SINCE + timedelta(seconds=1)),
):
session.add(Run(id=run_id, flow="study", status="ok", created_at=made))
session.add(
RunNode(
run_id=run_id,
node="study.a",
cache_key=run_id,
outputs=outputs,
)
)
session.commit()
try:
assert prune_stale_cache() == 1
with Session(db_engine) as session:
old = session.get(RunNode, ("shape-old", "study.a"))
new = session.get(RunNode, ("shape-new", "study.a"))
assert old is not None and old.cache_key == "" and old.outputs == outputs
assert new is not None and new.cache_key == "shape-new"
finally:
with Session(db_engine) as session:
for run_id in ("shape-old", "shape-new"):
session.delete(session.get(Run, run_id))
session.commit()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Naming an artifact from outside the process that made it # Naming an artifact from outside the process that made it
# #
+3 -1
View File
@@ -139,7 +139,9 @@ Use `fluksio run ... --no-cache`, `fluksio sweep ... --no-cache`, or
Only Python nodes are cached. Built-in and connector nodes are not. The cache Only Python nodes are cached. Built-in and connector nodes are not. The cache
uses run history; outputs too large to store there are not cacheable, and uses run history; outputs too large to store there are not cacheable, and
missing artifact bytes make an entry a miss. missing artifact bytes make an entry a miss. An entry written before the cache
key last changed shape can never match again, so the engine drops it from the
cache and keeps the node's recorded outputs.
A cache hit does not replay emissions. Its metrics are read from the original A cache hit does not replay emissions. Its metrics are read from the original
run, so comparisons still show the recorded curve. Deleting that original run run, so comparisons still show the recorded curve. Deleting that original run