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:
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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