Schedule a node across every machine, not just this one

The engine answered "where does this node run" twice, in two ways that could
not see each other: a device sent it to a worker carrying that label, and
resources were counted against the engine's own cores. Declaring both meant the
second answer won and nothing was counted at all — which the data-science
getting-started page and the worked example both do.

One question now, in flow/placement.py: of every machine attached, which could
grant what this node asked for, and which of those has it free. The books move
onto each machine — one accountant per worker, built from the inventory it
reported — and the waiting moves above them, where one condition variable can
be woken by a release anywhere or by a worker attaching. Locks go one way:
placer, then a machine's books, never back.

So a node asking for a card now finds the box that has one, rather than being
clamped down to none and run here. When nothing can grant the ask at all it is
still cut down and run — a flow written on a cluster has to work on a laptop —
but the ceiling is one real machine now, since taking the largest of each
dimension separately can describe a machine nobody has.

Two things fixed on the way. A device on a connector node held every batch run
of its flow forever, waiting for a worker that could never run an entry point.
And `prefer` falling back to the engine skipped the books, so the fallback held
nothing.

The bench flow's node has taken a `params` argument that with_settings has not
forwarded for some time, so the benchmark could not run at all: 62 ms median
submit-to-result with this, against the 61 ms on record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
This commit is contained in:
2026-08-27 08:49:36 +02:00
co-authored by Claude Opus 5
parent 1a9753fa9d
commit 6ff56533f5
14 changed files with 1214 additions and 326 deletions
+52 -1
View File
@@ -32,6 +32,7 @@ from dataclasses import dataclass
from typing import Any
from fluksio.flow.nodes.base import NodeOutputError
from fluksio.flow.resources import ResourceAccountant
from fluksio.flow.workers import NodeTimeout, RemoteError, _remote_class
logger = logging.getLogger(__name__)
@@ -106,6 +107,14 @@ class RemoteWorker:
self.labels = set(labels)
self.info = info or {}
self.inventory = inventory or WorkerInventory()
# This machine's books, held here so that a worker reconnecting — which
# is a new object — starts from a clean set rather than from counters
# the engine kept for a socket that is gone.
self.accountant = ResourceAccountant(
cpus=self.inventory.cpus,
gpus=self.inventory.gpus,
ram_mb=self.inventory.ram_mb,
)
self.attached_at = time.time()
self.last_seen = time.time()
self._send = send
@@ -251,9 +260,13 @@ class RemoteWorker:
class RemoteWorkerHub:
"""Every attached worker, and which of them a node may run on."""
def __init__(self) -> None:
def __init__(self, on_change: Callable[[], None] | None = None) -> None:
self._workers: dict[str, RemoteWorker] = {}
self._lock = threading.Lock()
#: Told when the set of attached workers changes, so whoever is waiting
#: for a machine can look again. Called outside the lock: it takes one
#: of its own, and the two are only ever taken in that order.
self.on_change = on_change
# -------------------------------------------------------------------------
# Attachment
@@ -270,6 +283,7 @@ class RemoteWorkerHub:
logger.info(
"Worker '%s' attached with labels %s", worker.name, sorted(worker.labels)
)
self._changed()
def detach(self, name: str) -> None:
with self._lock:
@@ -277,6 +291,11 @@ class RemoteWorkerHub:
if worker is not None:
worker.detach()
logger.info("Worker '%s' detached", name)
self._changed()
def _changed(self) -> None:
if self.on_change is not None:
self.on_change()
def workers(self) -> list[RemoteWorker]:
with self._lock:
@@ -326,6 +345,38 @@ class RemoteWorkerHub:
worker = self.pick(label)
if worker is None:
raise NoWorker(f"no worker labelled '{label}' is attached")
return self.run_on(
worker,
flow,
node,
source,
kwargs,
node_id,
timeout,
run_id=run_id,
on_event=on_event,
env=env,
)
def run_on(
self,
worker: RemoteWorker,
flow: str,
node: str,
source: str,
kwargs: dict[str, Any],
node_id: str,
timeout: float,
run_id: str = "",
on_event: Callable[[dict[str, Any]], None] | None = None,
env: dict[str, str] | None = None,
) -> Any:
"""Run this node on the worker the caller has already settled on.
The placer holds that machine's cores for the length of the call, so
resolving the label a second time here could send the work somewhere
else and leave the claim on a machine doing nothing.
"""
payload = {
"op": "run",
"call_id": f"{run_id}:{node_id}" if run_id else node_id,