The rate the media dtypes could carry was one frame every second or two: each
was a file on the data volume, an event on the socket, and a request back for
the bytes. This closes both halves of that, and they are one feature.
`save_artifact(..., volatile=True)` writes to a `VolatileStore` — the same
content-addressed store, in `/dev/shm`, bounded by size with the oldest falling
out (`ARTIFACT_VOLATILE_BYTES`, 48 MB under the container's raised `shm_size`).
Nothing sweeps it: a frame nobody kept is not worth walking the store to find.
`ArtifactStore.path` falls through to it, which is what lets a volatile frame be
an ordinary reference everywhere else — the dtype check, a panel's digest scope,
`load_artifact` in a node, and the widget's own fetch all work on one unchanged.
`adopt` copies one into the store when a run records it, so "returned media is
kept, emitted media is not" stays true.
The bytes then go down the flows websocket as a length-prefixed binary frame,
sent just ahead of the `message_value` naming them, so a tile has the frame when
it hears the value moved. Nothing is pushed unasked: a client names the messages
it is drawing (`{"type":"media","names":[…]}`), a panel's list is intersected
with the scope it already had, and only the newest frame per name in a batch is
sent — a client that fell behind is not handed frames it would draw over. The
tunnel relays text only, so a screen reached through a portal falls back to
fetching, which is why the rate table now has two rows.
Around the edges: the remote worker's fetch cache is bounded at last
(`FLUKSIO_ARTIFACT_CACHE_BYTES`), since content addressing means nothing in it
ever expires and a media stream fills it with chunks nothing asks for twice; a
port carrying an image draws the frame in the node panel rather than only
saying `image/png · frame.png · 1.79kB`; and an edge chip says that much instead
of a line of hash. The media screenshot stops waiting for `networkidle` — a
camera is a socket that never goes quiet, which is the point of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YC4u66vjzW54fnHu5Juhh9
267 lines
9.6 KiB
Python
267 lines
9.6 KiB
Python
"""The artifact store: bytes a node produced, addressed by their content.
|
|
|
|
A typed message carries JSON, which is what lets the same value pass through
|
|
Redis, the work queue and the worker protocol unchanged. A model checkpoint is
|
|
not that, so it does not travel as a message — it is written here and the
|
|
message carries a reference to it.
|
|
|
|
Addressed by digest rather than by run, for three reasons. A sweep whose fifty
|
|
configs share one preprocessed input stores it once. A reference stays valid
|
|
however it is passed around, because it names content instead of a location.
|
|
And the digest is what a stage cache will compare, so building it in now is
|
|
what keeps that from being a change to the message contract later.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from collections.abc import Iterable, Iterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: How much is read at a time when hashing or serving.
|
|
CHUNK = 1024 * 1024
|
|
DIGEST_PREFIX = "sha256:"
|
|
|
|
|
|
def is_reference(value: Any) -> bool:
|
|
"""Whether a message payload is an artifact reference."""
|
|
return isinstance(value, dict) and str(value.get("digest", "")).startswith(
|
|
DIGEST_PREFIX
|
|
)
|
|
|
|
|
|
def valid_digest(digest: str) -> bool:
|
|
"""Guard for anything that reaches the filesystem from outside."""
|
|
if not digest.startswith(DIGEST_PREFIX):
|
|
return False
|
|
body = digest[len(DIGEST_PREFIX) :]
|
|
return len(body) == 64 and all(c in "0123456789abcdef" for c in body)
|
|
|
|
|
|
class ArtifactStore:
|
|
"""Content-addressed files under one directory."""
|
|
|
|
def __init__(self, root: Path) -> None:
|
|
self.root = root
|
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
#: A ring of frames held in memory, for media a flow only shows live.
|
|
#: Set by the engine at startup; every lookup below falls through to
|
|
#: it, which is what lets a volatile reference be an ordinary one.
|
|
self.volatile: VolatileStore | None = None
|
|
|
|
def _path(self, digest: str) -> Path:
|
|
body = digest[len(DIGEST_PREFIX) :]
|
|
# Two levels, so a directory listing stays usable at a hundred thousand
|
|
# artifacts.
|
|
return self.root / body[:2] / body
|
|
|
|
def put(
|
|
self,
|
|
chunks: Iterable[bytes],
|
|
name: str = "",
|
|
media_type: str = "",
|
|
volatile: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Store a stream and return the reference to it.
|
|
|
|
Written to a temporary file first and moved into place once the digest
|
|
is known, so a half-written artifact never has a name anyone can find.
|
|
A file already there is left alone: identical content is identical.
|
|
|
|
``volatile`` puts it in the ring instead, where it is held in memory
|
|
and falls out once newer frames need the room. The reference is the
|
|
same shape either way — what differs is how long the bytes last, and a
|
|
caller that wants one kept returns it from a run.
|
|
"""
|
|
if volatile and self.volatile is not None:
|
|
return self.volatile.put(chunks, name=name, media_type=media_type)
|
|
digester = hashlib.sha256()
|
|
size = 0
|
|
handle = tempfile.NamedTemporaryFile(dir=self.root, delete=False)
|
|
try:
|
|
with handle:
|
|
for chunk in chunks:
|
|
digester.update(chunk)
|
|
size += len(chunk)
|
|
handle.write(chunk)
|
|
digest = DIGEST_PREFIX + digester.hexdigest()
|
|
target = self._path(digest)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if target.exists():
|
|
os.unlink(handle.name)
|
|
else:
|
|
# Same content from two nodes at once is one rename winning and
|
|
# the other replacing a byte-identical file.
|
|
os.replace(handle.name, target)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(handle.name)
|
|
raise
|
|
return {
|
|
"digest": digest,
|
|
"size": size,
|
|
"media_type": media_type or "application/octet-stream",
|
|
"name": name,
|
|
}
|
|
|
|
def put_file(
|
|
self,
|
|
path: Path,
|
|
media_type: str = "",
|
|
name: str = "",
|
|
volatile: bool = False,
|
|
) -> dict[str, Any]:
|
|
with path.open("rb") as handle:
|
|
return self.put(
|
|
iter(lambda: handle.read(CHUNK), b""),
|
|
name=name or path.name,
|
|
media_type=media_type,
|
|
volatile=volatile,
|
|
)
|
|
|
|
def path(self, digest: str) -> Path | None:
|
|
"""Where the bytes are, or None if this store does not have them.
|
|
|
|
The ring is looked in second, so everything that resolves a digest —
|
|
serving one over HTTP, checking a run input still exists, a panel's
|
|
scope — reaches a live frame without knowing there are two stores.
|
|
"""
|
|
if not valid_digest(digest):
|
|
return None
|
|
target = self._path(digest)
|
|
if target.exists():
|
|
return target
|
|
if self.volatile is not None:
|
|
return self.volatile.path(digest)
|
|
return None
|
|
|
|
def adopt(self, digest: str) -> bool:
|
|
"""Copy a volatile artifact into this store, so it outlives the ring.
|
|
|
|
What makes "emitted media is not kept, returned media is" true: a run
|
|
recording a reference calls this, and the frame stops being one the
|
|
next few seconds can evict.
|
|
"""
|
|
if self.volatile is None or not valid_digest(digest):
|
|
return False
|
|
if self._path(digest).exists():
|
|
return True
|
|
source = self.volatile.path(digest)
|
|
if source is None:
|
|
return False
|
|
self.put_file(source)
|
|
return True
|
|
|
|
def read(self, digest: str) -> Iterator[bytes]:
|
|
target = self.path(digest)
|
|
if target is None:
|
|
raise FileNotFoundError(digest)
|
|
with target.open("rb") as handle:
|
|
while chunk := handle.read(CHUNK):
|
|
yield chunk
|
|
|
|
def collect(self, keep: set[str], grace_s: float = 0.0) -> int:
|
|
"""Delete what nothing refers to any more. Returns how many went.
|
|
|
|
The caller passes every digest still recorded; anything else in the
|
|
store was produced by a run that has since been pruned, or never got a
|
|
row at all because the run failed between writing and recording.
|
|
|
|
``grace_s`` spares anything written that recently. Storing bytes and
|
|
recording the reference to them are two steps, and a sweep landing
|
|
between them would take an artifact its run is about to name — so
|
|
recent files are left for the next pass, by which time they are either
|
|
referenced or genuinely orphaned.
|
|
"""
|
|
removed = 0
|
|
cutoff = time.time() - grace_s
|
|
for entry in self.root.glob("*/*"):
|
|
if not entry.is_file():
|
|
continue
|
|
if DIGEST_PREFIX + entry.name in keep:
|
|
continue
|
|
try:
|
|
if grace_s > 0 and entry.stat().st_mtime > cutoff:
|
|
continue
|
|
entry.unlink()
|
|
removed += 1
|
|
except OSError:
|
|
logger.warning("Could not remove artifact %s", entry.name)
|
|
return removed
|
|
|
|
|
|
class VolatileStore(ArtifactStore):
|
|
"""A bounded ring of artifacts, held wherever memory is cheaper than disk.
|
|
|
|
A camera publishing ten frames a second is ten files a second, and on the
|
|
wall panel this is built for that disk is an SD card. So the frames go to a
|
|
memory-backed directory instead and the oldest fall out once the ring is
|
|
full: nothing sweeps it, because a frame nobody kept is not worth a pass
|
|
over the store to find.
|
|
|
|
It is an ``ArtifactStore``, digest layout and all, which is what lets a
|
|
frame be an ordinary reference — the dtype check, the panel's scope, a
|
|
node opening one with ``load_artifact`` and the widget fetching one all
|
|
work on it unchanged.
|
|
"""
|
|
|
|
def __init__(self, root: Path, limit_bytes: int) -> None:
|
|
super().__init__(root)
|
|
self.limit_bytes = limit_bytes
|
|
|
|
def put(
|
|
self,
|
|
chunks: Iterable[bytes],
|
|
name: str = "",
|
|
media_type: str = "",
|
|
volatile: bool = False,
|
|
) -> dict[str, Any]:
|
|
reference = super().put(chunks, name=name, media_type=media_type)
|
|
self.trim()
|
|
return reference
|
|
|
|
def trim(self) -> int:
|
|
"""Drop the oldest until the ring is inside its bound.
|
|
|
|
Also called on a timer, because a worker in this container writes here
|
|
itself and the engine never sees that ``put``.
|
|
|
|
# ponytail: a scandir per trim. An in-memory index if a ring of
|
|
# thousands of frames ever shows up in a profile.
|
|
"""
|
|
if self.limit_bytes <= 0:
|
|
return 0
|
|
entries: list[tuple[float, int, Path]] = []
|
|
total = 0
|
|
for entry in self.root.glob("*/*"):
|
|
try:
|
|
stat = entry.stat()
|
|
except OSError:
|
|
continue
|
|
if not entry.is_file():
|
|
continue
|
|
entries.append((stat.st_mtime, stat.st_size, entry))
|
|
total += stat.st_size
|
|
if total <= self.limit_bytes:
|
|
return 0
|
|
|
|
removed = 0
|
|
for _mtime, size, entry in sorted(entries):
|
|
if total <= self.limit_bytes:
|
|
break
|
|
try:
|
|
entry.unlink()
|
|
except OSError:
|
|
continue
|
|
total -= size
|
|
removed += 1
|
|
return removed
|