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
|
||||
|
||||
@@ -141,6 +141,11 @@ services:
|
||||
volumes:
|
||||
- app-flow-data:/data
|
||||
|
||||
# `/dev/shm`, where frames a flow only shows live are held. Docker's
|
||||
# default is 64 MB, which the ring's own bound sits just under; raise both
|
||||
# together (ARTIFACT_VOLATILE_BYTES) for more cameras or bigger frames.
|
||||
shm_size: 128mb
|
||||
|
||||
# Deep health: fails when the event loop is wedged or Redis is gone, not
|
||||
# just when the process is dead. Autoheal restarts on unhealthy.
|
||||
healthcheck:
|
||||
|
||||
+12
-2
@@ -137,7 +137,8 @@ def process(speech): # an `audio` port
|
||||
def process(camera_url):
|
||||
for index, jpeg in enumerate(grab(camera_url)): # a generator
|
||||
frame = fluksio.save_artifact(
|
||||
jpeg, f"frame-{index:05d}.jpg", media_type="image/jpeg"
|
||||
jpeg, f"frame-{index:05d}.jpg",
|
||||
media_type="image/jpeg", volatile=True,
|
||||
)
|
||||
frame["meta"] = {"seq": index}
|
||||
yield {"frame": frame} # an `image` stream port
|
||||
@@ -148,12 +149,21 @@ has to match, so a node declaring `audio` never receives a video by accident.
|
||||
See [Payload types](../reference/payload-types.md#image-audio-video) for what
|
||||
each carries and what rates are realistic.
|
||||
|
||||
`volatile=True` is for a frame rather than a result. The bytes go to a ring in
|
||||
memory instead of the data volume, and the engine pushes them down the
|
||||
websocket to whichever screens are drawing that message — so a camera runs at
|
||||
ten frames a second without writing anything to disk. They last as long as it
|
||||
takes newer frames to need the room. Leave it off for a clip somebody asked to
|
||||
keep.
|
||||
|
||||
!!! warning "Emitted media is not kept; returned media is"
|
||||
|
||||
Only what a node *returns* is recorded against its run. Frames yielded
|
||||
along the way are replaced in state by the next one, and the artifact sweep
|
||||
removes bytes nothing refers to any more, which is what stops a camera
|
||||
filling the disk. If a particular frame matters, return it.
|
||||
filling the disk. If a particular frame matters, return it: a volatile one
|
||||
is copied out of the ring when the run records it, so returning it is also
|
||||
what makes it outlive the next few seconds.
|
||||
|
||||
## Printing
|
||||
|
||||
|
||||
@@ -141,6 +141,14 @@ filesystem writes to it directly; one that does not fetches and uploads over
|
||||
HTTP, using the artifact endpoint beside the socket it already has. Either way
|
||||
your node code is the same two calls.
|
||||
|
||||
A fetch is cached on the worker by digest, since content addressing means an
|
||||
entry is never stale. Nothing expires on its own, so the cache is bounded by
|
||||
size and the oldest fall out: `FLUKSIO_ARTIFACT_CACHE` says where it lives
|
||||
(default a directory in the temporary directory) and
|
||||
`FLUKSIO_ARTIFACT_CACHE_BYTES` how much it holds (default 1 GiB). Worth raising
|
||||
where a worker reads the same large inputs repeatedly, and worth leaving alone
|
||||
where it reads a media stream — those are chunks nothing asks for twice.
|
||||
|
||||
## Seeing what is attached
|
||||
|
||||
```sh
|
||||
|
||||
@@ -105,21 +105,24 @@ so an answer to a different question is ignored.
|
||||
|
||||
A media widget draws what its message points at: a picture, a clip with
|
||||
controls, a video. Media does not travel as a message; a reference to it does.
|
||||
The tile fetches the bytes behind whichever reference the message holds,
|
||||
and redraws when a new one arrives.
|
||||
The tile takes the bytes behind whichever reference the message holds, and
|
||||
redraws when a new one arrives.
|
||||
|
||||
**Crop or fit** decides how a picture fills the tile. **Play as it arrives**
|
||||
starts a clip by itself, though a browser only plays sound once somebody has
|
||||
touched the page, so a screen nobody has tapped stays silent.
|
||||
|
||||
Rate is the thing to get right. A frame every second or two is a glance at a
|
||||
door, and works; through the portal, make that every few seconds. Live video is
|
||||
not something to push through the message plane at all. Put the camera's own
|
||||
address in **Live stream** and the browser plays it from source, leaving the
|
||||
messages to carry the occasional still that a flow can actually react to.
|
||||
Rate is the thing to get right, and it is decided by the node publishing rather
|
||||
than by the tile. A frame the engine is holding in memory is pushed down the
|
||||
same websocket that carries the value, so ten a second is a real view on the
|
||||
local network; a stored one is fetched, a round trip each, which suits a glance
|
||||
at a door every second or two. Through the portal everything is fetched, so
|
||||
make it every few seconds there. Above that, put the camera's own address in
|
||||
**Live stream** and the browser plays it from source, leaving the messages to
|
||||
carry the occasional still that a flow can actually react to.
|
||||
|
||||
Panels see media the same way, and only their own: a screen may fetch the bytes
|
||||
its own tiles are showing and nothing else.
|
||||
Panels see media the same way, and only their own: a screen is sent, and may
|
||||
fetch, the bytes its own tiles are showing and nothing else.
|
||||
|
||||
## Player tiles
|
||||
|
||||
|
||||
@@ -131,6 +131,9 @@ warning into a refusal to start.
|
||||
| `OBS_RETENTION_DAYS` | `30` | how long metrics, events and run records are kept |
|
||||
| `ARTIFACT_GC_INTERVAL_S` | `3600` | how often artifact bytes nothing refers to are swept away; 0 never sweeps |
|
||||
| `ARTIFACT_GC_GRACE_S` | `3600` | how long a freshly written artifact is spared, whatever refers to it |
|
||||
| `MAX_ARTIFACT_BYTES` | `2147483648` | the largest body `PUT /artifacts` will take; 0 removes the limit |
|
||||
| `ARTIFACT_VOLATILE_DIR` | worked out | where frames a flow only shows live are held; empty picks a directory under `/dev/shm` named for the data directory, and falls back to the temporary directory |
|
||||
| `ARTIFACT_VOLATILE_BYTES` | `50331648` | how much that ring holds before the oldest frames fall out; 0 turns it off and volatile saves land in the store |
|
||||
|
||||
The three concurrency limits are also flags on `fluksio serve`
|
||||
(`--max-workers`, `--max-cascades`, `--max-runs`), as is the card count,
|
||||
@@ -146,6 +149,15 @@ sweep is what keeps a flow streaming media from filling the disk. It stands
|
||||
aside entirely while a run is in flight, since a node may store a checkpoint
|
||||
long before it returns the reference to it.
|
||||
|
||||
Frames saved with `volatile=True` skip all of that. They go to a ring in memory
|
||||
instead of the volume, the oldest falling out once the newest need the room,
|
||||
and the engine pushes them down the websocket to whichever screens are drawing
|
||||
them — which is what a camera at ten frames a second needs and the store cannot
|
||||
give it. A frame a run *records* is copied into the store on the way, so
|
||||
returned media is kept and emitted media is not. Under Docker the ring lives in
|
||||
the container's `/dev/shm`, whose default is 64 MB: raise `shm_size` alongside
|
||||
`ARTIFACT_VOLATILE_BYTES`.
|
||||
|
||||
A node that declares nothing is not accounted against `FLOW_CPUS`; it runs on
|
||||
the shared pool and is given `FLOW_CPUS / FLOW_MAX_WORKERS` as a thread cap, so
|
||||
several at once cannot each size themselves to the whole machine. Setting
|
||||
|
||||
@@ -177,7 +177,9 @@ connector publishes a reference to it instead:
|
||||
async def poll(self) -> dict[str, Any] | None:
|
||||
jpeg = await asyncio.to_thread(self._grab)
|
||||
return {
|
||||
"frame": self.save_artifact(jpeg, "frame.jpg", media_type="image/jpeg")
|
||||
"frame": self.save_artifact(
|
||||
jpeg, "frame.jpg", media_type="image/jpeg", volatile=True
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -186,10 +188,18 @@ async def poll(self) -> dict[str, Any] | None:
|
||||
works once the node has started, since the store is the engine's and is handed
|
||||
over then.
|
||||
|
||||
`volatile=True` is what a camera publishes with. The frame goes to a ring in
|
||||
memory rather than the data volume and is pushed down the websocket to
|
||||
whichever screens are drawing it, so a wall panel sees ten frames a second and
|
||||
the SD card under it is never written to. Frames last until newer ones need the
|
||||
room; leave the flag off for a reading somebody asked to keep.
|
||||
|
||||
Each reading is a new artifact, which the poll loop publishes because its
|
||||
digest differs from the last. Set `poll_interval` to what somebody actually
|
||||
wants to look at: a frame every second or two is a glance, and live video
|
||||
belongs on the camera's own stream rather than in the graph.
|
||||
wants to look at. Through a portal the bytes are fetched rather than pushed, so
|
||||
a remote panel wants a frame every second or two; higher rates than that are
|
||||
for the local network, and full-rate video still belongs on the camera's own
|
||||
stream.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
|
||||
@@ -117,19 +117,27 @@ sequence numbers are for whoever consumes the media.
|
||||
|
||||
Bytes still never travel as a message. A camera publishes one reference per
|
||||
frame and a microphone one per chunk, which makes a media stream an ordinary
|
||||
[streaming port](../concepts/flows.md#streaming-ports), and each frame an
|
||||
artifact. What that costs is worth knowing before pointing a camera at it:
|
||||
[streaming port](../concepts/flows.md#streaming-ports). What that costs
|
||||
depends on how the bytes reach the screen:
|
||||
|
||||
| Rate | Where it works |
|
||||
|---|---|
|
||||
| A clip a second (speech) | anywhere, including through the portal |
|
||||
| A frame every second or two (a glance at a door) | locally; through the portal, every few seconds |
|
||||
| Live video, 15–30 fps | not here — see below |
|
||||
| Ten frames a second (a camera worth watching) | on the local network, with `volatile=True` |
|
||||
| A frame every second or two (a glance at a door) | anywhere, including through the portal |
|
||||
| Full-rate video, 30 fps and up | not here — see below |
|
||||
|
||||
Real-time video is not a message-plane problem: every frame would be an
|
||||
artifact, an event and a fetch. Point a media widget's **stream URL** at
|
||||
whatever the camera already serves and the browser plays it from source; the
|
||||
messages then carry the occasional still, and the flow reacts to those.
|
||||
The difference is one flag. A frame saved with
|
||||
[`volatile=True`](../code/nodes.md#media) is held in memory and pushed down the
|
||||
websocket in front of the value naming it, so a screen draws it without asking
|
||||
for anything. A stored one is fetched instead: a round trip per frame, which is
|
||||
a glance rather than a view. Through a portal every frame is fetched, so a
|
||||
remote panel is in the second row whatever the flag says.
|
||||
|
||||
Above that, video is not a message-plane problem — every frame would still be
|
||||
an event. Point a media widget's **stream URL** at whatever the camera already
|
||||
serves and the browser plays it from source; the messages then carry the
|
||||
occasional still, and the flow reacts to those.
|
||||
|
||||
### `json`
|
||||
|
||||
|
||||
@@ -199,13 +199,18 @@ async function captureDashboards(page, dir) {
|
||||
*
|
||||
* Skipped unless the media example is seeded (root `make seed-example-media`),
|
||||
* since it is the one shot that needs a source of frames. The bytes arrive as
|
||||
* a blob — the tile fetches them with the session's credential, which no `img`
|
||||
* could carry on its own — so a `blob:` source is the proof the whole path ran
|
||||
* rather than that a picture is merely present.
|
||||
* a blob — pushed down the socket, or fetched with the session's credential,
|
||||
* which no `img` could carry on its own — so a `blob:` source is the proof the
|
||||
* whole path ran rather than that a picture is merely present.
|
||||
*
|
||||
* The one page that cannot wait for `networkidle`: a camera publishing several
|
||||
* frames a second is a socket that never goes quiet, which is the point of it.
|
||||
* The blob source below is a stronger wait anyway — it says a frame arrived,
|
||||
* where idleness only ever said the page stopped asking.
|
||||
*/
|
||||
async function captureMedia(page, dir) {
|
||||
const answer = await page.goto(`${APP_URL}/view/camera`, {
|
||||
waitUntil: "networkidle",
|
||||
waitUntil: "domcontentloaded",
|
||||
})
|
||||
if (!answer?.ok()) return
|
||||
|
||||
|
||||
@@ -1,82 +1,17 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { apiToken } from "@/lib/portal"
|
||||
import { isRef, useArtifactUrl } from "@/lib/media"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useBoundValue } from "./dataContext"
|
||||
import { config, text } from "./ui/core/config"
|
||||
import type { WidgetProps } from "./widgets"
|
||||
|
||||
/** An artifact reference, as a message carries one. */
|
||||
type MediaRef = {
|
||||
digest?: string
|
||||
media_type?: string
|
||||
name?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
const isRef = (value: unknown): value is MediaRef =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as MediaRef).digest === "string" &&
|
||||
(value as MediaRef).digest!.startsWith("sha256:")
|
||||
|
||||
/**
|
||||
* A local URL for an artifact's bytes, refreshed whenever the digest changes.
|
||||
*
|
||||
* Not the endpoint itself: `/artifacts/{digest}` takes a bearer token, and no
|
||||
* `<img>` or `<audio>` can carry a header. So the bytes come through fetch and
|
||||
* are handed to the element as an object URL — which also means playback never
|
||||
* asks the server for a range, since the blob is already here.
|
||||
*
|
||||
* The URL is revoked when it is replaced, or the tab would hold every frame a
|
||||
* camera has ever sent for as long as the page is open.
|
||||
*/
|
||||
function useArtifactUrl(ref: MediaRef | null): string {
|
||||
const digest = ref?.digest ?? ""
|
||||
const mediaType = ref?.media_type ?? ""
|
||||
const [url, setUrl] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!digest) {
|
||||
setUrl("")
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
let made = ""
|
||||
const token = apiToken()
|
||||
const query = mediaType
|
||||
? `?media_type=${encodeURIComponent(mediaType)}`
|
||||
: ""
|
||||
fetch(`${OpenAPI.BASE}/api/v1/artifacts/${digest}${query}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((answer) => (answer.ok ? answer.blob() : Promise.reject(answer)))
|
||||
.then((blob) => {
|
||||
if (!live) return
|
||||
made = URL.createObjectURL(blob)
|
||||
setUrl(made)
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setUrl("")
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
if (made) URL.revokeObjectURL(made)
|
||||
}
|
||||
}, [digest, mediaType])
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* What a message's bytes look like: a frame, a clip, a segment.
|
||||
*
|
||||
* Media never travels as a message — the reference does, and the bytes are
|
||||
* fetched from the artifact store. What that means for a wall panel is a
|
||||
* refresh per published frame, which suits a camera glancing every few seconds
|
||||
* rather than a live view; for that, point `stream_url` at whatever the camera
|
||||
* already serves and the browser plays it directly.
|
||||
* Media never travels as a message — the reference does. The bytes come down
|
||||
* the socket in front of it where the engine is holding the frame in memory,
|
||||
* and are fetched from the artifact store otherwise; either way this tile only
|
||||
* asks for what it is drawing. A camera serving its own stream is still played
|
||||
* from source: point `stream_url` at it and the browser does the work.
|
||||
*/
|
||||
export function MediaWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
@@ -85,7 +20,7 @@ export function MediaWidget({ widget }: WidgetProps) {
|
||||
const live = useBoundValue(message || undefined)
|
||||
const value = live?.value
|
||||
const ref = isRef(value) ? value : null
|
||||
const url = useArtifactUrl(ref)
|
||||
const url = useArtifactUrl(ref, message || undefined)
|
||||
|
||||
const kind = (ref?.media_type ?? text(cfg.dtype)).split("/")[0]
|
||||
const fit = text(cfg.fit) === "contain" ? "object-contain" : "object-cover"
|
||||
|
||||
@@ -159,7 +159,12 @@ export function EdgeInspector({
|
||||
Nothing has come through yet. Run the flow to see a value here.
|
||||
</p>
|
||||
) : scalar === null ? (
|
||||
<ValuePreview value={live.value} className="mt-2" defaultOpen />
|
||||
<ValuePreview
|
||||
value={live.value}
|
||||
name={edge.message}
|
||||
className="mt-2"
|
||||
defaultOpen
|
||||
/>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@xyflow/react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { describeArtifact, isRef } from "@/lib/media"
|
||||
import { duration } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { FlowEdgeData } from "./deriveEdges"
|
||||
@@ -66,6 +67,9 @@ function formatValue(value: unknown): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
if (typeof value === "string") return value
|
||||
// A media reference serialised whole is a line of hash: what belongs in a
|
||||
// chip this size is what kind of bytes crossed the edge.
|
||||
if (isRef(value)) return describeArtifact(value as Record<string, unknown>)
|
||||
return JSON.stringify(value) ?? ""
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,13 @@ export function MessageSparkline({
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return <ValuePreview value={live.value} dtype={dtype} />
|
||||
return (
|
||||
<ValuePreview
|
||||
value={live.value}
|
||||
name={qualify(flow, message)}
|
||||
dtype={dtype}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The value is live here, so the dot on the newest reading is earned. The
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useState } from "react"
|
||||
|
||||
import type { DType } from "@/client"
|
||||
import { Marquee } from "@/components/Common/Marquee"
|
||||
import { cn, si } from "@/lib/utils"
|
||||
import { describeArtifact, isRef, useArtifactUrl } from "@/lib/media"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* How much of a structured value is worth unfolding in a side panel.
|
||||
@@ -19,11 +20,6 @@ function count(n: number, one: string, many = `${one}s`): string {
|
||||
return `${n} ${n === 1 ? one : many}`
|
||||
}
|
||||
|
||||
/** Whether this is an artifact reference rather than data of its own. */
|
||||
function isArtifact(value: Record<string, unknown>): boolean {
|
||||
return typeof value.digest === "string" && value.digest.startsWith("sha256:")
|
||||
}
|
||||
|
||||
/**
|
||||
* What a structured value *is*, in the space a value would have taken.
|
||||
*
|
||||
@@ -38,21 +34,7 @@ function summarize(value: object, dtype?: DType): string {
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (isArtifact(record)) {
|
||||
const name =
|
||||
typeof record.name === "string" && record.name ? record.name : ""
|
||||
const size = typeof record.size === "number" ? `${si(record.size)}B` : ""
|
||||
// The media type when there is one: what kind of bytes these are is the
|
||||
// first thing worth knowing about a frame or a clip, and it is what the
|
||||
// port's own type was declared against.
|
||||
const media =
|
||||
typeof record.media_type === "string" &&
|
||||
record.media_type &&
|
||||
record.media_type !== "application/octet-stream"
|
||||
? record.media_type
|
||||
: "artifact"
|
||||
return [media, name, size].filter(Boolean).join(" · ")
|
||||
}
|
||||
if (isRef(record)) return describeArtifact(record)
|
||||
|
||||
if (Array.isArray(record.lines)) {
|
||||
const points = record.lines.reduce(
|
||||
@@ -77,13 +59,39 @@ function summarize(value: object, dtype?: DType): string {
|
||||
* which is what keeps a panel four hundred pixels wide from being pushed open
|
||||
* by one checkpoint reference.
|
||||
*/
|
||||
/**
|
||||
* The frame itself, where the value is one.
|
||||
*
|
||||
* A port carrying an image says `image/png · frame.png · 48kB`, which is the
|
||||
* right answer while wiring and the wrong one while pointing a camera. Drawn
|
||||
* small: this is a side panel, and the tile is where a frame is looked at.
|
||||
*/
|
||||
function Thumbnail({ value, name }: { value: object; name?: string }) {
|
||||
const url = useArtifactUrl(value, name)
|
||||
if (!url) return null
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={String((value as { name?: string }).name ?? "frame")}
|
||||
className="mt-1 max-h-24 w-full rounded-sm object-contain"
|
||||
data-testid="value-preview-thumbnail"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ValuePreview({
|
||||
value,
|
||||
name,
|
||||
dtype,
|
||||
defaultOpen = false,
|
||||
className,
|
||||
}: {
|
||||
value: unknown
|
||||
/**
|
||||
* The message this value is on. Only needed to draw a live frame: it is what
|
||||
* asks the engine to push the bytes for it.
|
||||
*/
|
||||
name?: string
|
||||
dtype?: DType
|
||||
/** Start unfolded, where there is room for it — an inspector, not a row. */
|
||||
defaultOpen?: boolean
|
||||
@@ -91,6 +99,8 @@ export function ValuePreview({
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const structured = value !== null && typeof value === "object"
|
||||
const image =
|
||||
structured && isRef(value) && (value.media_type ?? "").startsWith("image/")
|
||||
|
||||
if (!structured) {
|
||||
return (
|
||||
@@ -118,6 +128,7 @@ export function ValuePreview({
|
||||
/>
|
||||
<Marquee text={summarize(value, dtype)} className="flex-1 font-mono" />
|
||||
</button>
|
||||
{image ? <Thumbnail value={value as object} name={name} /> : null}
|
||||
{open ? (
|
||||
<pre
|
||||
className={cn(
|
||||
|
||||
@@ -83,6 +83,26 @@ const listeners = new Map<string, Set<Listener>>()
|
||||
let connected = false
|
||||
const connectionListeners = new Set<Listener>()
|
||||
|
||||
/**
|
||||
* Object URLs for frames the engine pushed, keyed by digest.
|
||||
*
|
||||
* Small on purpose: a camera's frames are worth exactly as long as the next
|
||||
* one takes to arrive, and a tab that held every one of them would grow
|
||||
* without bound. The oldest is revoked when the room runs out.
|
||||
*/
|
||||
const BYTES_LIMIT = 8
|
||||
const bytes = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Which messages this page is drawing bytes for, and how many tiles each.
|
||||
*
|
||||
* Refcounted because two tiles may show one camera and the first to unmount
|
||||
* must not stop the second's frames. The socket sends the key set whenever it
|
||||
* changes; nothing is pushed for a name nobody is looking at.
|
||||
*/
|
||||
const wanted = new Map<string, number>()
|
||||
const wantedListeners = new Set<Listener>()
|
||||
|
||||
function notify(key: string) {
|
||||
for (const listener of listeners.get(key) ?? []) listener()
|
||||
}
|
||||
@@ -247,6 +267,53 @@ export const liveStore = {
|
||||
isConnected() {
|
||||
return connected
|
||||
},
|
||||
/**
|
||||
* Take a frame the socket pushed, as an object URL the tile can draw.
|
||||
*
|
||||
* Content addressing means a digest already here is the same bytes, so the
|
||||
* blob is dropped rather than replacing an identical one.
|
||||
*/
|
||||
setBytes(digest: string, blob: Blob) {
|
||||
if (bytes.has(digest)) return
|
||||
bytes.set(digest, URL.createObjectURL(blob))
|
||||
while (bytes.size > BYTES_LIMIT) {
|
||||
const oldest = bytes.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
const url = bytes.get(oldest)
|
||||
bytes.delete(oldest)
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
notify(`bytes:${oldest}`)
|
||||
}
|
||||
notify(`bytes:${digest}`)
|
||||
},
|
||||
getBytes(digest: string) {
|
||||
return bytes.get(digest)
|
||||
},
|
||||
/** Ask for this message's frames while the caller is drawing it. */
|
||||
wantBytes(name: string) {
|
||||
wanted.set(name, (wanted.get(name) ?? 0) + 1)
|
||||
if (wanted.get(name) === 1)
|
||||
for (const listener of wantedListeners) listener()
|
||||
return () => {
|
||||
const left = (wanted.get(name) ?? 1) - 1
|
||||
if (left > 0) {
|
||||
wanted.set(name, left)
|
||||
return
|
||||
}
|
||||
wanted.delete(name)
|
||||
for (const listener of wantedListeners) listener()
|
||||
}
|
||||
},
|
||||
wantedNames() {
|
||||
return [...wanted.keys()]
|
||||
},
|
||||
/** Told when the set changes, so the socket can say so. */
|
||||
onWanted(listener: Listener) {
|
||||
wantedListeners.add(listener)
|
||||
return () => {
|
||||
wantedListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
for (const key of values.keys()) notify(`value:${key}`)
|
||||
values.clear()
|
||||
@@ -264,6 +331,13 @@ export const liveStore = {
|
||||
notify("logs")
|
||||
for (const flow of paused) notify(`paused:${flow}`)
|
||||
paused.clear()
|
||||
for (const [digest, url] of bytes) {
|
||||
URL.revokeObjectURL(url)
|
||||
notify(`bytes:${digest}`)
|
||||
}
|
||||
bytes.clear()
|
||||
// `wanted` is deliberately kept: the tiles asking are still mounted, and a
|
||||
// reconnecting socket has to say what they want all over again.
|
||||
},
|
||||
}
|
||||
|
||||
@@ -274,6 +348,15 @@ export function useLiveValue(name: string | undefined): LiveValue | undefined {
|
||||
)
|
||||
}
|
||||
|
||||
/** The pushed bytes for a digest, if the socket carried them. */
|
||||
export function useLiveBytes(digest: string | undefined): string | undefined {
|
||||
return useSyncExternalStore(
|
||||
(listener) =>
|
||||
digest ? subscribeKey(`bytes:${digest}`, listener) : () => {},
|
||||
() => (digest ? bytes.get(digest) : undefined),
|
||||
)
|
||||
}
|
||||
|
||||
export function useNodeStatus(nodeId: string): LiveStatus | undefined {
|
||||
return useSyncExternalStore(
|
||||
(listener) => subscribeKey(`status:${nodeId}`, listener),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
|
||||
import { healthKeys } from "@/components/Health/queries"
|
||||
import { runKeys } from "@/components/Runs/queries"
|
||||
import { connectionStore } from "@/lib/connectionStore"
|
||||
import { parseMediaFrame } from "@/lib/media"
|
||||
import { apiToken } from "@/lib/portal"
|
||||
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
||||
import { flowKeys } from "./queries"
|
||||
@@ -160,9 +161,25 @@ function schedule() {
|
||||
retry = Math.min(retry * 2, RECONNECT_MAX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Say which messages this page wants frames for.
|
||||
*
|
||||
* Bytes are the one thing the socket does not send unasked: a camera is
|
||||
* hundreds of kilobytes a second and most tabs are drawing no media at all. So
|
||||
* a Media tile or a thumbnail registers its message, and this tells the engine
|
||||
* — again on every reconnect, since the new socket knows nothing.
|
||||
*/
|
||||
function sendWanted() {
|
||||
if (socket?.readyState !== WebSocket.OPEN) return
|
||||
socket.send(JSON.stringify({ type: "media", names: liveStore.wantedNames() }))
|
||||
}
|
||||
|
||||
liveStore.onWanted(sendWanted)
|
||||
|
||||
function connect() {
|
||||
if (watchers === 0 || socket || timer) return
|
||||
const ws = new WebSocket(socketUrl())
|
||||
ws.binaryType = "arraybuffer"
|
||||
socket = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -185,6 +202,7 @@ function connect() {
|
||||
]) {
|
||||
client?.invalidateQueries({ queryKey })
|
||||
}
|
||||
sendWanted()
|
||||
}
|
||||
|
||||
const handle = (message: FlowEvent) => {
|
||||
@@ -318,6 +336,13 @@ function connect() {
|
||||
// frame rather than the connection: an exception thrown here escapes into
|
||||
// `window.onerror` and leaves whatever it had already applied behind.
|
||||
try {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// Media, sent in front of the value that names it — so the tile has
|
||||
// the bytes by the time it hears the message changed.
|
||||
const frame = parseMediaFrame(event.data)
|
||||
if (frame) liveStore.setBytes(frame.header.digest, frame.bytes)
|
||||
return
|
||||
}
|
||||
const payload = JSON.parse(event.data)
|
||||
if (payload?.type === "batch") {
|
||||
// A cascade publishes a dozen events at once and the engine coalesces
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The framing a pushed frame arrives in, and what a malformed one must do.
|
||||
*
|
||||
* Run: `bun src/lib/media.check.ts` (there is no unit runner; the suite in
|
||||
* `tests/` drives a running stack).
|
||||
*/
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { describeArtifact, isRef, parseMediaFrame } from "./media"
|
||||
|
||||
function frame(header: object, payload: Uint8Array): ArrayBuffer {
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(header))
|
||||
const out = new Uint8Array(4 + encoded.length + payload.length)
|
||||
new DataView(out.buffer).setUint32(0, encoded.length)
|
||||
out.set(encoded, 4)
|
||||
out.set(payload, 4 + encoded.length)
|
||||
return out.buffer
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
|
||||
const parsed = parseMediaFrame(
|
||||
frame(
|
||||
{
|
||||
type: "media",
|
||||
name: "cam.frame",
|
||||
digest: `sha256:${"a".repeat(64)}`,
|
||||
media_type: "image/png",
|
||||
ts: 1,
|
||||
},
|
||||
bytes,
|
||||
),
|
||||
)
|
||||
assert.ok(parsed)
|
||||
assert.equal(parsed.header.name, "cam.frame")
|
||||
assert.equal(parsed.header.media_type, "image/png")
|
||||
assert.equal(parsed.bytes.size, 4)
|
||||
assert.equal(parsed.bytes.type, "image/png")
|
||||
|
||||
// A frame the client cannot read is dropped, never thrown on: this runs on
|
||||
// every socket message.
|
||||
assert.equal(parseMediaFrame(new ArrayBuffer(0)), null)
|
||||
assert.equal(parseMediaFrame(new ArrayBuffer(2)), null)
|
||||
assert.equal(parseMediaFrame(frame({ name: "n" }, bytes)), null)
|
||||
// A length longer than the frame is truncation, not a payload.
|
||||
const short = new Uint8Array(8)
|
||||
new DataView(short.buffer).setUint32(0, 999)
|
||||
assert.equal(parseMediaFrame(short.buffer), null)
|
||||
|
||||
assert.ok(isRef({ digest: `sha256:${"b".repeat(64)}` }))
|
||||
assert.equal(isRef({ digest: "nope" }), false)
|
||||
assert.equal(isRef(null), false)
|
||||
|
||||
assert.equal(
|
||||
describeArtifact({ media_type: "image/png", name: "f.png", size: 48000 }),
|
||||
"image/png · f.png · 48kB",
|
||||
)
|
||||
// No media type is still worth summarising as something.
|
||||
assert.equal(
|
||||
describeArtifact({ media_type: "application/octet-stream", size: 12 }),
|
||||
"artifact · 12B",
|
||||
)
|
||||
|
||||
console.log("media.check.ts ok")
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Media references, and the bytes behind them.
|
||||
*
|
||||
* A frame never travels as a message — the reference does. What the bytes cost
|
||||
* to get is what limits the rate: fetched, it is a round trip per frame and a
|
||||
* camera is a glance; pushed down the socket that already carries the value,
|
||||
* it is a view. Both paths are here, and a caller does not choose between
|
||||
* them: the hook takes whichever arrived.
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { liveStore, useLiveBytes } from "@/components/Flow/liveStore"
|
||||
import { apiToken } from "@/lib/portal"
|
||||
import { si } from "@/lib/utils"
|
||||
|
||||
/** An artifact reference, as a message carries one. */
|
||||
export type MediaRef = {
|
||||
digest?: string
|
||||
media_type?: string
|
||||
name?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export const isRef = (value: unknown): value is MediaRef =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as MediaRef).digest === "string" &&
|
||||
(value as MediaRef).digest!.startsWith("sha256:")
|
||||
|
||||
/**
|
||||
* What a reference *is*, in the space a value would have taken: what kind of
|
||||
* bytes, what they were called, how many of them.
|
||||
*/
|
||||
export function describeArtifact(record: Record<string, unknown>): string {
|
||||
const name = typeof record.name === "string" && record.name ? record.name : ""
|
||||
const size = typeof record.size === "number" ? `${si(record.size)}B` : ""
|
||||
// The media type when there is one: what kind of bytes these are is the
|
||||
// first thing worth knowing about a frame or a clip, and it is what the
|
||||
// port's own type was declared against.
|
||||
const media =
|
||||
typeof record.media_type === "string" &&
|
||||
record.media_type &&
|
||||
record.media_type !== "application/octet-stream"
|
||||
? record.media_type
|
||||
: "artifact"
|
||||
return [media, name, size].filter(Boolean).join(" · ")
|
||||
}
|
||||
|
||||
/** The header a pushed frame carries in front of its bytes. */
|
||||
export type MediaFrame = {
|
||||
name: string
|
||||
digest: string
|
||||
media_type: string
|
||||
ts?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a binary socket frame into what it says and what it carries.
|
||||
*
|
||||
* Four bytes of header length, the header as JSON, then the bytes. Returns
|
||||
* null for anything that does not parse, since a frame the client cannot read
|
||||
* is one to drop rather than one to crash on.
|
||||
*/
|
||||
export function parseMediaFrame(
|
||||
buffer: ArrayBuffer,
|
||||
): { header: MediaFrame; bytes: Blob } | null {
|
||||
if (buffer.byteLength < 4) return null
|
||||
const length = new DataView(buffer).getUint32(0)
|
||||
if (length <= 0 || 4 + length > buffer.byteLength) return null
|
||||
try {
|
||||
const header = JSON.parse(
|
||||
new TextDecoder().decode(new Uint8Array(buffer, 4, length)),
|
||||
) as MediaFrame
|
||||
if (
|
||||
typeof header?.digest !== "string" ||
|
||||
typeof header?.name !== "string"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
header,
|
||||
bytes: new Blob([new Uint8Array(buffer, 4 + length)], {
|
||||
type: header.media_type || "application/octet-stream",
|
||||
}),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A local URL for an artifact's bytes, refreshed whenever the digest changes.
|
||||
*
|
||||
* Pushed bytes are used where the socket sent them, which is the fast path and
|
||||
* costs no request at all. Otherwise they are fetched: `/artifacts/{digest}`
|
||||
* takes a bearer token and no `<img>` or `<audio>` can carry a header, so the
|
||||
* bytes come through fetch and are handed to the element as an object URL.
|
||||
*
|
||||
* Passing `name` registers interest in that message, which is what tells the
|
||||
* engine to push its frames at all — nothing is sent to a screen that is not
|
||||
* drawing it.
|
||||
*/
|
||||
export function useArtifactUrl(ref: MediaRef | null, name?: string): string {
|
||||
const digest = ref?.digest ?? ""
|
||||
const mediaType = ref?.media_type ?? ""
|
||||
const pushed = useLiveBytes(digest)
|
||||
const [fetched, setFetched] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return
|
||||
return liveStore.wantBytes(name)
|
||||
}, [name])
|
||||
|
||||
useEffect(() => {
|
||||
// Already here: the socket carried the bytes in front of the value.
|
||||
if (!digest || pushed) {
|
||||
setFetched("")
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
let made = ""
|
||||
const token = apiToken()
|
||||
const query = mediaType
|
||||
? `?media_type=${encodeURIComponent(mediaType)}`
|
||||
: ""
|
||||
const request = new AbortController()
|
||||
fetch(`${OpenAPI.BASE}/api/v1/artifacts/${digest}${query}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: request.signal,
|
||||
})
|
||||
.then((answer) => (answer.ok ? answer.blob() : Promise.reject(answer)))
|
||||
.then((blob) => {
|
||||
if (!live) return
|
||||
made = URL.createObjectURL(blob)
|
||||
setFetched(made)
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setFetched("")
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
// Abandoned rather than merely ignored: a tile showing a camera starts a
|
||||
// request per frame, and the ones it no longer wants should not still be
|
||||
// arriving.
|
||||
request.abort()
|
||||
if (made) URL.revokeObjectURL(made)
|
||||
}
|
||||
}, [digest, mediaType, pushed])
|
||||
|
||||
return pushed || fetched
|
||||
}
|
||||
@@ -62,9 +62,18 @@ MAX_LOG = 16 * 1024
|
||||
#: Where the artifact store is, from this worker's point of view: a directory
|
||||
#: when it shares the engine's filesystem, a URL when it does not.
|
||||
ARTIFACT_DIR_ENV = "FLUKSIO_ARTIFACT_DIR"
|
||||
#: The ring, for frames a flow only shows live. Same layout as the store above;
|
||||
#: what differs is that the oldest fall out of it.
|
||||
ARTIFACT_VOLATILE_DIR_ENV = "FLUKSIO_ARTIFACT_VOLATILE_DIR"
|
||||
ARTIFACT_URL_ENV = "FLUKSIO_ARTIFACT_URL"
|
||||
ARTIFACT_TOKEN_ENV = "FLUKSIO_ARTIFACT_TOKEN"
|
||||
ARTIFACT_CACHE_ENV = "FLUKSIO_ARTIFACT_CACHE"
|
||||
#: How much a worker that fetches over HTTP keeps of what it downloaded. The
|
||||
#: cache is keyed by digest and nothing ever invalidates an entry, so without
|
||||
#: a bound a node reading a media stream fills the disk with chunks nothing
|
||||
#: will ask for twice.
|
||||
ARTIFACT_CACHE_BYTES_ENV = "FLUKSIO_ARTIFACT_CACHE_BYTES"
|
||||
DEFAULT_CACHE_BYTES = 1024 * 1024 * 1024
|
||||
ARTIFACT_TIMEOUT_S = 300
|
||||
#: How much of an artifact is held in memory at a time, matching the engine's
|
||||
#: own store. Repeated rather than imported: nothing of the engine is here.
|
||||
@@ -109,16 +118,27 @@ class _Reporter(ModuleType):
|
||||
source: Any,
|
||||
name: str = "",
|
||||
media_type: str = "application/octet-stream",
|
||||
volatile: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Store bytes or a file and return the reference to return onward.
|
||||
|
||||
Return the reference from an ``artifact`` port: a checkpoint is far too
|
||||
big to be a message, and the reference is what the next node opens.
|
||||
|
||||
``volatile`` is for a frame rather than a result: the bytes go to a
|
||||
ring held in memory and are pushed to whatever screen is watching,
|
||||
instead of being written to the data volume. They last seconds. A
|
||||
volatile reference a node *returns* from a run is kept anyway, so what
|
||||
this costs is only what nobody asked to keep.
|
||||
"""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
return _store_bytes(bytes(source), name or "artifact.bin", media_type)
|
||||
return _store_bytes(
|
||||
bytes(source), name or "artifact.bin", media_type, volatile
|
||||
)
|
||||
path = str(source)
|
||||
return _store_file(path, name or os.path.basename(path), media_type)
|
||||
return _store_file(
|
||||
path, name or os.path.basename(path), media_type, volatile
|
||||
)
|
||||
|
||||
def load_artifact(self, ref: dict[str, Any]) -> str:
|
||||
"""Fetch an artifact and hand back a local path to read it from."""
|
||||
@@ -186,7 +206,22 @@ class _Declared:
|
||||
return "<fluksio declaration>"
|
||||
|
||||
|
||||
def _store_bytes(data: bytes, name: str, media_type: str) -> dict[str, Any]:
|
||||
def _artifact_dir(volatile: bool) -> str | None:
|
||||
"""Which store this worker writes to, if it can see one at all.
|
||||
|
||||
A volatile write falls back to the durable directory rather than failing:
|
||||
an engine with its ring turned off still has to take the frame.
|
||||
"""
|
||||
if volatile:
|
||||
ring = os.environ.get(ARTIFACT_VOLATILE_DIR_ENV)
|
||||
if ring:
|
||||
return ring
|
||||
return os.environ.get(ARTIFACT_DIR_ENV)
|
||||
|
||||
|
||||
def _store_bytes(
|
||||
data: bytes, name: str, media_type: str, volatile: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Write to the artifact store, whichever end of it this worker can see.
|
||||
|
||||
A worker in the engine's own container writes the file; one on another host
|
||||
@@ -196,7 +231,7 @@ def _store_bytes(data: bytes, name: str, media_type: str) -> dict[str, Any]:
|
||||
is importable here.
|
||||
"""
|
||||
digest = "sha256:" + hashlib.sha256(data).hexdigest()
|
||||
directory = os.environ.get(ARTIFACT_DIR_ENV)
|
||||
directory = _artifact_dir(volatile)
|
||||
if directory:
|
||||
target = os.path.join(directory, digest[7:9], digest[7:])
|
||||
if not os.path.exists(target):
|
||||
@@ -208,7 +243,7 @@ def _store_bytes(data: bytes, name: str, media_type: str) -> dict[str, Any]:
|
||||
out.write(data)
|
||||
os.replace(temporary, target)
|
||||
else:
|
||||
_put_over_http(data, name, media_type)
|
||||
_put_over_http(data, name, media_type, volatile=volatile)
|
||||
return {
|
||||
"digest": digest,
|
||||
"size": len(data),
|
||||
@@ -217,7 +252,9 @@ def _store_bytes(data: bytes, name: str, media_type: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _store_file(path: str, name: str, media_type: str) -> dict[str, Any]:
|
||||
def _store_file(
|
||||
path: str, name: str, media_type: str, volatile: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""The same, for a file already on disk — read a chunk at a time.
|
||||
|
||||
A video segment or a checkpoint is as likely to be handed over as a path as
|
||||
@@ -232,7 +269,7 @@ def _store_file(path: str, name: str, media_type: str) -> dict[str, Any]:
|
||||
size += len(chunk)
|
||||
digest = "sha256:" + digester.hexdigest()
|
||||
|
||||
directory = os.environ.get(ARTIFACT_DIR_ENV)
|
||||
directory = _artifact_dir(volatile)
|
||||
if directory:
|
||||
target = os.path.join(directory, digest[7:9], digest[7:])
|
||||
if not os.path.exists(target):
|
||||
@@ -244,12 +281,16 @@ def _store_file(path: str, name: str, media_type: str) -> dict[str, Any]:
|
||||
os.replace(temporary, target)
|
||||
else:
|
||||
with open(path, "rb") as handle:
|
||||
_put_over_http(handle, name, media_type, size)
|
||||
_put_over_http(handle, name, media_type, size, volatile=volatile)
|
||||
return {"digest": digest, "size": size, "media_type": media_type, "name": name}
|
||||
|
||||
|
||||
def _put_over_http(
|
||||
data: Any, name: str, media_type: str, length: int | None = None
|
||||
data: Any,
|
||||
name: str,
|
||||
media_type: str,
|
||||
length: int | None = None,
|
||||
volatile: bool = False,
|
||||
) -> None:
|
||||
base = os.environ.get(ARTIFACT_URL_ENV)
|
||||
if not base:
|
||||
@@ -257,7 +298,10 @@ def _put_over_http(
|
||||
"this worker has no artifact store — neither "
|
||||
f"{ARTIFACT_DIR_ENV} nor {ARTIFACT_URL_ENV} is set"
|
||||
)
|
||||
query = urllib.parse.urlencode({"name": name, "media_type": media_type})
|
||||
fields = {"name": name, "media_type": media_type}
|
||||
if volatile:
|
||||
fields["volatile"] = "1"
|
||||
query = urllib.parse.urlencode(fields)
|
||||
request = urllib.request.Request(
|
||||
f"{base.rstrip('/')}?{query}", data=data, method="PUT"
|
||||
)
|
||||
@@ -271,12 +315,17 @@ def _put_over_http(
|
||||
|
||||
def _fetch(digest: str) -> str:
|
||||
"""A local path holding this artifact's bytes, downloading it if needed."""
|
||||
directory = os.environ.get(ARTIFACT_DIR_ENV)
|
||||
if directory:
|
||||
for variable in (ARTIFACT_DIR_ENV, ARTIFACT_VOLATILE_DIR_ENV):
|
||||
directory = os.environ.get(variable)
|
||||
if not directory:
|
||||
continue
|
||||
target = os.path.join(directory, digest[7:9], digest[7:])
|
||||
if not os.path.exists(target):
|
||||
raise FileNotFoundError(digest)
|
||||
return target
|
||||
if os.path.exists(target):
|
||||
return target
|
||||
if os.environ.get(ARTIFACT_DIR_ENV):
|
||||
# A worker sharing the engine's filesystem has nowhere else to look:
|
||||
# either the bytes are in one of the two stores or they have gone.
|
||||
raise FileNotFoundError(digest)
|
||||
|
||||
# Cached by digest: content-addressing means a file once fetched is never
|
||||
# stale, so a sweep pulls a shared input across the network once.
|
||||
@@ -298,9 +347,47 @@ def _fetch(digest: str) -> str:
|
||||
while chunk := response.read(CHUNK):
|
||||
out.write(chunk)
|
||||
os.replace(temporary, target)
|
||||
_trim_cache(cache)
|
||||
return target
|
||||
|
||||
|
||||
def _trim_cache(cache: str) -> None:
|
||||
"""Hold the fetch cache inside its bound, oldest first.
|
||||
|
||||
Content addressing means an entry is never stale, so nothing here ever
|
||||
expires on its own — and a node reading a media stream asks for a digest it
|
||||
will never ask for again. Cheap: one listing per download.
|
||||
"""
|
||||
try:
|
||||
limit = int(os.environ.get(ARTIFACT_CACHE_BYTES_ENV) or DEFAULT_CACHE_BYTES)
|
||||
except ValueError:
|
||||
limit = DEFAULT_CACHE_BYTES
|
||||
if limit <= 0:
|
||||
return
|
||||
entries = []
|
||||
total = 0
|
||||
with os.scandir(cache) as listing:
|
||||
for entry in listing:
|
||||
try:
|
||||
stat = entry.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if not entry.is_file():
|
||||
continue
|
||||
entries.append((stat.st_mtime, stat.st_size, entry.path))
|
||||
total += stat.st_size
|
||||
if total <= limit:
|
||||
return
|
||||
for _mtime, size, path in sorted(entries):
|
||||
if total <= limit:
|
||||
break
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
continue
|
||||
total -= size
|
||||
|
||||
|
||||
def _authorize(request: Any) -> None:
|
||||
token = os.environ.get(ARTIFACT_TOKEN_ENV)
|
||||
if token:
|
||||
|
||||
Reference in New Issue
Block a user