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:
+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()
|
||||
|
||||
Reference in New Issue
Block a user