Artifacts: bytes a node produced, addressed by their content
A checkpoint is not a message. DType.ARTIFACT carries a reference — digest, size, media type, name — so everything on the wire stays JSON and thirty megabytes never sit in Redis, which answers the vision's open binary-payload question by narrowing it: inline codecs would only serve payloads too small to be worth a round trip, and nothing asks for that. The store is content-addressed rather than per-run, for three reasons that all pay later: a sweep whose fifty configs share one preprocessed input stores it once, a reference stays valid however it is passed around because it names content instead of a location, and the digest is what a stage cache will compare — so building it in now is what keeps that from being a change to the message contract. Node code calls fluksio.save_artifact/load_artifact and cannot tell whether it is writing the engine's own directory or putting bytes over HTTP, which is what will let the same flow run on a remote worker unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
This commit is contained in:
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import (
|
||||
alerts,
|
||||
artifacts,
|
||||
dashboards,
|
||||
flows,
|
||||
login,
|
||||
@@ -29,6 +30,7 @@ api_router.include_router(messages.router)
|
||||
api_router.include_router(modules.router)
|
||||
api_router.include_router(observability.router)
|
||||
api_router.include_router(runs.router)
|
||||
api_router.include_router(artifacts.router)
|
||||
# Always mounted so the generated SDK stays the same shape; the endpoints
|
||||
# themselves refuse to work unless MCP is switched on.
|
||||
api_router.include_router(oauth.router)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Artifacts over HTTP: the one way bytes get in and out of the store.
|
||||
|
||||
A node on this host could reach the directory itself, but a node on a remote
|
||||
worker cannot — and having one path rather than two is what keeps a flow's
|
||||
code the same wherever it runs.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.flow.artifacts import ArtifactStore
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/artifacts", tags=["artifacts"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
class ArtifactRef(BaseModel):
|
||||
digest: str
|
||||
size: int
|
||||
media_type: str
|
||||
name: str = ""
|
||||
|
||||
|
||||
def _store(request: Request) -> ArtifactStore:
|
||||
store: ArtifactStore | None = getattr(request.app.state, "artifact_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="The artifact store is not ready")
|
||||
return store
|
||||
|
||||
|
||||
@router.put("", response_model=ArtifactRef)
|
||||
async def put_artifact(
|
||||
request: Request,
|
||||
name: str = Query(default=""),
|
||||
media_type: str = Query(default=""),
|
||||
) -> Any:
|
||||
"""Store the request body and answer with the reference to it."""
|
||||
store = _store(request)
|
||||
body = await request.body()
|
||||
return store.put([body], name=name, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/{digest}")
|
||||
def get_artifact(digest: str, request: Request) -> Any:
|
||||
"""Stream one artifact back."""
|
||||
store = _store(request)
|
||||
path = store.path(digest)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="No such artifact")
|
||||
return StreamingResponse(
|
||||
store.read(digest),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Length": str(path.stat().st_size)},
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""The artifact store: bytes a node produced, addressed by their content.
|
||||
|
||||
A typed message carries JSON, which is what lets the same value pass through
|
||||
Redis, the work queue and the worker protocol unchanged. A model checkpoint is
|
||||
not that, so it does not travel as a message — it is written here and the
|
||||
message carries a reference to it.
|
||||
|
||||
Addressed by digest rather than by run, for three reasons. A sweep whose fifty
|
||||
configs share one preprocessed input stores it once. A reference stays valid
|
||||
however it is passed around, because it names content instead of a location.
|
||||
And the digest is what a stage cache will compare, so building it in now is
|
||||
what keeps that from being a change to the message contract later.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: How much is read at a time when hashing or serving.
|
||||
CHUNK = 1024 * 1024
|
||||
DIGEST_PREFIX = "sha256:"
|
||||
|
||||
|
||||
def is_reference(value: Any) -> bool:
|
||||
"""Whether a message payload is an artifact reference."""
|
||||
return isinstance(value, dict) and str(value.get("digest", "")).startswith(
|
||||
DIGEST_PREFIX
|
||||
)
|
||||
|
||||
|
||||
def valid_digest(digest: str) -> bool:
|
||||
"""Guard for anything that reaches the filesystem from outside."""
|
||||
if not digest.startswith(DIGEST_PREFIX):
|
||||
return False
|
||||
body = digest[len(DIGEST_PREFIX) :]
|
||||
return len(body) == 64 and all(c in "0123456789abcdef" for c in body)
|
||||
|
||||
|
||||
class ArtifactStore:
|
||||
"""Content-addressed files under one directory."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path(self, digest: str) -> Path:
|
||||
body = digest[len(DIGEST_PREFIX) :]
|
||||
# Two levels, so a directory listing stays usable at a hundred thousand
|
||||
# artifacts.
|
||||
return self.root / body[:2] / body
|
||||
|
||||
def put(
|
||||
self, chunks: Iterable[bytes], name: str = "", media_type: str = ""
|
||||
) -> 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.
|
||||
"""
|
||||
digester = hashlib.sha256()
|
||||
size = 0
|
||||
handle = tempfile.NamedTemporaryFile(dir=self.root, delete=False)
|
||||
try:
|
||||
with handle:
|
||||
for chunk in chunks:
|
||||
digester.update(chunk)
|
||||
size += len(chunk)
|
||||
handle.write(chunk)
|
||||
digest = DIGEST_PREFIX + digester.hexdigest()
|
||||
target = self._path(digest)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
os.unlink(handle.name)
|
||||
else:
|
||||
# Same content from two nodes at once is one rename winning and
|
||||
# the other replacing a byte-identical file.
|
||||
os.replace(handle.name, target)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(handle.name)
|
||||
raise
|
||||
return {
|
||||
"digest": digest,
|
||||
"size": size,
|
||||
"media_type": media_type or "application/octet-stream",
|
||||
"name": name,
|
||||
}
|
||||
|
||||
def put_file(self, path: Path, media_type: str = "") -> dict[str, Any]:
|
||||
with path.open("rb") as handle:
|
||||
return self.put(
|
||||
iter(lambda: handle.read(CHUNK), b""),
|
||||
name=path.name,
|
||||
media_type=media_type,
|
||||
)
|
||||
|
||||
def path(self, digest: str) -> Path | None:
|
||||
"""Where the bytes are, or None if this store does not have them."""
|
||||
if not valid_digest(digest):
|
||||
return None
|
||||
target = self._path(digest)
|
||||
return target if target.exists() else None
|
||||
|
||||
def read(self, digest: str) -> Iterator[bytes]:
|
||||
target = self.path(digest)
|
||||
if target is None:
|
||||
raise FileNotFoundError(digest)
|
||||
with target.open("rb") as handle:
|
||||
while chunk := handle.read(CHUNK):
|
||||
yield chunk
|
||||
|
||||
def collect(self, keep: set[str]) -> int:
|
||||
"""Delete what no run refers to any more. Returns how many went.
|
||||
|
||||
The caller passes every digest still recorded; anything else in the
|
||||
store was produced by a run that has since been pruned, or never got a
|
||||
row at all because the run failed between writing and recording.
|
||||
"""
|
||||
removed = 0
|
||||
for entry in self.root.glob("*/*"):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
if DIGEST_PREFIX + entry.name in keep:
|
||||
continue
|
||||
try:
|
||||
entry.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
logger.warning("Could not remove artifact %s", entry.name)
|
||||
return removed
|
||||
@@ -25,8 +25,13 @@ class DType(str, Enum):
|
||||
knows what it is getting before anything runs, which is what lets the
|
||||
dashboard picker offer a message and refuse a wrong binding.
|
||||
|
||||
Binary payloads (tensors, images) will arrive later as explicitly declared
|
||||
codec fields; until then everything on the wire is JSON.
|
||||
Binary payloads — tensors, checkpoints, images — travel as ``artifact``:
|
||||
the bytes go to the artifact store and the message carries a reference to
|
||||
them. That keeps everything on the wire JSON, which is what the state
|
||||
backend, the queue and the worker protocol all rely on, and it means a
|
||||
thirty-megabyte checkpoint never sits in Redis. Inline codecs would only be
|
||||
needed for payloads too small to be worth a round trip, and nothing asks
|
||||
for that yet.
|
||||
"""
|
||||
|
||||
FLOAT = "float"
|
||||
@@ -42,6 +47,9 @@ class DType(str, Enum):
|
||||
RECORD = "record"
|
||||
#: Ordered items of one declared shape; see :attr:`MessageSpec.item`.
|
||||
LIST = "list"
|
||||
#: A reference to stored bytes:
|
||||
#: ``{"digest": "sha256:…", "size": int, "media_type": str, "name": str}``.
|
||||
ARTIFACT = "artifact"
|
||||
|
||||
|
||||
_JSON_TYPES = (dict, list, str, int, float, bool, type(None))
|
||||
@@ -92,6 +100,21 @@ def _is_series(value: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_artifact(value: Any) -> bool:
|
||||
"""A reference to stored bytes, not the bytes themselves.
|
||||
|
||||
The digest is what makes it one: it names content rather than a location,
|
||||
so the same file produced twice is stored once and a reference stays valid
|
||||
wherever the store is reachable from.
|
||||
"""
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and isinstance(value.get("digest"), str)
|
||||
and value["digest"].startswith("sha256:")
|
||||
and isinstance(value.get("size"), int)
|
||||
)
|
||||
|
||||
|
||||
def _matches(dtype: DType, value: Any) -> bool:
|
||||
"""Whether one value satisfies a scalar or record type."""
|
||||
if dtype is DType.BOOL:
|
||||
@@ -104,6 +127,8 @@ def _matches(dtype: DType, value: Any) -> bool:
|
||||
return isinstance(value, str)
|
||||
if dtype is DType.RECORD:
|
||||
return _is_record(value)
|
||||
if dtype is DType.ARTIFACT:
|
||||
return _is_artifact(value)
|
||||
return isinstance(value, _JSON_TYPES)
|
||||
|
||||
|
||||
@@ -190,7 +215,7 @@ class MessageSpec(BaseModel):
|
||||
return str(value).lower() in ("true", "1", "yes", "on")
|
||||
if self.dtype is DType.STR:
|
||||
return value if isinstance(value, str) else json.dumps(value)
|
||||
if self.dtype in (DType.SERIES, DType.RECORD, DType.LIST):
|
||||
if self.dtype in (DType.SERIES, DType.RECORD, DType.LIST, DType.ARTIFACT):
|
||||
# A structured payload arriving as text is the same hint a numeric
|
||||
# one is; the shape itself is still checked afterwards.
|
||||
return json.loads(value) if isinstance(value, str) else value
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.flow import logs
|
||||
from app.flow.artifacts import is_reference
|
||||
from app.flow.events import EventBus
|
||||
from app.flow.messages import flow_of
|
||||
from app.flow.nodes import Node
|
||||
@@ -90,6 +91,9 @@ class NodeOutcome(BaseModel):
|
||||
outputs: int = 0
|
||||
error: str = ""
|
||||
logs: str = ""
|
||||
#: Artifact references this node emitted, keyed by the message carrying
|
||||
#: them — what a run records so a result can be opened later.
|
||||
artifacts: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
class Pipeline:
|
||||
@@ -706,6 +710,11 @@ class Pipeline:
|
||||
duration_ms=duration_ms,
|
||||
outputs=len(result or {}),
|
||||
logs=collected.text,
|
||||
artifacts={
|
||||
name: value
|
||||
for name, value in (result or {}).items()
|
||||
if is_reference(value)
|
||||
},
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -49,7 +49,7 @@ from app.flow.pipeline import NodeOutcome, Pipeline
|
||||
from app.flow.queue import WorkItem, WorkQueue
|
||||
from app.flow.schemas import FlowDef
|
||||
from app.flow.state import MemoryState, StateBackend
|
||||
from app.models import Run, RunMetric, RunNode
|
||||
from app.models import Run, RunArtifact, RunMetric, RunNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -587,6 +587,19 @@ class RunService:
|
||||
try:
|
||||
with Session(db_engine) as session:
|
||||
session.merge(row)
|
||||
for message, ref in outcome.artifacts.items():
|
||||
session.merge(
|
||||
RunArtifact(
|
||||
run_id=run_id,
|
||||
name=message[:255],
|
||||
node=outcome.node[:255],
|
||||
digest=str(ref.get("digest") or "")[:71],
|
||||
size=int(ref.get("size") or 0),
|
||||
media_type=str(
|
||||
ref.get("media_type") or "application/octet-stream"
|
||||
)[:128],
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
||||
@@ -36,8 +36,11 @@ import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from types import ModuleType
|
||||
from typing import Any, cast
|
||||
@@ -46,6 +49,14 @@ from typing import Any, cast
|
||||
#: pipe or the reply.
|
||||
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"
|
||||
ARTIFACT_URL_ENV = "FLUKSIO_ARTIFACT_URL"
|
||||
ARTIFACT_TOKEN_ENV = "FLUKSIO_ARTIFACT_TOKEN"
|
||||
ARTIFACT_CACHE_ENV = "FLUKSIO_ARTIFACT_CACHE"
|
||||
ARTIFACT_TIMEOUT_S = 300
|
||||
|
||||
#: The reply channel, opened by ``main``. Also what an event line goes down.
|
||||
_RPC: Any = None
|
||||
#: The call being served, so an event can say which one it belongs to.
|
||||
@@ -96,6 +107,119 @@ class _Reporter(ModuleType):
|
||||
}
|
||||
)
|
||||
|
||||
def save_artifact(
|
||||
self,
|
||||
source: Any,
|
||||
name: str = "",
|
||||
media_type: str = "application/octet-stream",
|
||||
) -> 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.
|
||||
"""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
data = bytes(source)
|
||||
name = name or "artifact.bin"
|
||||
else:
|
||||
path = str(source)
|
||||
with open(path, "rb") as handle:
|
||||
data = handle.read()
|
||||
name = name or os.path.basename(path)
|
||||
return _store_bytes(data, name, media_type)
|
||||
|
||||
def load_artifact(self, ref: dict[str, Any]) -> str:
|
||||
"""Fetch an artifact and hand back a local path to read it from."""
|
||||
digest = str((ref or {}).get("digest") or "")
|
||||
if not digest.startswith("sha256:"):
|
||||
raise ValueError("not an artifact reference")
|
||||
return _fetch(digest)
|
||||
|
||||
|
||||
def _store_bytes(data: bytes, name: str, media_type: str) -> 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
|
||||
puts it over HTTP. Node code cannot tell the difference, which is the point
|
||||
— the same flow runs either place. The hashing is repeated from
|
||||
``app.flow.artifacts`` rather than imported, because nothing of the app is
|
||||
importable here.
|
||||
"""
|
||||
digest = "sha256:" + hashlib.sha256(data).hexdigest()
|
||||
directory = os.environ.get(ARTIFACT_DIR_ENV)
|
||||
if directory:
|
||||
target = os.path.join(directory, digest[7:9], digest[7:])
|
||||
if not os.path.exists(target):
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
# Written beside the target and moved, so a reader never opens a
|
||||
# half-written artifact.
|
||||
handle, temporary = tempfile.mkstemp(dir=os.path.dirname(target))
|
||||
with os.fdopen(handle, "wb") as out:
|
||||
out.write(data)
|
||||
os.replace(temporary, target)
|
||||
else:
|
||||
_put_over_http(data, name, media_type)
|
||||
return {
|
||||
"digest": digest,
|
||||
"size": len(data),
|
||||
"media_type": media_type,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
|
||||
def _put_over_http(data: bytes, name: str, media_type: str) -> None:
|
||||
base = os.environ.get(ARTIFACT_URL_ENV)
|
||||
if not base:
|
||||
raise RuntimeError(
|
||||
"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})
|
||||
request = urllib.request.Request(
|
||||
f"{base.rstrip('/')}?{query}", data=data, method="PUT"
|
||||
)
|
||||
_authorize(request)
|
||||
with urllib.request.urlopen(request, timeout=ARTIFACT_TIMEOUT_S):
|
||||
pass
|
||||
|
||||
|
||||
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:
|
||||
target = os.path.join(directory, digest[7:9], digest[7:])
|
||||
if not os.path.exists(target):
|
||||
raise FileNotFoundError(digest)
|
||||
return target
|
||||
|
||||
# Cached by digest: content-addressing means a file once fetched is never
|
||||
# stale, so a sweep pulls a shared input across the network once.
|
||||
cache = os.environ.get(ARTIFACT_CACHE_ENV) or os.path.join(
|
||||
tempfile.gettempdir(), "fluksio-artifacts"
|
||||
)
|
||||
target = os.path.join(cache, digest[7:])
|
||||
if os.path.exists(target):
|
||||
return target
|
||||
base = os.environ.get(ARTIFACT_URL_ENV)
|
||||
if not base:
|
||||
raise RuntimeError(f"this worker cannot reach the artifact store: {digest}")
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
request = urllib.request.Request(f"{base.rstrip('/')}/{digest}")
|
||||
_authorize(request)
|
||||
handle, temporary = tempfile.mkstemp(dir=cache)
|
||||
with urllib.request.urlopen(request, timeout=ARTIFACT_TIMEOUT_S) as response:
|
||||
with os.fdopen(handle, "wb") as out:
|
||||
while chunk := response.read(1024 * 1024):
|
||||
out.write(chunk)
|
||||
os.replace(temporary, target)
|
||||
return target
|
||||
|
||||
|
||||
def _authorize(request: Any) -> None:
|
||||
token = os.environ.get(ARTIFACT_TOKEN_ENV)
|
||||
if token:
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
|
||||
|
||||
def _install_reporter() -> None:
|
||||
"""Put ``fluksio`` on the import path of every node this worker runs."""
|
||||
|
||||
@@ -42,7 +42,7 @@ ENV_DENY_PREFIXES = ("POSTGRES_", "FIRST_SUPERUSER", "SENTRY_DSN")
|
||||
ENV_DENY_WORDS = ("PASSWORD", "SECRET", "TOKEN")
|
||||
|
||||
|
||||
def worker_env() -> dict[str, str]:
|
||||
def worker_env(extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""The engine's environment with its credentials taken out.
|
||||
|
||||
A denylist, so ``PATH``, ``HOME``, the locale and whatever else a venv
|
||||
@@ -50,13 +50,19 @@ def worker_env() -> dict[str, str]:
|
||||
user in the same container, with the same filesystem and the same network.
|
||||
It only means node code cannot read the deployment's secrets straight out
|
||||
of its own environment.
|
||||
|
||||
``extra`` is put back afterwards, deliberately: the artifact store's
|
||||
address is something the worker has to be told, and one of its names would
|
||||
otherwise be caught by the denylist for containing "TOKEN".
|
||||
"""
|
||||
return {
|
||||
env = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if not key.startswith(ENV_DENY_PREFIXES)
|
||||
and not any(word in key.upper() for word in ENV_DENY_WORDS)
|
||||
}
|
||||
env.update(extra or {})
|
||||
return env
|
||||
|
||||
|
||||
class RemoteError(Exception):
|
||||
@@ -99,7 +105,9 @@ def _remote_class(name: str) -> type[RemoteError]:
|
||||
class _Worker:
|
||||
"""One subprocess, and the framing of one request/response over its pipes."""
|
||||
|
||||
def __init__(self, python: str, generation: int) -> None:
|
||||
def __init__(
|
||||
self, python: str, generation: int, env: dict[str, str] | None = None
|
||||
) -> None:
|
||||
self.generation = generation
|
||||
self.cancelled = False
|
||||
# What a read took past the end of a line. A node reporting quickly
|
||||
@@ -112,7 +120,7 @@ class _Worker:
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
close_fds=True,
|
||||
env=worker_env(),
|
||||
env=worker_env(env),
|
||||
)
|
||||
|
||||
def alive(self) -> bool:
|
||||
@@ -176,11 +184,18 @@ class PythonWorkerPool:
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, python: str, size: int = 4, events: EventBus | None = None
|
||||
self,
|
||||
python: str,
|
||||
size: int = 4,
|
||||
events: EventBus | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.python = python
|
||||
self.size = size
|
||||
self.events = events
|
||||
# Put into every worker's environment past the denylist — where the
|
||||
# artifact store is, which node code needs and cannot guess.
|
||||
self.env = env or {}
|
||||
self._idle: queue.Queue[_Worker | None] = queue.Queue()
|
||||
# Keyed by (run, node): a sweep has the same node executing in several
|
||||
# runs at once, and cancelling one of them must not kill the others.
|
||||
@@ -256,7 +271,7 @@ class PythonWorkerPool:
|
||||
if slot is not None:
|
||||
slot.kill()
|
||||
try:
|
||||
return _Worker(self.python, self._generation)
|
||||
return _Worker(self.python, self._generation, self.env)
|
||||
except Exception as exc:
|
||||
self._idle.put(None)
|
||||
raise RemoteError(f"worker unavailable: {exc}") from exc
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.flow import logs, modules
|
||||
from app.flow.alerts import AlertManager
|
||||
from app.flow.artifacts import ArtifactStore
|
||||
from app.flow.controller import FlowController
|
||||
from app.flow.dashboards import DashboardStore
|
||||
from app.flow.events import event_bus
|
||||
@@ -30,6 +31,7 @@ from app.flow.secrets import init_secrets
|
||||
from app.flow.state import MemoryState, RedisState, StateBackend
|
||||
from app.flow.store import FlowStore
|
||||
from app.flow.watchdog import LoopWatchdog
|
||||
from app.flow.worker_main import ARTIFACT_DIR_ENV
|
||||
from app.flow.workers import PythonWorkerPool
|
||||
|
||||
|
||||
@@ -100,10 +102,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
store = FlowStore(settings.FLOWS_DIR)
|
||||
# The packages node code imports, before anything tries to import them.
|
||||
await run_in_threadpool(modules.reconcile, store)
|
||||
# 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")
|
||||
app.state.artifact_store = artifacts
|
||||
pool = PythonWorkerPool(
|
||||
python=modules.venv_python(),
|
||||
size=settings.FLOW_MAX_WORKERS,
|
||||
events=event_bus,
|
||||
# A worker in this container writes to the store directly; a remote one
|
||||
# is given a URL instead. Node code calls the same two functions.
|
||||
env={ARTIFACT_DIR_ENV: str(artifacts.root)},
|
||||
)
|
||||
pool.start()
|
||||
app.state.worker_pool = pool
|
||||
|
||||
@@ -7,6 +7,9 @@ from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from app.flow.artifacts import ArtifactStore
|
||||
from app.flow.messages import DType, MessageSpec
|
||||
from app.flow.worker_main import ARTIFACT_DIR_ENV
|
||||
from app.flow.workers import NodeCancelled, NodeTimeout, PythonWorkerPool
|
||||
|
||||
|
||||
@@ -280,3 +283,45 @@ def test_cancelling_one_run_leaves_the_same_node_in_another_alone(pool):
|
||||
assert pool.cancel("demo.hold", run_id="run-a") is True
|
||||
started.wait(timeout=5)
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_a_node_saves_and_loads_an_artifact(tmp_path):
|
||||
# Bytes never travel as a message: the node stores them and returns a
|
||||
# reference, which the next node opens.
|
||||
store = ArtifactStore(tmp_path / "artifacts")
|
||||
pool = PythonWorkerPool(
|
||||
python=sys.executable, size=1, env={ARTIFACT_DIR_ENV: str(store.root)}
|
||||
)
|
||||
pool.start()
|
||||
try:
|
||||
ref = pool.run(
|
||||
"demo",
|
||||
"save",
|
||||
"import fluksio\n"
|
||||
"def process(params):\n"
|
||||
" return {'weights': fluksio.save_artifact(b'x' * 2048, 'w.npz')}\n",
|
||||
{},
|
||||
{},
|
||||
"demo.save",
|
||||
timeout=10,
|
||||
)["weights"]
|
||||
|
||||
assert ref["size"] == 2048
|
||||
assert MessageSpec(name="weights", dtype=DType.ARTIFACT).check(ref) is None
|
||||
assert store.path(ref["digest"]) is not None
|
||||
|
||||
loaded = pool.run(
|
||||
"demo",
|
||||
"load",
|
||||
"import fluksio\n"
|
||||
"def process(weights, params):\n"
|
||||
" with open(fluksio.load_artifact(weights), 'rb') as f:\n"
|
||||
" return {'size': len(f.read())}\n",
|
||||
{"weights": ref},
|
||||
{},
|
||||
"demo.load",
|
||||
timeout=10,
|
||||
)
|
||||
assert loaded == {"size": 2048}
|
||||
finally:
|
||||
pool.stop()
|
||||
|
||||
Reference in New Issue
Block a user