Split the worker into a distribution of its own

A cluster or GPU host installs `pip install fluksio-worker` and gets the
agent and the runner, not psycopg, numpy and the MCP SDK. The engine
depends on it as a workspace member, so the file it launches node code
with is the same file a remote worker runs — which is what keeps a node
unable to tell the difference. Copying the two files by hand still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 21:54:10 +02:00
co-authored by Claude Opus 5
parent 640654bd66
commit f8abd91fc0
17 changed files with 122 additions and 42 deletions
+1
View File
@@ -170,6 +170,7 @@ lint-backend: ## Lint backend with ruff + mypy
cd backend && uv run ruff check . cd backend && uv run ruff check .
cd backend && uv run ruff format --check . cd backend && uv run ruff format --check .
cd backend && uv run mypy fluksio cd backend && uv run mypy fluksio
cd worker && uv run --no-project --with mypy mypy fluksio_worker
lint-frontend: ## Lint frontend with biome lint-frontend: ## Lint frontend with biome
cd frontend && bun run lint cd frontend && bun run lint
+5
View File
@@ -33,6 +33,11 @@ COPY ./backend/pyproject.toml ./backend/alembic.ini /app/backend/
COPY ./backend/fluksio /app/backend/fluksio COPY ./backend/fluksio /app/backend/fluksio
# The worker is a workspace member of its own, so the engine's sync needs it
# present to resolve the dependency on it. It is also what the engine runs
# node code with.
COPY ./worker /app/worker
# Sync the project # Sync the project
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
+2 -2
View File
@@ -15,12 +15,12 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from fluksio_worker import worker_main
from jwt.exceptions import InvalidTokenError from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from fluksio.api.deps import get_current_active_superuser, get_current_user from fluksio.api.deps import get_current_active_superuser, get_current_user
from fluksio.core import security from fluksio.core import security
from fluksio.flow import worker_main
from fluksio.flow.remote import PROTOCOL, RemoteWorker, RemoteWorkerHub from fluksio.flow.remote import PROTOCOL, RemoteWorker, RemoteWorkerHub
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -103,7 +103,7 @@ def read_runtime() -> str:
"""The worker's own code, so a fresh host installs by fetching one file. """The worker's own code, so a fresh host installs by fetching one file.
It is the same module the engine's local workers run — deliberately It is the same module the engine's local workers run — deliberately
standard library only, and with nothing of the app importable in it. standard library only, and with nothing of the engine importable in it.
""" """
return worker_main.__file__ and open(worker_main.__file__).read() return worker_main.__file__ and open(worker_main.__file__).read()
+1 -1
View File
@@ -20,6 +20,7 @@ from typing import Any, cast
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from fluksio_worker.worker_main import load_function
from fluksio.core.config import settings from fluksio.core.config import settings
from fluksio.flow.alerts import AlertManager from fluksio.flow.alerts import AlertManager
@@ -60,7 +61,6 @@ from fluksio.flow.secrets import SecretNotFound, resolve_params
from fluksio.flow.state import MemoryState, StateBackend from fluksio.flow.state import MemoryState, StateBackend
from fluksio.flow.store import LIB_DIR, FlowNotFound, FlowStore, LibNotFound from fluksio.flow.store import LIB_DIR, FlowNotFound, FlowStore, LibNotFound
from fluksio.flow.supervision import Supervisor from fluksio.flow.supervision import Supervisor
from fluksio.flow.worker_main import load_function
from fluksio.flow.workers import PythonWorkerPool from fluksio.flow.workers import PythonWorkerPool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+5 -1
View File
@@ -26,11 +26,15 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fluksio_worker import worker_main as _worker_main
from fluksio.flow.events import EventBus from fluksio.flow.events import EventBus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
WORKER_MAIN = Path(__file__).with_name("worker_main.py") #: The runner, in the worker distribution — the same file a remote worker runs,
#: so a node cannot tell which kind of worker it is on.
WORKER_MAIN = Path(_worker_main.__file__)
#: Importing what a node needs can be slow the first time; compiling is not #: Importing what a node needs can be slow the first time; compiling is not
#: something a person is watching a spinner for. #: something a person is watching a spinner for.
+1 -1
View File
@@ -8,6 +8,7 @@ from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from fluksio_worker.worker_main import ARTIFACT_DIR_ENV
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from fluksio.api.main import api_router from fluksio.api.main import api_router
@@ -33,7 +34,6 @@ from fluksio.flow.secrets import init_secrets
from fluksio.flow.state import MemoryState, RedisState, StateBackend from fluksio.flow.state import MemoryState, RedisState, StateBackend
from fluksio.flow.store import FlowStore from fluksio.flow.store import FlowStore
from fluksio.flow.watchdog import LoopWatchdog from fluksio.flow.watchdog import LoopWatchdog
from fluksio.flow.worker_main import ARTIFACT_DIR_ENV
from fluksio.flow.workers import PythonWorkerPool from fluksio.flow.workers import PythonWorkerPool
+9 -6
View File
@@ -28,8 +28,12 @@ dependencies = [
"influxdb-client[async]>=1.40.0", "influxdb-client[async]>=1.40.0",
"croniter>=1.3.0", "croniter>=1.3.0",
"mcp>=1.29,<2", "mcp>=1.29,<2",
"fluksio-worker>=0.1,<0.2",
] ]
[tool.uv.sources]
fluksio-worker = { workspace = true }
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest<8.0.0,>=7.4.3", "pytest<8.0.0,>=7.4.3",
@@ -42,6 +46,11 @@ dev = [
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["fluksio"] packages = ["fluksio"]
# Named rather than excluded: this directory also holds a working tree's
# runtime data — `flow-data/` with the user venv in it — which is not source.
[tool.hatch.build.targets.sdist]
include = ["fluksio", "tests", "scripts", "alembic.ini", "pyproject.toml", "README.md"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
@@ -92,12 +101,6 @@ ignore = [
# Printing is what this one is about: node code is user code, and `print` is # Printing is what this one is about: node code is user code, and `print` is
# how it says things. # how it says things.
"tests/flow/test_logs.py" = ["ARG001", "T201"] "tests/flow/test_logs.py" = ["ARG001", "T201"]
# This one takes its own directory off sys.path before the rest of its imports
# run, which is the whole point of doing it there.
"fluksio/flow/worker_main.py" = ["E402"]
# A standalone script copied onto another machine: it has no logger configured
# before it tells you the one dependency it is missing.
"fluksio/worker/fluksio_worker.py" = ["T201"]
[tool.ruff.lint.pyupgrade] [tool.ruff.lint.pyupgrade]
# Preserve types, even if a file imports `from __future__ import annotations`. # Preserve types, even if a file imports `from __future__ import annotations`.
+1 -1
View File
@@ -6,10 +6,10 @@ import time
from collections.abc import Iterator from collections.abc import Iterator
import pytest import pytest
from fluksio_worker.worker_main import ARTIFACT_DIR_ENV
from fluksio.flow.artifacts import ArtifactStore from fluksio.flow.artifacts import ArtifactStore
from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.worker_main import ARTIFACT_DIR_ENV
from fluksio.flow.workers import NodeCancelled, NodeTimeout, PythonWorkerPool from fluksio.flow.workers import NodeCancelled, NodeTimeout, PythonWorkerPool
+1 -1
View File
@@ -1,2 +1,2 @@
[tool.uv.workspace] [tool.uv.workspace]
members = ["backend"] members = ["backend", "worker"]
Generated
+14
View File
@@ -9,6 +9,7 @@ resolution-markers = [
[manifest] [manifest]
members = [ members = [
"fluksio", "fluksio",
"fluksio-worker",
] ]
[[package]] [[package]]
@@ -765,6 +766,7 @@ dependencies = [
{ name = "email-validator" }, { name = "email-validator" },
{ name = "emails" }, { name = "emails" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "fluksio-worker" },
{ name = "httpx" }, { name = "httpx" },
{ name = "influxdb-client", extra = ["async"] }, { name = "influxdb-client", extra = ["async"] },
{ name = "jinja2" }, { name = "jinja2" },
@@ -801,6 +803,7 @@ requires-dist = [
{ name = "email-validator", specifier = ">=2.1.0.post1,<3.0.0.0" }, { name = "email-validator", specifier = ">=2.1.0.post1,<3.0.0.0" },
{ name = "emails", specifier = ">=0.6,<1.0" }, { name = "emails", specifier = ">=0.6,<1.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" },
{ name = "fluksio-worker", editable = "worker" },
{ name = "httpx", specifier = ">=0.25.1,<1.0.0" }, { name = "httpx", specifier = ">=0.25.1,<1.0.0" },
{ name = "influxdb-client", extras = ["async"], specifier = ">=1.40.0" }, { name = "influxdb-client", extras = ["async"], specifier = ">=1.40.0" },
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
@@ -827,6 +830,17 @@ dev = [
{ name = "ruff", specifier = ">=0.2.2,<1.0.0" }, { name = "ruff", specifier = ">=0.2.2,<1.0.0" },
] ]
[[package]]
name = "fluksio-worker"
version = "0.1.0"
source = { editable = "worker" }
dependencies = [
{ name = "websockets" },
]
[package.metadata]
requires-dist = [{ name = "websockets", specifier = ">=12" }]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.8.0" version = "1.8.0"
+21
View File
@@ -0,0 +1,21 @@
# fluksio-worker
Runs [Fluksio](https://fluksio.com) nodes on a machine the engine cannot reach —
a GPU box, a cluster node, anything behind a NAT. It dials *out* to the engine
over one authenticated websocket, so nothing has to be exposed here.
```sh
pip install fluksio-worker
fluksio-worker \
--url wss://api.example.com/api/v1/workers/attach \
--token "$FLUKSIO_WORKER_TOKEN" \
--labels gpu,cuda12 \
--python /opt/torch-venv/bin/python
```
`--python` is the interpreter node code runs on, which is how this machine keeps
its own wheels without the engine ever installing them. Mint a token from the
engine with `POST /api/v1/workers/tokens`.
Linux and macOS.
+6
View File
@@ -0,0 +1,6 @@
"""The Fluksio worker: node execution on a machine the engine cannot reach.
Deliberately import-free. The engine imports :mod:`fluksio_worker.worker_main`
to find the runner it launches as a subprocess, and paying for the agent's
imports on that path would be a cost for nothing.
"""
+5
View File
@@ -0,0 +1,5 @@
"""``python -m fluksio_worker`` — the same entry point as the console script."""
from fluksio_worker.agent import main
raise SystemExit(main())
@@ -1,10 +1,9 @@
"""The agent that runs Fluksio nodes on a machine the engine cannot reach. """The agent that runs Fluksio nodes on a machine the engine cannot reach.
Copy this file and ``worker_main.py`` onto the box with the GPU, point it at Point it at the engine on the box with the GPU and it dials in::
the engine, and it dials in::
pip install websockets pip install fluksio-worker
python fluksio_worker.py --url wss://api.example.com/api/v1/workers/attach \\ fluksio-worker --url wss://api.example.com/api/v1/workers/attach \\
--token "$FLUKSIO_WORKER_TOKEN" --labels gpu --python /opt/venv/bin/python --token "$FLUKSIO_WORKER_TOKEN" --labels gpu --python /opt/venv/bin/python
It connects *out*, so the engine needs no route back and nothing has to expose It connects *out*, so the engine needs no route back and nothing has to expose
@@ -13,9 +12,11 @@ subprocesses running ``worker_main.py``, hand each call to one, and pass back
everything that comes out including the metrics a training loop reports everything that comes out including the metrics a training loop reports
while it is still running. while it is still running.
Deliberately one file with one dependency. Nothing of the app is imported Deliberately two files with one dependency. Nothing of the engine is imported
here; a worker host installs Python, ``websockets``, and whatever the nodes here; a worker host installs Python, ``websockets``, and whatever the nodes
themselves need. themselves need. Where pip is not an option, copying this file and
``worker_main.py`` into one directory and running ``python agent.py`` is the
same thing the engine serves the runner at ``GET /api/v1/workers/runtime``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,7 +41,6 @@ except ImportError: # pragma: no cover - the one dependency, named plainly
) )
raise SystemExit(1) from None raise SystemExit(1) from None
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fluksio-worker") log = logging.getLogger("fluksio-worker")
PROTOCOL = 1 PROTOCOL = 1
@@ -50,22 +50,9 @@ HEARTBEAT_S = 10.0
MAX_BACKOFF_S = 30.0 MAX_BACKOFF_S = 30.0
def _worker_main() -> Path: #: The user-code runner, beside this file — installed together, or copied
"""The user-code runner: beside this file once deployed, or in a checkout. #: together onto a host where pip is not an option.
WORKER_MAIN = Path(__file__).resolve().with_name("worker_main.py")
On a worker host the two files sit together, which is what the install
instructions say. Run straight from a clone and the runner is one directory
over, in the engine's own package — worth finding, so trying this out does
not start with copying files around.
"""
here = Path(__file__).resolve().parent
for candidate in (here / "worker_main.py", here.parent / "flow" / "worker_main.py"):
if candidate.exists():
return candidate
return here / "worker_main.py"
WORKER_MAIN = _worker_main()
class Subprocess: class Subprocess:
@@ -267,7 +254,10 @@ def _venv_digest(python: str) -> str:
return hashlib.sha256(listing.encode()).hexdigest()[:16] return hashlib.sha256(listing.encode()).hexdigest()[:16]
def main() -> int: def main(argv: list[str] | None = None) -> int:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
)
parser = argparse.ArgumentParser(description="Run Fluksio nodes on this machine.") parser = argparse.ArgumentParser(description="Run Fluksio nodes on this machine.")
parser.add_argument("--url", required=True, help="wss://…/api/v1/workers/attach") parser.add_argument("--url", required=True, help="wss://…/api/v1/workers/attach")
parser.add_argument( parser.add_argument(
@@ -288,7 +278,7 @@ def main() -> int:
) )
parser.add_argument("--parallel", type=int, default=1) parser.add_argument("--parallel", type=int, default=1)
parser.add_argument("--artifact-url", default="") parser.add_argument("--artifact-url", default="")
args = parser.parse_args() args = parser.parse_args(argv)
if not args.token: if not args.token:
parser.error("a token is required (--token or FLUKSIO_WORKER_TOKEN)") parser.error("a token is required (--token or FLUKSIO_WORKER_TOKEN)")
@@ -2,7 +2,7 @@
This runs under the *user* venv's interpreter, so a node's imports resolve This runs under the *user* venv's interpreter, so a node's imports resolve
against what the Modules page installed rather than against the engine's own against what the Modules page installed rather than against the engine's own
packages. Nothing of the app is importable here the file is handed to the packages. Nothing of the engine is importable here the file is handed to the
interpreter by path and is deliberately standard library only. interpreter by path and is deliberately standard library only.
The protocol is one JSON object per line: a request arrives on stdin, the reply The protocol is one JSON object per line: a request arrives on stdin, the reply
@@ -33,7 +33,7 @@ import os
import sys import sys
# Handed to the interpreter by path, so CPython puts this file's own directory # Handed to the interpreter by path, so CPython puts this file's own directory
# — the engine's ``app/flow`` — on ``sys.path[0]``. A node doing ``import # — ``fluksio_worker`` — on ``sys.path[0]``. A node doing ``import
# queue`` would then get the engine's queue module rather than the standard # queue`` would then get the engine's queue module rather than the standard
# library's. Drop it before anything else can import. (``-P`` and # library's. Drop it before anything else can import. (``-P`` and
# ``PYTHONSAFEPATH`` do this at startup, but both are 3.11+.) # ``PYTHONSAFEPATH`` do this at startup, but both are 3.11+.)
@@ -131,8 +131,8 @@ def _store_bytes(data: bytes, name: str, media_type: str) -> dict[str, Any]:
A worker in the engine's own container writes the file; one on another host 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 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 the same flow runs either place. The hashing is repeated from
``fluksio.flow.artifacts`` rather than imported, because nothing of the app is ``fluksio.flow.artifacts`` rather than imported, because nothing of the engine
importable here. is importable here.
""" """
digest = "sha256:" + hashlib.sha256(data).hexdigest() digest = "sha256:" + hashlib.sha256(data).hexdigest()
directory = os.environ.get(ARTIFACT_DIR_ENV) directory = os.environ.get(ARTIFACT_DIR_ENV)
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "fluksio-worker"
version = "0.1.0"
description = "Runs Fluksio nodes on a machine the engine cannot reach"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"websockets>=12",
]
[project.scripts]
fluksio-worker = "fluksio_worker.agent:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["fluksio_worker"]
[tool.ruff]
# The engine's config, so both distributions are linted the same way.
extend = "../backend/pyproject.toml"
[tool.ruff.lint.per-file-ignores]
# This one takes its own directory off sys.path before the rest of its imports
# run, which is the whole point of doing it there.
"fluksio_worker/worker_main.py" = ["E402"]
# Copied onto another machine it has no logger configured before it tells you
# the one dependency it is missing.
"fluksio_worker/agent.py" = ["T201"]