Push a frame instead of storing and fetching it

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
This commit is contained in:
2026-09-02 10:15:14 +02:00
co-authored by Claude Opus 5
parent 518231aa39
commit d471614e6a
29 changed files with 1101 additions and 147 deletions
+4 -1
View File
@@ -61,7 +61,10 @@ def emit(**ports: Any) -> None:
def save_artifact(
source: Any, name: str = "", media_type: str = "application/octet-stream"
source: Any,
name: str = "",
media_type: str = "application/octet-stream",
volatile: bool = False,
) -> dict[str, Any]:
"""Put bytes in the artifact store and return a reference to them."""
raise RuntimeError(_OUTSIDE.format(name="save_artifact", verb="save to"))
+6 -1
View File
@@ -80,6 +80,7 @@ async def put_artifact(
request: Request,
name: str = Query(default=""),
media_type: str = Query(default=""),
volatile: bool = Query(default=False),
) -> Any:
"""Store the request body and answer with the reference to it.
@@ -87,6 +88,10 @@ async def put_artifact(
legitimate a body here as a checkpoint, and neither should have to fit in
memory twice. Capped, because nothing else here was: any account, and any
worker credential, could otherwise fill the data volume.
``volatile`` puts it in the ring instead of the store — a frame a screen is
watching now, which the oldest of falls out of memory rather than being
kept. A worker on another host publishing a camera sends this.
"""
store = _store(request)
cap = settings.MAX_ARTIFACT_BYTES
@@ -112,7 +117,7 @@ async def put_artifact(
# as long as the upload lasts.
await run_in_threadpool(handle.write, chunk)
return await run_in_threadpool(
store.put_file, Path(handle.name), media_type, name
store.put_file, Path(handle.name), media_type, name, volatile
)
finally:
Path(handle.name).unlink(missing_ok=True)
+96 -1
View File
@@ -28,6 +28,7 @@ from fluksio.api.deps import (
user_from_token,
)
from fluksio.core.db import engine
from fluksio.flow.artifacts import is_reference
from fluksio.flow.controller import FlowController
from fluksio.flow.dashboards import DashboardStore
from fluksio.flow.events import event_bus
@@ -931,6 +932,88 @@ def event_for_panel(event: dict[str, Any], only: set[str]) -> bool:
# should not be handed the whole queue in one message.
MAX_FRAME_EVENTS = 64
#: How many message names one socket may ask for bytes on. A screen draws a
#: handful of tiles; this is only here so a client cannot ask for the whole
#: namespace and be served every frame of it.
MAX_MEDIA_NAMES = 32
def wanted_names(
frame: str, only: set[str] | None, previous: set[str]
) -> set[str] | None:
"""What this client is asking to be sent bytes for, or None if it said
something else.
A client asks by name — the tiles it is drawing — and is answered only for
the names a panel credential would have been allowed anyway. Bytes are the
expensive thing on this socket, so nothing is pushed until something says
it is looking at it.
"""
try:
payload = orjson.loads(frame)
except orjson.JSONDecodeError:
return None
if not isinstance(payload, dict) or payload.get("type") != "media":
return None
names = payload.get("names")
if not isinstance(names, list):
return set()
asked = {str(name) for name in names[:MAX_MEDIA_NAMES]}
return asked if only is None else asked & only
def media_frames(
events: list[dict[str, Any]], wanted: set[str], store: Any
) -> list[bytes]:
"""The bytes behind the media values in this batch, one frame each.
Referenced-then-fetched costs a round trip per frame, which is what keeps
the message plane at a glance rather than a view. Pushing the bytes down
the socket that already carries the event closes that, and only for the
frames a client said it was drawing.
Only what the ring holds: the durable store is what a fetch is for, and a
checkpoint has no business being pushed at anyone. The newest value per
name wins, so a client that fell behind is not handed a backlog of frames
it would only draw over.
"""
if not wanted or store is None or getattr(store, "volatile", None) is None:
return []
newest: dict[str, dict[str, Any]] = {}
for event in events:
if event.get("type") != "message_value":
continue
name = str(event.get("name") or "")
if name not in wanted or not is_reference(event.get("value")):
continue
newest[name] = event
frames: list[bytes] = []
for name, event in newest.items():
value = event["value"]
digest = str(value.get("digest") or "")
path = store.volatile.path(digest)
if path is None:
continue
try:
payload = path.read_bytes()
except OSError:
continue
header = orjson.dumps(
{
"type": "media",
"name": name,
"digest": digest,
"media_type": str(value.get("media_type") or ""),
"ts": event.get("ts"),
}
)
# Length-prefixed, so one frame carries both halves and the reader
# never has to guess where the JSON stops.
frames.append(len(header).to_bytes(4, "big") + header + payload)
return frames
async def _send(websocket: WebSocket, events: list[dict[str, Any]]) -> None:
"""One event, or a batch of them under `events`.
@@ -962,12 +1045,16 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
await websocket.close(code=1008)
return
only = panel_scope(token, websocket.app)
# Nothing until a client says it is drawing something: bytes are what this
# socket cannot afford to send speculatively.
wanted: set[str] = set()
await websocket.accept()
controller: FlowController | None = getattr(
websocket.app.state, "flow_controller", None
)
store = getattr(websocket.app.state, "artifact_store", None)
async def send_snapshot() -> None:
if controller is not None:
@@ -989,7 +1076,10 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
# stream — a keepalive, or anything else it decides to
# say, used to be read as the client going away and cost
# it every live update from then on.
receiver.exception()
if receiver.exception() is None:
asked = wanted_names(receiver.result(), only, wanted)
if asked is not None:
wanted = asked
receiver = asyncio.create_task(websocket.receive_text())
if sender not in done:
continue
@@ -1017,6 +1107,7 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
# token — and that would widen this socket to
# everything on the bus.
only = panel_scope(token, websocket.app) or set()
wanted &= only
if out:
await _send(websocket, out)
out = []
@@ -1025,6 +1116,10 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
continue
out.append(event)
if out:
# The bytes first: a tile that has the frame when the
# value lands draws it in one pass rather than two.
for frame in media_frames(out, wanted, store):
await websocket.send_bytes(frame)
await _send(websocket, out)
except (WebSocketDisconnect, RuntimeError):
# A peer that goes away mid-send takes the RuntimeError route
+11
View File
@@ -150,6 +150,17 @@ class Settings(BaseSettings):
# Storing bytes and recording the reference are two steps; this is the
# window between them.
ARTIFACT_GC_GRACE_S: int = 3600
#: Where frames a flow only shows live are held: memory rather than the
#: data volume, so a camera at ten frames a second is not writing to an SD
#: card. Empty works it out — a directory under `/dev/shm` named for this
#: data directory, so two instances on one host do not trim each other —
#: and falls back to the temporary directory where there is no `/dev/shm`.
ARTIFACT_VOLATILE_DIR: Path | None = None
#: How much the volatile ring holds before the oldest frames fall out of
#: it; 0 turns it off, and a volatile save then lands in the durable store
#: like any other. Under Docker's default 64 MB `/dev/shm`: raise
#: `shm_size` with it.
ARTIFACT_VOLATILE_BYTES: int = 48 * 1024 * 1024
# Without a Redis host the engine keeps its state in memory.
REDIS_HOST: str | None = None
REDIS_PORT: int = 6379
+118 -4
View File
@@ -52,6 +52,10 @@ class ArtifactStore:
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) :]
@@ -60,14 +64,25 @@ class ArtifactStore:
return self.root / body[:2] / body
def put(
self, chunks: Iterable[bytes], name: str = "", media_type: str = ""
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)
@@ -98,21 +113,52 @@ class ArtifactStore:
}
def put_file(
self, path: Path, media_type: str = "", name: str = ""
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."""
"""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)
return target if target.exists() else None
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)
@@ -150,3 +196,71 @@ class ArtifactStore:
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
+9 -1
View File
@@ -141,6 +141,7 @@ class ConnectorNode(Node):
data: bytes,
name: str = "",
media_type: str = "application/octet-stream",
volatile: bool = False,
) -> dict[str, Any]:
"""Store bytes and return the reference to publish on a media port.
@@ -149,6 +150,11 @@ class ConnectorNode(Node):
store and the reference names them, which is what an ``image``,
``audio`` or ``video`` port carries.
``volatile`` is what a camera publishes with: the bytes go to a ring
held in memory rather than to the data volume, are pushed to whatever
screen is watching, and last seconds. Use it for a frame; leave it off
for a recording somebody asked to keep.
Only available once the node has started — the store belongs to the
engine, and is handed over then.
"""
@@ -156,7 +162,9 @@ class ConnectorNode(Node):
raise RuntimeError(
"no artifact store: a connector can only save bytes once it has started"
)
return self._artifacts.put([data], name=name, media_type=media_type)
return self._artifacts.put(
[data], name=name, media_type=media_type, volatile=volatile
)
async def start(self, app: FastAPI | None = None) -> None:
self._artifacts = getattr(app.state, "artifact_store", None) if app else None
+5
View File
@@ -1313,6 +1313,11 @@ class RunService:
with Session(db_engine) as session:
session.merge(row)
for message, ref in outcome.artifacts.items():
# A run recording a reference is what "returned media is
# kept" means: copied out of the volatile ring, or the row
# would outlive the bytes it names by a few seconds.
if self._artifacts is not None:
self._artifacts.adopt(str(ref.get("digest") or ""))
session.merge(
RunArtifact(
run_id=run_id,
+49 -2
View File
@@ -1,16 +1,19 @@
import asyncio
import contextlib
import hashlib
import inspect
import logging
import tempfile
from collections.abc import AsyncIterator, Callable, Coroutine
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from fluksio_worker.worker_main import ARTIFACT_DIR_ENV
from fluksio_worker.worker_main import ARTIFACT_DIR_ENV, ARTIFACT_VOLATILE_DIR_ENV
from starlette.middleware.cors import CORSMiddleware
from fluksio import __version__
@@ -23,7 +26,7 @@ from fluksio.core.db import engine as db_engine
from fluksio.core.db import prepare
from fluksio.flow import logs, modules
from fluksio.flow.alerts import AlertManager
from fluksio.flow.artifacts import ArtifactStore
from fluksio.flow.artifacts import ArtifactStore, VolatileStore
from fluksio.flow.controller import FlowController, RebuildBusy
from fluksio.flow.dashboards import DashboardStore
from fluksio.flow.events import event_bus
@@ -113,6 +116,43 @@ async def _sweep_artifacts(store: ArtifactStore, controller: FlowController) ->
logger.exception("Artifact sweep failed")
#: How often the ring is measured. Seconds rather than the sweep's hour: it is
#: bounded by size and the frames arriving are what push the old ones out.
VOLATILE_TRIM_S = 5.0
async def _trim_volatile(ring: VolatileStore) -> None:
"""Hold the volatile ring inside its bound.
``put`` trims what it wrote, but a worker in this container writes to the
ring itself and the engine never sees that one — so the bound needs
something of its own watching it.
"""
if ring.limit_bytes <= 0:
return
while True:
await asyncio.sleep(VOLATILE_TRIM_S)
try:
await run_in_threadpool(ring.trim)
except Exception:
logger.exception("Could not trim the volatile artifact ring")
def _volatile_root() -> Path:
"""Where the ring goes: memory if this machine has some to lend.
Named for the data directory rather than fixed, so two engines on one host
have a ring each instead of quietly evicting each other's frames.
"""
configured = settings.ARTIFACT_VOLATILE_DIR
if configured is not None:
return configured
tag = hashlib.sha256(str(settings.DATA_DIR.resolve()).encode()).hexdigest()[:8]
shm = Path("/dev/shm")
parent = shm if shm.is_dir() else Path(tempfile.gettempdir())
return parent / f"fluksio-volatile-{tag}"
def _mcp_sessions() -> AbstractAsyncContextManager[None]:
"""The MCP session manager's run scope, or nothing when MCP is off."""
if not settings.MCP_ENABLED:
@@ -205,6 +245,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Beside the flows rather than in them: an artifact is what a run produced,
# not something anyone wrote, so it has no business in the git repository.
artifacts = ArtifactStore(settings.FLOWS_DIR.parent / "artifacts")
# Frames a flow only shows live, held in memory and bounded by size.
# The store falls through to it, so a volatile reference is an
# ordinary one everywhere but in how long its bytes last.
volatile = VolatileStore(_volatile_root(), settings.ARTIFACT_VOLATILE_BYTES)
artifacts.volatile = volatile
app.state.artifact_store = artifacts
accountant = ResourceAccountant(
cpus=settings.FLOW_CPUS, gpus=settings.FLOW_GPUS
@@ -223,6 +268,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# is given a URL instead. Node code calls the same two functions.
env={
ARTIFACT_DIR_ENV: str(artifacts.root),
ARTIFACT_VOLATILE_DIR_ENV: str(volatile.root),
# Every slot can be busy at once, so a worker left to size its own
# thread pool to the machine means as many processes as there are
# slots, each believing it has the whole of it. A node that says
@@ -292,6 +338,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
_background(alerts.run(), "alert-manager")
_background(MetricsCollector(event_bus).run(), "metrics-collector")
_background(_sweep_artifacts(artifacts, controller), "artifact-gc")
_background(_trim_volatile(volatile), "artifact-ring")
await controller.start()
started.append(controller.stop)
run_service.start()
+80
View File
@@ -838,3 +838,83 @@ def test_a_panel_fetches_only_the_media_its_tiles_are_showing(
).status_code
== 403
)
def test_a_socket_pushes_only_the_frames_it_was_asked_for(tmp_path) -> None:
"""Bytes are the one thing this socket cannot send speculatively.
A frame a second per subscriber is affordable; every frame to every open
editor tab is not. So nothing goes until a client names what it is drawing,
and a panel credential can only name what it was already allowed to see.
"""
import orjson
from fluksio.api.routes.flows import media_frames, wanted_names
from fluksio.flow.artifacts import ArtifactStore, VolatileStore
store = ArtifactStore(tmp_path / "artifacts")
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
frame = store.put([b"\x89PNG..."], name="f.png", media_type="image/png",
volatile=True)
kept = store.put([b"checkpoint"], name="w.pt")
asking = orjson.dumps({"type": "media", "names": ["cam.frame", "other.frame"]})
# A person's socket gets what it asked for; a panel's is intersected with
# what it draws, so naming a message is not a way around the scope.
assert wanted_names(asking.decode(), None, set()) == {"cam.frame", "other.frame"}
assert wanted_names(asking.decode(), {"cam.frame"}, set()) == {"cam.frame"}
# Anything else on this socket leaves the set alone.
assert wanted_names('{"type":"ping"}', None, set()) is None
assert wanted_names("not json", None, set()) is None
events = [
{"type": "message_value", "name": "cam.frame", "value": frame, "ts": 1.0},
{"type": "message_value", "name": "other.frame", "value": frame, "ts": 1.0},
{"type": "message_value", "name": "cam.model", "value": kept, "ts": 1.0},
{"type": "node_log", "name": "cam.frame", "value": frame},
]
frames = media_frames(events, {"cam.frame", "cam.model"}, store)
# One frame: the ring's, for the name that asked. The durable checkpoint is
# what a fetch is for, and a log is not a value.
assert len(frames) == 1
length = int.from_bytes(frames[0][:4], "big")
header = orjson.loads(frames[0][4 : 4 + length])
assert header == {
"type": "media",
"name": "cam.frame",
"digest": frame["digest"],
"media_type": "image/png",
"ts": 1.0,
}
assert frames[0][4 + length :] == b"\x89PNG..."
# Nothing asked for, nothing sent.
assert media_frames(events, set(), store) == []
def test_only_the_newest_frame_of_a_batch_is_pushed(tmp_path) -> None:
"""A client that fell behind is not handed frames it would only draw over."""
import orjson
from fluksio.api.routes.flows import media_frames
from fluksio.flow.artifacts import ArtifactStore, VolatileStore
store = ArtifactStore(tmp_path / "artifacts")
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
first = store.put([b"one"], media_type="image/png", volatile=True)
second = store.put([b"two"], media_type="image/png", volatile=True)
frames = media_frames(
[
{"type": "message_value", "name": "cam.frame", "value": first, "ts": 1.0},
{"type": "message_value", "name": "cam.frame", "value": second, "ts": 2.0},
],
{"cam.frame"},
store,
)
assert len(frames) == 1
length = int.from_bytes(frames[0][:4], "big")
assert orjson.loads(frames[0][4 : 4 + length])["digest"] == second["digest"]
assert frames[0][4 + length :] == b"two"
+81
View File
@@ -730,3 +730,84 @@ def test_cancelling_reaches_a_node_running_in_a_child(pool):
assert pool.cancel("demo.slow"), "the parent must find a child's node"
thread.join(timeout=10)
assert failed
def test_a_node_saves_a_frame_to_the_ring(tmp_path):
"""A camera writes to memory, and the next node reads it from there.
The point of the ring being an ordinary store: node code says one word
more, and everything downstream the dtype, ``load_artifact``, the widget
carries on not knowing there are two of them.
"""
from fluksio_worker.worker_main import ARTIFACT_VOLATILE_DIR_ENV
from fluksio.flow.artifacts import VolatileStore
store = ArtifactStore(tmp_path / "artifacts")
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
pool = PythonWorkerPool(
python=sys.executable,
size=1,
env={
ARTIFACT_DIR_ENV: str(store.root),
ARTIFACT_VOLATILE_DIR_ENV: str(store.volatile.root),
},
)
pool.start()
try:
ref = pool.run(
"demo",
"frame",
"import fluksio\n"
"def process():\n"
" return {'frame': fluksio.save_artifact(\n"
" b'\\x89PNG' + b'p' * 64, 'f.png',\n"
" media_type='image/png', volatile=True)}\n",
{},
"demo.frame",
timeout=10,
)["frame"]
assert MessageSpec(name="frame", dtype=DType.IMAGE).check(ref) is None
# In the ring, and nowhere near the data volume.
assert store.volatile.path(ref["digest"]) is not None
assert not (store.root / ref["digest"][7:9]).exists()
read = pool.run(
"demo",
"read",
"import fluksio\n"
"def process(frame):\n"
" with open(fluksio.load_artifact(frame), 'rb') as f:\n"
" return {'size': len(f.read())}\n",
{"frame": ref},
"demo.read",
timeout=10,
)
assert read == {"size": 68}
finally:
pool.stop()
def test_the_fetch_cache_is_bounded(tmp_path):
"""A worker downloading a media stream fills its cache with chunks nothing
will ask for twice, and content addressing means nothing ever expires."""
import os
from fluksio_worker.worker_main import ARTIFACT_CACHE_BYTES_ENV, _trim_cache
cache = tmp_path / "cache"
cache.mkdir()
for index in range(5):
entry = cache / f"chunk{index}"
entry.write_bytes(b"c" * 1000)
os.utime(entry, (index, index))
os.environ[ARTIFACT_CACHE_BYTES_ENV] = "3000"
try:
_trim_cache(str(cache))
finally:
del os.environ[ARTIFACT_CACHE_BYTES_ENV]
left = sorted(entry.name for entry in cache.iterdir())
assert left == ["chunk2", "chunk3", "chunk4"]
+74 -1
View File
@@ -3,10 +3,13 @@ import time
from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, col, select
from fluksio.core.config import settings
from fluksio.core.db import engine as db_engine
from fluksio.flow.artifacts import ArtifactStore
from fluksio.flow.artifacts import ArtifactStore, VolatileStore
from fluksio.flow.runs import new_run_id, sweep_artifacts
from fluksio.flow.state import MemoryState
from fluksio.models import Run, RunArtifact
@@ -108,3 +111,73 @@ def test_a_streamed_chunk_falls_out_once_the_message_moves_on(store):
assert sweep_artifacts(store, state, grace_s=5) == 1
assert store.path(first["digest"]) is None
assert store.path(second["digest"]) is not None
def test_a_ring_drops_the_oldest_once_it_is_full(tmp_path):
"""What makes a camera affordable: the room is fixed, not the history."""
ring = VolatileStore(tmp_path / "ring", limit_bytes=3000)
refs = [ring.put([bytes([i]) * 1000], name=f"{i}.bin") for i in range(5)]
held = [ref for ref in refs if ring.path(ref["digest"]) is not None]
assert len(held) == 3
# The newest three, in order: eviction is by age, not by chance.
assert held == refs[2:]
def test_a_volatile_reference_resolves_like_any_other(tmp_path):
"""A frame is an ordinary reference, which is what keeps the rest honest.
Everything that resolves a digest serving one, checking a run's input,
a panel's scope — goes through ``path``, so the ring has to answer there.
"""
store = ArtifactStore(tmp_path / "artifacts")
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
ref = store.put([b"frame"], name="f.png", media_type="image/png", volatile=True)
assert store.path(ref["digest"]) is not None
# In the ring, and not on the volume the sweep is about.
assert not (store.root / ref["digest"][7:9]).exists()
def test_a_recorded_frame_is_copied_out_of_the_ring(tmp_path):
"""Emitted media is not kept; returned media is."""
store = ArtifactStore(tmp_path / "artifacts")
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
ref = store.put([b"kept"], name="f.png", volatile=True)
assert store.adopt(ref["digest"]) is True
store.volatile.collect(set())
assert store.path(ref["digest"]) is not None
assert store.adopt("sha256:" + "0" * 64) is False
def test_an_upload_may_ask_for_the_ring(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A camera on another host publishes over HTTP and wants the ring too."""
store = client.app.state.artifact_store
previous = store.volatile
store.volatile = VolatileStore(
store.root.parent / "test-ring", limit_bytes=1_000_000
)
try:
url = f"{settings.API_V1_STR}/artifacts"
answer = client.put(
f"{url}?name=f.png&media_type=image%2Fpng&volatile=1",
headers=superuser_token_headers,
content=b"\x89PNG-frame",
)
assert answer.status_code == 200, answer.text
digest = answer.json()["digest"]
assert store.volatile.path(digest) is not None
assert not (store.root / digest[7:9]).exists()
# And it is served back like anything else, which is what the widget
# falls back on when the socket did not push it.
served = client.get(f"{url}/{digest}", headers=superuser_token_headers)
assert served.status_code == 200
assert served.content == b"\x89PNG-frame"
finally:
store.volatile = previous