Files
app/backend/tests/flow/test_remote.py
T
stroblmeandClaude Fable 5 eb2d098d7c Remote workers: a GPU box dials in and runs the nodes bound to it
The engine runs where the automations are and the GPU is somewhere else,
usually behind a different network — so the worker connects out and the engine
answers over the socket it was given. Nothing has to expose Redis, and the
same connection works through the tunnel the hosted access will use.

What travels is the protocol the local pool already speaks, so a node cannot
tell which kind of worker it is on. A node declares device: gpu and
device_policy, the label is resolved per call (a worker attaching later needs
no rebuild), and a run whose labels nothing carries waits in the queue saying
what it waits for rather than failing — submit from the couch, the GPU box
picks it up when it is switched on.

Two things had to move with it. Compiling now happens on the machine that will
run the node: a node importing torch is correct on the GPU box and a missing
module on the engine, so checking it here failed nodes that were fine. And the
artifact endpoint accepts a worker's own credential, because storing a
checkpoint is exactly what that credential is for — and only that.

Verified against the real split: the training ran on this host (its checkpoint
names the machine and a numpy the engine does not have), streamed 40 metric
points back mid-run, and the evaluate node read the checkpoint on the engine.
Cancel kills the remote training; pulling the worker fails the run in six
seconds instead of waiting out its ten-minute timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
2026-08-18 17:52:42 +02:00

226 lines
6.8 KiB
Python

"""A worker on another host, and the thread-to-loop bridge that reaches it.
The socket lives on an event loop; a node executes on a worker thread. These
run a real loop in a thread of its own, because that split is the whole
difficulty: what is checked is that a call handed across it comes back — with
its answer, with the reports it made on the way, or with a failure that says
what happened rather than a wait that never ends.
"""
import asyncio
import threading
from collections.abc import Iterator
import pytest
from app.flow.remote import NoWorker, RemoteWorker, RemoteWorkerHub
from app.flow.workers import NodeTimeout, RemoteError
@pytest.fixture
def loop() -> Iterator[asyncio.AbstractEventLoop]:
"""An event loop running in a thread, as the server's does."""
running = asyncio.new_event_loop()
thread = threading.Thread(target=running.run_forever, daemon=True)
thread.start()
yield running
running.call_soon_threadsafe(running.stop)
thread.join(timeout=5)
running.close()
class FakeSocket:
"""Stands in for the websocket: records frames, and says when one lands."""
def __init__(self) -> None:
self.sent: list[dict] = []
self.arrived = threading.Event()
async def send_json(self, payload: dict) -> None:
self.sent.append(payload)
self.arrived.set()
def attach(hub: RemoteWorkerHub, loop: asyncio.AbstractEventLoop, name: str = "gpu1"):
socket = FakeSocket()
worker = RemoteWorker(
name=name, labels=["gpu"], send=socket.send_json, loop=loop, max_parallel=2
)
hub.attach(worker)
return worker, socket
def call_in_thread(target) -> threading.Thread:
thread = threading.Thread(target=target, daemon=True)
thread.start()
return thread
def test_a_call_crosses_to_the_thread_and_the_answer_comes_back(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
result: dict = {}
thread = call_in_thread(
lambda: result.update(
value=hub.run(
"gpu", "flow", "node", "src", {"x": 1}, {}, "flow.node", timeout=5
)
)
)
assert socket.arrived.wait(5)
assert socket.sent[0]["source"] == "src"
assert socket.sent[0]["kwargs"] == {"x": 1}
worker.deliver(
{"call_id": socket.sent[0]["call_id"], "ok": True, "result": {"out": 2}}
)
thread.join(timeout=5)
assert result["value"] == {"out": 2}
def test_reports_arrive_before_the_answer_and_a_heartbeat_is_not_one(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
seen: list[dict] = []
result: dict = {}
thread = call_in_thread(
lambda: result.update(
value=hub.run(
"gpu",
"flow",
"node",
"src",
{},
{},
"flow.node",
timeout=5,
run_id="r1",
on_event=seen.append,
)
)
)
assert socket.arrived.wait(5)
call_id = socket.sent[0]["call_id"]
# The call names its run, which is how a metric finds the run that made it.
assert call_id == "r1:flow.node"
worker.deliver(
{"call_id": call_id, "event": "metric", "name": "loss", "value": 1.0}
)
worker.deliver({"call_id": call_id, "event": "heartbeat"})
worker.deliver({"call_id": call_id, "ok": True, "result": {"done": True}})
thread.join(timeout=5)
assert result["value"] == {"done": True}
# Liveness is not a measurement; only the metric is kept.
assert [event["event"] for event in seen] == ["metric"]
def test_a_failure_keeps_its_class_across_the_socket(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
caught: list[Exception] = []
def call() -> None:
try:
hub.run("gpu", "flow", "node", "src", {}, {}, "flow.node", timeout=5)
except Exception as exc:
caught.append(exc)
thread = call_in_thread(call)
assert socket.arrived.wait(5)
worker.deliver(
{
"call_id": socket.sent[0]["call_id"],
"ok": False,
"error": {"type": "ValueError", "message": "bad input", "traceback": "tb"},
}
)
thread.join(timeout=5)
assert type(caught[0]).__name__ == "ValueError"
assert str(caught[0]) == "bad input"
def test_a_worker_that_goes_away_fails_the_call_rather_than_hanging(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
caught: list[Exception] = []
def call() -> None:
try:
hub.run("gpu", "flow", "node", "src", {}, {}, "flow.node", timeout=30)
except Exception as exc:
caught.append(exc)
thread = call_in_thread(call)
assert socket.arrived.wait(5)
# Pulling the cable mid-training: the node fails, and does not wait out its
# thirty-second deadline to do it.
worker.detach()
thread.join(timeout=5)
assert not thread.is_alive()
assert isinstance(caught[0], RemoteError)
assert "went away" in str(caught[0])
def test_silence_past_the_deadline_is_a_timeout(loop):
hub = RemoteWorkerHub()
attach(hub, loop)
caught: list[Exception] = []
def call() -> None:
try:
hub.run("gpu", "flow", "node", "src", {}, {}, "flow.node", timeout=0.3)
except Exception as exc:
caught.append(exc)
call_in_thread(call).join(timeout=5)
assert isinstance(caught[0], NodeTimeout)
def test_a_label_nothing_carries_is_named_rather_than_waited_on(loop):
hub = RemoteWorkerHub()
attach(hub, loop)
with pytest.raises(NoWorker, match="tpu"):
hub.run("tpu", "flow", "node", "src", {}, {}, "flow.node", timeout=5)
# Compiling against a machine that is not attached is not a broken node —
# a node importing torch is correct there and missing here.
assert hub.compile("tpu", "flow", "node", "src") is None
def test_reattaching_replaces_the_old_socket(loop):
hub = RemoteWorkerHub()
first, _ = attach(hub, loop)
second, _ = attach(hub, loop)
assert first.gone
assert hub.pick("gpu") is second
assert [worker.name for worker in hub.workers()] == ["gpu1"]
def test_cancelling_a_run_reaches_only_that_run(loop):
hub = RemoteWorkerHub()
worker, socket = attach(hub, loop)
def call(run_id: str) -> None:
try:
hub.run(
"gpu", "flow", "node", "src", {}, {}, "flow.node", 30, run_id=run_id
)
except Exception:
pass
call_in_thread(lambda: call("run-a"))
assert socket.arrived.wait(5)
socket.arrived.clear()
call_in_thread(lambda: call("run-b"))
assert socket.arrived.wait(5)
assert hub.cancel_run("run-a") == 1
cancels = [frame for frame in socket.sent if frame.get("op") == "cancel"]
assert [frame["call_id"] for frame in cancels] == ["run-a:flow.node"]