Service node: a server kept running on a worker (openpanel V2)

- `service` node type: command, port, worker label, health path, env and
  weights (an artifact digest the worker fetches as {weights}). Its flow
  starts it; it restarts with backoff, reads degraded while starting or
  without a worker and down after three starts that never got healthy, and
  publishes http://<worker>:<port> once /health answers 200, "" when it ends.
- Worker protocol 3: a `serve` call, every call in its own process group,
  killed when the engine ends the call, the socket drops or the agent gets
  SIGTERM. `--service-host` overrides the address the engine sees.
- Services and compiles take no `--parallel` slot, and a flow's detail is
  built off the event loop, so a save no longer stalls the engine.
This commit is contained in:
Melvin Strobl
2026-10-19 18:43:04 +02:00
parent 80e0201612
commit 85dad39973
16 changed files with 757 additions and 34 deletions
+14 -9
View File
@@ -220,6 +220,11 @@ def _endpoints(controller: FlowController, definition: FlowDef) -> list[Endpoint
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
"""The flow and how its nodes are doing. Blocking: call it off the loop.
A draft is built to report on it, which asks a worker to compile its
nodes, and that waits on the socket the loop serves.
"""
name = definition.name
running = {
"enabled": controller.is_enabled(name),
@@ -534,7 +539,7 @@ async def save_flow(
status_code=409,
detail={"message": str(exc), "current_version": exc.current},
)
return _detail(controller, stored)
return await run_in_threadpool(_detail, controller, stored)
@router.post("/{name}/publish", response_model=FlowDetail)
@@ -561,7 +566,7 @@ async def publish_flow(
)
_audit("published", name, user)
await controller.reload_flow(name)
return _detail(controller, published)
return await run_in_threadpool(_detail, controller, published)
@router.post("/{name}/discard-draft", response_model=FlowDetail)
@@ -581,7 +586,7 @@ async def discard_draft(name: str, controller: FlowControllerDep) -> Any:
),
)
published = await run_in_threadpool(controller.store.discard_draft, name)
return _detail(controller, published)
return await run_in_threadpool(_detail, controller, published)
@router.delete("/{name}", response_model=Message)
@@ -644,7 +649,7 @@ async def rename_flow(
# run, so this is cleanup rather than a migration.
await run_in_threadpool(controller.forget_flow, name)
await controller.reload()
return _detail(controller, renamed)
return await run_in_threadpool(_detail, controller, renamed)
@router.post("/{name}/duplicate", response_model=FlowDetail)
@@ -662,7 +667,7 @@ async def duplicate_flow(
copy = await run_in_threadpool(controller.store.duplicate_flow, name)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
return _detail(controller, copy)
return await run_in_threadpool(_detail, controller, copy)
# -----------------------------------------------------------------------------
@@ -810,7 +815,7 @@ async def share_node(
)
except LibExists as exc:
raise HTTPException(status_code=409, detail=str(exc))
return _detail(controller, _read_flow(controller, name))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/nodes/{node_id}/unshare", response_model=FlowDetail)
@@ -825,7 +830,7 @@ async def unshare_node(
await run_in_threadpool(controller.store.unshare_node, name, node_id)
except LibNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc))
return _detail(controller, _read_flow(controller, name))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
# -----------------------------------------------------------------------------
@@ -841,7 +846,7 @@ async def start_flow(
_read_flow(controller, name)
await controller.set_enabled(name, True)
_audit("started", name, user)
return _detail(controller, _read_flow(controller, name))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/stop", response_model=FlowDetail)
@@ -850,7 +855,7 @@ async def stop_flow(name: str, controller: FlowControllerDep, user: CurrentUser)
_read_flow(controller, name)
await controller.set_enabled(name, False)
_audit("stopped", name, user)
return _detail(controller, _read_flow(controller, name))
return await run_in_threadpool(_detail, controller, _read_flow(controller, name))
@router.post("/{name}/pause", response_model=Message)
+4
View File
@@ -337,6 +337,10 @@ async def _attached_session(websocket: WebSocket, claims: dict[str, Any]) -> Non
"version": hello.get("version"),
},
inventory=WorkerInventory.from_hello(hello.get("inventory")),
host=str(
hello.get("service_host")
or (websocket.client.host if websocket.client else "")
),
)
hub.attach(worker)
_behind.pop(name, None)
+8
View File
@@ -56,6 +56,7 @@ from fluksio.flow.nodes import (
Node,
NtfyNode,
RbeNode,
ServiceNode,
SwitchNode,
WebSocketNode,
WrenNode,
@@ -425,6 +426,13 @@ NODE_TYPES: dict[str, NodeType] = {
runs_code=True,
params_schema=_schema_of(ExecNode),
),
"service": NodeType(
title="Service",
description="Keep a server running on a worker, and publish its url.",
cls=ServiceNode,
runs_code=True,
params_schema=_schema_of(ServiceNode),
),
"file": NodeType(
title="File",
description="Read a file into the flow, or write one out of it.",
+2
View File
@@ -17,6 +17,7 @@ from fluksio.flow.nodes.logic import ChangeNode, JoinNode, RbeNode, SwitchNode
from fluksio.flow.nodes.mlp import MLPNode
from fluksio.flow.nodes.mqtt import MqttNode
from fluksio.flow.nodes.ntfy import NtfyNode
from fluksio.flow.nodes.service import ServiceNode
from fluksio.flow.nodes.websocket import WebSocketNode
from fluksio.flow.nodes.wren import WrenNode
@@ -37,6 +38,7 @@ __all__ = [
"NtfyNode",
"RESERVED_SETTINGS",
"RbeNode",
"ServiceNode",
"SwitchNode",
"WebSocketNode",
"WrenNode",
+246
View File
@@ -0,0 +1,246 @@
"""Service: a server kept running on a worker while its flow is live.
A Python node is a call that returns, in a process the worker kills afterwards,
so a model it loads is loaded again on every message. A service is the other
shape: one long call that runs a command, such as a speech server, and keeps
it up. Other nodes reach it over HTTP at the url it publishes.
"""
from __future__ import annotations
import asyncio
import logging
import shlex
import uuid
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
import httpx
from pydantic import BaseModel, ConfigDict, Field
from fluksio.core.config import settings
from fluksio.flow.messages import MessageSpec
from fluksio.flow.nodes.base import Node
if TYPE_CHECKING:
from fastapi import FastAPI
from fluksio.flow.remote import RemoteWorker, RemoteWorkerHub
logger = logging.getLogger(__name__)
#: Seconds between restarts, one step per start in a row that never got
#: healthy. A service that served and then died starts again after the first.
BACKOFF = settings.FLOW_RESTART_BACKOFF
#: Starts in a row that never answered healthy before the node reads down.
DOWN_AFTER = 3
#: How often a starting server's health is asked, and how long one ask may take.
HEALTH_POLL_S = 1.0
HEALTH_TIMEOUT_S = 2.0
#: How often to look again when no worker carries the label.
WORKER_POLL_S = 2.0
#: How long a stopping flow waits for the worker to say the server is gone, so
#: a flow started again finds the port free. Under the supervisor's grace.
STOP_WAIT_S = 4.0
class ServiceNode(Node):
"""Keep a server running on a worker and publish the url it listens on.
The command runs on a worker matched by label, in one call that takes none
of the worker's ``--parallel`` slots, and starts again whenever it ends.
Every output port carries the url once the server answers its health
check, and an empty string the moment the call ends.
"""
class Params(BaseModel):
model_config = ConfigDict(extra="allow")
command: str = Field(
description=(
"The server to run, such as `parrot serve --host 0.0.0.0 "
"--port {port}`. {port} and {weights} are filled in."
)
)
port: int = Field(
default=8100, ge=1, le=65535, description="The port the server listens on."
)
worker: str = Field(
description="The label of the worker to run it on, such as gpu."
)
health: str = Field(
default="/health",
description="The path that answers 200 once the server is ready.",
)
env: dict[str, str] = Field(
default_factory=dict,
description="Environment variables for the server.",
)
weights: str = Field(
default="",
pattern=r"^(sha256:[0-9a-f]{64})?$",
description=(
"An artifact digest, fetched to the worker. Its local path "
"is {weights} in the command."
),
)
__slots__ = ("cfg", "argv", "hub", "_task", "_url")
def __init__(
self,
requires: MessageSpec | Iterable[MessageSpec] = (),
provides: MessageSpec | Iterable[MessageSpec] = (),
params: dict[str, Any] | None = None,
name: str | None = None,
):
self.cfg = self.Params.model_validate(params or {})
self.argv = shlex.split(self.cfg.command.replace("{port}", str(self.cfg.port)))
if not self.argv:
raise ValueError("a service needs a command")
if "{weights}" in self.cfg.command and not self.cfg.weights:
raise ValueError("the command uses {weights}, but no weights are set")
# Set from the app on start; a test sets it directly.
self.hub: RemoteWorkerHub | None = None
self._task: asyncio.Task[None] | None = None
self._url = ""
super().__init__(
f=self._never_called,
requires=requires,
provides=provides,
params=params,
name=name or "service",
)
@staticmethod
def _never_called(params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
"""Its flow starts it, not a message, and it publishes on its own."""
return None
# -- lifecycle ------------------------------------------------------------
async def start(self, app: FastAPI | None = None) -> None:
if app is not None:
self.hub = getattr(app.state, "worker_hub", None)
# Spawned on every start, as the websocket node's: the supervisor
# ignores a duplicate.
if self._task is not None and not self._task.done():
return
self._task = self._run_supervised("service", self._keep_running)
async def stop(self, app: FastAPI | None = None) -> None:
if self._task is not None:
await self._cancel_task(self._task)
self._task = None
async def _keep_running(self) -> None:
"""Run the server, and run it again whenever it ends."""
failures = 0
while True:
worker = self.hub.pick(self.cfg.worker) if self.hub is not None else None
if worker is None:
self.report_health(
"degraded", f"no worker labelled '{self.cfg.worker}' is attached"
)
await asyncio.sleep(WORKER_POLL_S)
continue
self.report_health("degraded", f"starting on '{worker.name}'")
healthy, reason = await self._serve_once(worker)
# Only starts that never got healthy lead to down: one that served
# for a while is restarted as often as it dies.
failures = 0 if healthy else failures + 1
delay = BACKOFF[min(failures, len(BACKOFF) - 1)]
logger.warning("Service '%s' ended: %s", self.id, reason)
self.report_health(
"down" if failures >= DOWN_AFTER else "degraded",
f"{reason}; restarting in {delay:g}s",
)
await asyncio.sleep(delay)
async def _serve_once(self, worker: RemoteWorker) -> tuple[bool, str]:
"""One call on this worker, until it ends: whether it got healthy, and why."""
call_id = f"{self.id}:{uuid.uuid4().hex[:8]}"
host = f"[{worker.host}]" if ":" in worker.host else worker.host
url = f"http://{host}:{self.cfg.port}"
payload = {
"op": "serve",
"call_id": call_id,
"argv": self.argv,
"env": self.cfg.env,
"weights": self.cfg.weights,
}
# On a thread, as every call is, and holding no slot.
call = asyncio.ensure_future(
asyncio.to_thread(worker.request, payload, 0, slot=False)
)
# Retrieved however it ends, so one that outlives a stop is not
# reported at exit.
call.add_done_callback(lambda done: done.cancelled() or done.exception())
healthy = False
try:
healthy = await self._await_health(call, worker.name, url)
if healthy:
self._url = url
await asyncio.to_thread(self._publish, url)
self.report_health("ok")
await asyncio.wait({call})
error = call.result().get("error") or {}
return healthy, str(error.get("message") or "the service stopped")
except Exception as exc:
# The worker went away or went silent, or the url was refused.
return healthy, f"{type(exc).__name__}: {exc}"
finally:
if not call.done():
# The flow stopped, or this side failed. Ending the call kills
# the server's process group on the worker, and its answer says
# that is done, so the next start finds the port free.
await asyncio.to_thread(worker.cancel, call_id)
await asyncio.wait({call}, timeout=STOP_WAIT_S)
await asyncio.to_thread(self._withdraw)
async def _await_health(
self, call: asyncio.Future[Any], worker: str, url: str
) -> bool:
"""Ask the server's health until it answers 200, or the call ends first."""
async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT_S) as client:
while not call.done():
problem = await self._probe(client, url + self.cfg.health)
if problem is None:
return True
self.report_health(
"degraded",
f"starting on '{worker}': {self.cfg.health} {problem}",
)
await asyncio.wait({call}, timeout=HEALTH_POLL_S)
return False
@staticmethod
async def _probe(client: httpx.AsyncClient, url: str) -> str | None:
"""None once the url answers 200, else what it did instead."""
try:
response = await client.get(url)
except httpx.HTTPError as exc:
return f"is not answering ({type(exc).__name__})"
if response.status_code == 200:
return None
return f"answers {response.status_code}"
# -- the url ----------------------------------------------------------------
def _publish(self, url: str) -> None:
self.emit({spec.port: url for spec in self.output_ports})
def _withdraw(self) -> None:
"""Empty the url the moment the server is gone.
Unless its value is gone already: a deleted flow's values are cleared
before its nodes stop, and writing the url would bring one back.
"""
if not self._url:
return
self._url = ""
pipeline = self._pipeline
if pipeline is not None and any(
pipeline.state.get(spec.name) for spec in self.output_ports
):
self._publish("")
+18 -4
View File
@@ -45,8 +45,9 @@ SEND_TIMEOUT_S = 30.0
#: whose node has no timeout of its own.
SILENCE_S = 90.0
#: Protocol version this engine speaks. A worker announcing anything else is
#: refused rather than half-understood.
PROTOCOL = 2
#: refused rather than half-understood. 3: the ``serve`` call, and calls that
#: end with their process group.
PROTOCOL = 3
class NoWorker(RemoteError):
@@ -102,9 +103,13 @@ class RemoteWorker:
max_parallel: int = 1,
info: dict[str, Any] | None = None,
inventory: WorkerInventory | None = None,
host: str = "",
) -> None:
self.name = name
self.labels = set(labels)
#: Where the engine reaches what this worker serves: the address its
#: socket came from, unless the worker says otherwise.
self.host = host
self.info = info or {}
self.inventory = inventory or WorkerInventory()
# This machine's books, held here so that a worker reconnecting — which
@@ -163,14 +168,21 @@ class RemoteWorker:
payload: dict[str, Any],
timeout: float,
on_event: Callable[[dict[str, Any]], None] | None = None,
slot: bool = True,
) -> dict[str, Any]:
"""Send a call and wait for its answer, on a thread rather than the loop.
``slot`` off is for what must not queue behind running calls, nor hold
one: a compile, and a service, which would hold one for good.
"""
if self._gone:
raise RemoteError(f"worker '{self.name}' is no longer attached")
call_id = str(payload["call_id"])
inbox: queue.Queue[dict[str, Any] | None] = queue.Queue()
# Blocking here is the backpressure, exactly as taking a slot is in the
# local pool.
self._slots.acquire()
if slot:
self._slots.acquire()
with self._lock:
self._pending[call_id] = inbox
try:
@@ -226,7 +238,8 @@ class RemoteWorker:
finally:
with self._lock:
self._pending.pop(call_id, None)
self._slots.release()
if slot:
self._slots.release()
@property
def compiled(self) -> set[str]:
@@ -440,6 +453,7 @@ class RemoteWorkerHub:
"source": source,
},
timeout=timeout,
slot=False,
)
except RemoteError as exc:
return f"{type(exc).__name__}: {exc}"
+5
View File
@@ -85,6 +85,11 @@ FIXTURES: dict[str, dict] = {
"requires": [MessageSpec(name="go", dtype=DType.BOOL)],
"provides": [MessageSpec(name="stdout", dtype=DType.STR)],
},
"service": {
"params": {"command": "parrot serve --port {port}", "worker": "gpu"},
"requires": [],
"provides": [MessageSpec(name="url", dtype=DType.STR)],
},
"file": {
"params": {"path": "readings.log", "mode": "append"},
"requires": [MessageSpec(name="line", dtype=DType.STR)],
+37
View File
@@ -243,6 +243,43 @@ def test_cancelling_a_run_reaches_only_that_run(loop):
assert [frame["call_id"] for frame in cancels] == ["run-a:flow.node"]
def test_a_service_and_a_compile_leave_the_only_slot_to_calls(loop):
"""A service holding the slot for good would leave the worker blocked."""
hub = RemoteWorkerHub()
socket = FakeSocket()
worker = RemoteWorker(
name="gpu1", labels=["gpu"], send=socket.send_json, loop=loop, max_parallel=1
)
hub.attach(worker)
call_in_thread(
lambda: worker.request({"op": "serve", "call_id": "svc"}, 0, slot=False)
)
assert socket.arrived.wait(5)
socket.arrived.clear()
result: dict = {}
thread = call_in_thread(
lambda: result.update(
value=hub.run("gpu", "flow", "node", "src", {}, "flow.node", timeout=5)
)
)
assert socket.arrived.wait(5)
# Neither waits for the slot the call above holds.
compiled = call_in_thread(
lambda: result.update(problem=hub.compile("gpu", "flow", "other", "src"))
)
deadline = time.monotonic() + 5
while len(socket.sent) < 3 and time.monotonic() < deadline:
time.sleep(0.01)
for frame in socket.sent[1:]:
worker.deliver({"call_id": frame["call_id"], "ok": True, "result": None})
thread.join(timeout=5)
compiled.join(timeout=5)
assert [frame["op"] for frame in socket.sent] == ["serve", "run", "compile"]
assert result == {"value": None, "problem": None}
assert worker.in_flight == 1
def test_a_cancel_after_the_loop_closed_leaves_no_coroutine_behind():
closed = asyncio.new_event_loop()
closed.close()
+134
View File
@@ -0,0 +1,134 @@
"""The service node: a server on a worker, restarted when it ends, its url following."""
import asyncio
from collections.abc import Callable
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import ServiceNode, service
from fluksio.flow.pipeline import Pipeline
from fluksio.flow.remote import RemoteWorker, RemoteWorkerHub
class Agent:
"""The worker's side of the socket: records calls, and ends one on cancel."""
def __init__(self) -> None:
self.sent: list[dict] = []
self.worker: RemoteWorker | None = None
async def send_json(self, payload: dict) -> None:
self.sent.append(payload)
if payload["op"] == "cancel":
self.end(payload["call_id"], "the service was stopped")
def end(self, call_id: str, reason: str) -> None:
assert self.worker is not None
self.worker.deliver(
{"call_id": call_id, "ok": False, "error": {"message": reason}}
)
def serves(self) -> list[dict]:
return [frame for frame in self.sent if frame["op"] == "serve"]
async def until(check: Callable[[], bool]) -> None:
for _ in range(500):
if check():
return
await asyncio.sleep(0.01)
raise AssertionError("never happened")
def test_a_service_comes_back_and_its_url_follows(monkeypatch):
monkeypatch.setattr(service, "BACKOFF", (0.01,))
async def healthy(client, url):
return None
monkeypatch.setattr(ServiceNode, "_probe", staticmethod(healthy))
async def scenario() -> None:
agent = Agent()
agent.worker = RemoteWorker(
name="amcomp",
labels=["gpu"],
send=agent.send_json,
loop=asyncio.get_running_loop(),
host="10.0.0.5",
)
hub = RemoteWorkerHub()
node = ServiceNode(
provides=[MessageSpec(name="url", dtype=DType.STR)],
params={"command": "serve --port {port}", "worker": "gpu", "port": 8110},
)
node.assign_flow("voice", "parrot")
pipeline = Pipeline(nodes=[node])
health: list[str] = []
node._on_health = lambda _node, status, _detail: health.append(status)
node.hub = hub
def url() -> object:
return pipeline.state.get("voice.url")
await node.start()
await asyncio.sleep(0.05)
# No worker carries the label yet.
assert health[-1] == "degraded"
assert not agent.sent
hub.attach(agent.worker)
await until(lambda: bool(agent.serves()))
await until(lambda: url() == "http://10.0.0.5:8110")
first = agent.serves()[0]
assert first["argv"] == ["serve", "--port", "8110"]
assert health[-1] == "ok"
# The server dies: the url goes at once, and a second call starts.
agent.end(first["call_id"], "the service exited with code 1")
await until(lambda: url() == "")
assert "degraded" in health[health.index("ok") :]
await until(lambda: len(agent.serves()) == 2)
await until(lambda: url() == "http://10.0.0.5:8110")
# Stopping the flow ends the call on the worker, and empties the url.
await node.stop()
second = agent.serves()[1]["call_id"]
assert {"op": "cancel", "call_id": second} in agent.sent
assert url() == ""
asyncio.run(scenario())
def test_a_service_that_never_gets_healthy_reads_down(monkeypatch):
monkeypatch.setattr(service, "BACKOFF", (0.01,))
async def refused(client, url):
return "is not answering"
monkeypatch.setattr(ServiceNode, "_probe", staticmethod(refused))
async def scenario() -> None:
agent = Agent()
agent.worker = RemoteWorker(
name="amcomp",
labels=["gpu"],
send=agent.send_json,
loop=asyncio.get_running_loop(),
)
hub = RemoteWorkerHub()
hub.attach(agent.worker)
node = ServiceNode(params={"command": "serve", "worker": "gpu"})
node.assign_flow("voice", "parrot")
health: list[tuple[str, str]] = []
node._on_health = lambda _node, status, detail: health.append((status, detail))
node.hub = hub
await node.start()
for attempt in range(1, service.DOWN_AFTER + 1):
await until(lambda n=attempt: len(agent.serves()) == n)
agent.end(agent.serves()[-1]["call_id"], "Address already in use")
await until(lambda: health[-1][0] == "down")
assert "Address already in use" in health[-1][1]
await node.stop()
asyncio.run(scenario())
+60
View File
@@ -1,6 +1,10 @@
"""The remote worker's own guards: where its token goes, and what node code gets."""
import argparse
import asyncio
import json
import time
from pathlib import Path
import pytest
from fluksio_worker import agent
@@ -75,3 +79,59 @@ def test_only_a_whole_digest_is_opened(digest: str) -> None:
"""The digest is joined into a path, so anything else could read any file."""
with pytest.raises(ValueError, match="not an artifact reference"):
_Reporter("fluksio").load_artifact({"digest": digest})
def _gone(pid: int) -> bool:
"""No such process, or one only waiting to be reaped."""
try:
return Path(f"/proc/{pid}/stat").read_text().split()[2] == "Z"
except FileNotFoundError:
return True
def test_stopping_a_service_takes_its_process_group_with_it(tmp_path: Path) -> None:
"""A child of the server left running would keep its port."""
args = argparse.Namespace(
url="wss://engine.example.com/api/v1/workers/attach",
artifact_url="",
token="t",
)
worker = agent.Agent(args)
child = tmp_path / "child"
sent: list[dict] = []
class Socket:
async def send(self, text: str) -> None:
sent.append(json.loads(text))
async def scenario() -> None:
script = f"sleep 300 & echo $! > {child}; wait"
request = {"op": "serve", "call_id": "svc", "argv": ["sh", "-c", script]}
task = asyncio.create_task(worker._serve(Socket(), request))
for _ in range(500):
if child.exists() and child.read_text().strip():
break
await asyncio.sleep(0.01)
worker._cancel("svc")
await task
asyncio.run(scenario())
pid = int(child.read_text())
deadline = time.monotonic() + 5
while not _gone(pid) and time.monotonic() < deadline:
time.sleep(0.05)
assert _gone(pid)
assert not worker.serving
assert sent == [
{
"call_id": "svc",
"ok": False,
"error": {
"type": "ServiceStopped",
"message": "the service was stopped",
"short": "the service was stopped",
"traceback": "",
},
}
]
+5
View File
@@ -335,6 +335,11 @@ the local engine when none is attached. Code is compiled on the selected
machine, which needs the packages your node imports. See
[Remote workers](workers.md) for setup and resource requests.
Each call runs in a fresh process that ends with the call, so a model loaded
in a node is loaded again on the next message. To keep one loaded, serve it
from a [Service node](../reference/node-types.md#service) and call it over
HTTP.
## Sharing code between flows
Promote a node's source to the shared library from its panel. Other flows can
+20
View File
@@ -55,6 +55,7 @@ provides the same agent as `fluksio worker`.
| `--python` | worker's interpreter | Python used for node code |
| `--parallel` | `1` | concurrent calls |
| `--artifact-url` | derived from `--url` | artifact endpoint, if hosted separately |
| `--service-host` | address the engine sees | host in a service's url, when the engine reaches this machine elsewhere, such as through a tunnel |
| `--cpus` | detected | advertised CPU count |
| `--gpus` | detected | advertised GPU count; explicit `0` means none |
| `--ram-mb` | detected | advertised memory in MB |
@@ -99,6 +100,25 @@ in-flight calls. While executing, the agent sends heartbeats every ten seconds;
90 seconds without contact fails the call. Heartbeats do not reset the node's
own silence timeout.
Every call runs in a process group of its own. The agent kills the group when
the engine ends the call, when the connection drops and when the agent stops,
so nothing a node started keeps running. If the agent itself is killed, its
service manager has to clean up: systemd's default `KillMode=control-group`
does.
## Services
A [Service node](../reference/node-types.md#service) runs a server on a worker
for as long as its flow runs, such as a speech model that should stay loaded.
It is one long call that holds none of the worker's `--parallel` slots and no
resources, so it does not block calls or hold a GPU against a training run.
The engine checks the server's health over HTTP and restarts it whenever it
stops, and its url names the address the engine sees the worker connect from.
Set `--service-host` on the worker where that address is wrong.
A service's `weights` setting holds an artifact digest. The worker fetches it
as node code fetches an artifact and passes the local path as `{weights}`.
## Resources and artifacts
Workers report CPU, GPU and memory inventory, and scheduling checks both labels
+22
View File
@@ -285,6 +285,28 @@ Outputs the command's stdout, stderr and exit code.
In Docker, the command runs inside the backend container. Mount host files
explicitly if the command needs them.
### Service
**`service`** — keep a server running on a [remote worker](../code/workers.md#services)
for as long as its flow runs, and publish the url it listens on. Adding one
or changing its settings requires a superuser.
| Setting | Default | Notes |
|---|---|---|
| `command` | — | the server, such as `parrot serve --host 0.0.0.0 --port {port}` |
| `port` | `8100` | where the server listens, `{port}` in the command |
| `worker` | — | label of the worker to run it on, such as `gpu` |
| `health` | `/health` | path that answers 200 once the server is ready |
| `env` | `{}` | environment variables for the server |
| `weights` | — | an artifact digest, fetched to the worker as `{weights}` |
The flow starts it, so it takes no inputs. Every output port carries
`http://<worker>:<port>` once the health check answers 200, and an empty
string the moment the server stops. When it stops, it starts again after a
backoff. Its health reads `degraded` while it starts, restarts or waits for a
worker, `down` after three starts in a row that never got healthy, and `ok`
while it serves.
### File
**`file`** — read a file into the flow, or write one out of it.
@@ -16,6 +16,7 @@ import {
Play,
Plug,
Radio,
Server,
Shuffle,
Split,
Square,
@@ -58,6 +59,7 @@ const NODE_ICONS = {
join: Merge,
debounce: Timer,
exec: Terminal,
service: Server,
file: FileText,
ntfy: Bell,
wren: Bird,
+172 -20
View File
@@ -13,7 +13,12 @@ It connects *out*, so the engine needs no route back and nothing has to expose
Redis. What it then does is what the engine's own worker pool does: hold a few
subprocesses running ``worker_main.py``, hand each call to one, and pass back
everything that comes out — including the metrics a training loop reports
while it is still running.
while it is still running. A service node's call runs a command instead, a
server kept up until the engine ends the call.
Every call runs in a process group of its own, and ends with it: when the
engine ends the call, when the socket drops, or when this agent stops. A
process left behind would keep its port and its card.
Deliberately two files with one dependency. Nothing of the engine is imported
here; a worker host installs Python, ``websockets``, and whatever the nodes
@@ -51,9 +56,13 @@ except ImportError: # pragma: no cover - the one dependency, named plainly
log = logging.getLogger("fluksio-worker")
PROTOCOL = 2
PROTOCOL = 3
#: Sent while a call is running, so the engine can tell working from wedged.
HEARTBEAT_S = 10.0
#: How long a stopped service gets to shut down before its group is killed.
STOP_GRACE_S = 3.0
#: How much of what a service printed last is kept, to say why it ended.
TAIL_BYTES = 4096
#: Reconnection backs off to this and no further.
MAX_BACKOFF_S = 30.0
#: Environment node code is not given; the engine's lists, see `_node_env`.
@@ -84,6 +93,7 @@ class Subprocess:
stdout=subprocess.PIPE,
close_fds=True,
env=env,
start_new_session=True,
)
self._buffer = bytearray()
@@ -119,8 +129,8 @@ class Subprocess:
self._buffer += chunk
def kill(self) -> None:
with contextlib.suppress(OSError):
self.proc.send_signal(signal.SIGKILL)
"""SIGKILL to the whole group, so whatever the node started goes too."""
_signal_group(self.proc.pid, signal.SIGKILL)
with contextlib.suppress(subprocess.TimeoutExpired):
self.proc.wait(timeout=5)
@@ -136,10 +146,18 @@ class Agent:
)
self.env["FLUKSIO_ARTIFACT_TOKEN"] = args.token
self.running: dict[str, Subprocess] = {}
#: Service calls, by call id. Cancelling one stops its server.
self.serving: dict[str, asyncio.Task[None]] = {}
#: Every call of this session, so a dropped socket can end them.
self._calls: set[asyncio.Task[None]] = set()
self.last_done = time.monotonic()
self._idled = False
async def serve_forever(self) -> None:
# A stop is a Ctrl-C: the calls end, and their process groups with them.
main = asyncio.current_task()
assert main is not None
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, main.cancel)
backoff = 1.0
while True:
try:
@@ -175,6 +193,7 @@ class Agent:
"max_parallel": self.args.parallel,
"venv_digest": _venv_digest(self.args.python),
"inventory": _inventory(self.args),
"service_host": self.args.service_host,
}
)
)
@@ -207,11 +226,10 @@ class Agent:
# this import" is a question about *this* machine's
# packages.
log.info("%s %s", op, message.get("call_id"))
task = asyncio.create_task(self._run(socket, message))
# Without this a failure in the task is only noticed
# when it is garbage collected, which reads as a call
# that vanished.
task.add_done_callback(_report_failure)
self._spawn(self._run(socket, message))
elif op == "serve":
log.info("serve %s", message.get("call_id"))
self._spawn(self._serve(socket, message))
elif op == "cancel":
self._cancel(str(message.get("call_id") or ""))
except Exception:
@@ -221,9 +239,26 @@ class Agent:
finally:
if watchdog is not None:
watchdog.cancel()
# The engine has failed these calls already, and a process
# left running would keep its port for the next one.
await self._end_calls()
if self._idled:
raise Idle
def _spawn(self, call: Any) -> None:
task = asyncio.create_task(call)
self._calls.add(task)
task.add_done_callback(self._calls.discard)
# Without this a failure in the task is only noticed when it is
# garbage collected, which reads as a call that vanished.
task.add_done_callback(_report_failure)
async def _end_calls(self) -> None:
calls = list(self._calls)
for task in calls:
task.cancel()
await asyncio.gather(*calls, return_exceptions=True)
async def _idle_watch(self, socket: Any) -> None:
"""Close the socket once nothing has run here for ``--max-idle``.
@@ -234,7 +269,7 @@ class Agent:
tick = min(30.0, max(1.0, self.args.max_idle / 4))
while True:
await asyncio.sleep(tick)
if self.running:
if self.running or self.serving:
continue
if time.monotonic() - self.last_done >= self.args.max_idle:
self._idled = True
@@ -259,15 +294,7 @@ class Agent:
worker = Subprocess(self.args.python, {**self.env, **extra})
self.running[call_id] = worker
async def beat() -> None:
while True:
await asyncio.sleep(HEARTBEAT_S)
with contextlib.suppress(Exception):
await socket.send(
json.dumps({"call_id": call_id, "event": "heartbeat"})
)
heartbeat = asyncio.create_task(beat())
heartbeat = asyncio.create_task(_beat(socket, call_id))
try:
# A subprocess that died before it could be written to is reported
# by the read below, which says so and ends the call — rather than
@@ -304,11 +331,128 @@ class Agent:
self.last_done = time.monotonic()
worker.kill()
async def _serve(self, socket: Any, request: dict[str, Any]) -> None:
"""Run one server until it exits or the call is ended, then say why.
The engine checks its health and restarts it; this only runs it. What
it prints goes to this agent's log, and the last of it into the reply.
"""
call_id = str(request.get("call_id") or "")
task = asyncio.current_task()
assert task is not None
self.serving[call_id] = task
env = {
**self.env,
**{str(k): str(v) for k, v in (request.get("env") or {}).items()},
}
argv = [str(part) for part in request.get("argv") or []]
heartbeat = asyncio.create_task(_beat(socket, call_id))
proc: asyncio.subprocess.Process | None = None
tail = b""
try:
weights = str(request.get("weights") or "")
if weights:
path = await self._fetch(weights, env)
argv = [part.replace("{weights}", path) for part in argv]
proc = await asyncio.create_subprocess_exec(
*argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
)
assert proc.stdout is not None
while chunk := await proc.stdout.read(65536):
sys.stderr.buffer.write(chunk)
sys.stderr.flush()
tail = (tail + chunk)[-TAIL_BYTES:]
reason = f"the service exited with code {await proc.wait()}"
last = tail.decode(errors="replace").strip().splitlines()
if last:
reason += f": {last[-1][:300]}"
except asyncio.CancelledError:
# Ended by the engine or by the socket going: stopping it is the
# job, and the reply below still goes if the socket is there.
reason = "the service was stopped"
except Exception as exc:
reason = f"{type(exc).__name__}: {exc}"
finally:
heartbeat.cancel()
self.serving.pop(call_id, None)
self.last_done = time.monotonic()
if proc is not None:
await _stop_group(proc)
log.info("%s ended: %s", call_id, reason)
with contextlib.suppress(Exception):
await socket.send(
json.dumps(
{
"call_id": call_id,
"ok": False,
"error": {
"type": "ServiceStopped",
"message": reason,
"short": reason,
"traceback": "",
},
}
)
)
async def _fetch(self, digest: str, env: dict[str, str]) -> str:
"""A local path for an artifact, fetched as node code fetches one."""
proc = await asyncio.create_subprocess_exec(
self.args.python,
str(WORKER_MAIN),
"--fetch",
digest,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode:
problem = err.decode(errors="replace").strip().splitlines()
raise RuntimeError(
f"could not fetch {digest}: {problem[-1] if problem else proc.returncode}"
)
return out.decode().strip()
def _cancel(self, call_id: str) -> None:
worker = self.running.get(call_id)
if worker is not None:
log.info("cancelling %s", call_id)
worker.kill()
service = self.serving.get(call_id)
if service is not None:
log.info("stopping %s", call_id)
service.cancel()
async def _beat(socket: Any, call_id: str) -> None:
"""Say this call is alive, for as long as it runs."""
while True:
await asyncio.sleep(HEARTBEAT_S)
with contextlib.suppress(Exception):
await socket.send(json.dumps({"call_id": call_id, "event": "heartbeat"}))
def _signal_group(pid: int, sig: int) -> None:
"""Signal a call's process group, which it leads. Gone already is fine."""
with contextlib.suppress(OSError):
os.killpg(pid, sig)
async def _stop_group(proc: asyncio.subprocess.Process) -> None:
"""SIGTERM to the group, a moment to shut down, then SIGKILL to the rest."""
_signal_group(proc.pid, signal.SIGTERM)
try:
await asyncio.wait_for(proc.wait(), STOP_GRACE_S)
except TimeoutError:
pass
finally:
_signal_group(proc.pid, signal.SIGKILL)
def _report_failure(task: asyncio.Task[Any]) -> None:
@@ -475,6 +619,14 @@ def main(argv: list[str] | None = None) -> int:
)
parser.add_argument("--parallel", type=int, default=1)
parser.add_argument("--artifact-url", default="")
parser.add_argument(
"--service-host",
default="",
help=(
"the address the engine reaches this machine's services on "
"(default: the address the engine sees this worker connect from)"
),
)
parser.add_argument(
"--insecure",
action="store_true",
@@ -527,7 +679,7 @@ def main(argv: list[str] | None = None) -> int:
agent = Agent(args)
try:
asyncio.run(agent.serve_forever())
except KeyboardInterrupt:
except (KeyboardInterrupt, asyncio.CancelledError):
log.info("stopping")
except Refused as exc:
log.error("the engine refused this worker: %s", exc)
+8 -1
View File
@@ -620,4 +620,11 @@ def main() -> None:
if __name__ == "__main__":
main()
if sys.argv[1:2] == ["--fetch"]:
# The agent's way to a service's weights: fetched as node code fetches
# an artifact, with the same cache and credentials, and the path printed.
if not _valid_digest(sys.argv[2]):
raise SystemExit(f"not an artifact digest: {sys.argv[2]}")
sys.stdout.write(_fetch(sys.argv[2]) + "\n")
else:
main()