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:
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user