Let a worker say what machine it is
A worker reported its labels and nothing about the machine behind them, so the engine could route a node to a GPU box but not tell whether that box had a GPU free. Inventory — cores, GPUs, memory — now arrives with the hello frame, and the run frame carries back what the engine allocated for that call. Which is protocol 2 on both ends. GPUs are never probed: asking a vendor tool would make the one dependency two, so a GPU is what the batch job says it was given or what --gpus says. A worker that reports nothing still attaches and is scheduled by its label alone. Two things a job scheduler needs: --max-idle stops a worker started for one job rather than letting it hold its allocation to the walltime, and a refusal is now fatal instead of a reconnect loop that reads as a hang in a job's log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
This commit is contained in:
@@ -21,7 +21,12 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from fluksio.api.deps import get_current_active_superuser, get_current_user
|
||||
from fluksio.core import security
|
||||
from fluksio.flow.remote import PROTOCOL, RemoteWorker, RemoteWorkerHub
|
||||
from fluksio.flow.remote import (
|
||||
PROTOCOL,
|
||||
RemoteWorker,
|
||||
RemoteWorkerHub,
|
||||
WorkerInventory,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,6 +45,9 @@ class WorkerInfo(BaseModel):
|
||||
last_seen: float = 0.0
|
||||
python: str = ""
|
||||
venv_digest: str = ""
|
||||
cpus: int = 1
|
||||
gpus: int = 0
|
||||
ram_mb: int | None = None
|
||||
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
@@ -74,6 +82,9 @@ def read_workers(request: Request) -> Any:
|
||||
last_seen=worker.last_seen,
|
||||
python=str(worker.info.get("python") or ""),
|
||||
venv_digest=str(worker.info.get("venv_digest") or ""),
|
||||
cpus=worker.inventory.cpus,
|
||||
gpus=worker.inventory.gpus,
|
||||
ram_mb=worker.inventory.ram_mb,
|
||||
)
|
||||
for worker in _hub(request.app).workers()
|
||||
]
|
||||
@@ -163,6 +174,7 @@ async def attach(websocket: WebSocket, token: str = "") -> None:
|
||||
"python": hello.get("python"),
|
||||
"venv_digest": hello.get("venv_digest"),
|
||||
},
|
||||
inventory=WorkerInventory.from_hello(hello.get("inventory")),
|
||||
)
|
||||
hub.attach(worker)
|
||||
await websocket.send_json({"op": "welcome", "protocol": PROTOCOL, "name": name})
|
||||
|
||||
@@ -28,6 +28,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fluksio.flow.nodes.base import NodeOutputError
|
||||
@@ -44,13 +45,50 @@ SEND_TIMEOUT_S = 30.0
|
||||
SILENCE_S = 90.0
|
||||
#: Protocol version this engine speaks. A worker announcing anything else is
|
||||
#: refused rather than half-understood.
|
||||
PROTOCOL = 1
|
||||
PROTOCOL = 2
|
||||
|
||||
|
||||
class NoWorker(RemoteError):
|
||||
"""Nothing is attached that carries the label this node asked for."""
|
||||
|
||||
|
||||
def _whole(value: Any, default: int) -> int:
|
||||
"""An int from whatever a worker sent, or the default when it was junk."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerInventory:
|
||||
"""What an attached worker says it holds.
|
||||
|
||||
The defaults are a machine that runs one thing at a time and has no GPU,
|
||||
so a worker reporting nothing is scheduled by its label alone — which is
|
||||
how every worker was scheduled before any of them reported anything.
|
||||
"""
|
||||
|
||||
cpus: int = 1
|
||||
gpus: int = 0
|
||||
ram_mb: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_hello(cls, payload: Any) -> WorkerInventory:
|
||||
"""Read what is understood and ignore the rest.
|
||||
|
||||
Unknown members are deliberately not an error: a worker that learns to
|
||||
report something new must not need this engine taught about it first.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
cpus=max(1, _whole(payload.get("cpus"), 1)),
|
||||
gpus=max(0, _whole(payload.get("gpus"), 0)),
|
||||
ram_mb=_whole(payload.get("ram_mb"), 0) or None,
|
||||
)
|
||||
|
||||
|
||||
class RemoteWorker:
|
||||
"""One attached worker, and the calls it has in flight."""
|
||||
|
||||
@@ -62,10 +100,12 @@ class RemoteWorker:
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
max_parallel: int = 1,
|
||||
info: dict[str, Any] | None = None,
|
||||
inventory: WorkerInventory | None = None,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.labels = set(labels)
|
||||
self.info = info or {}
|
||||
self.inventory = inventory or WorkerInventory()
|
||||
self.attached_at = time.time()
|
||||
self.last_seen = time.time()
|
||||
self._send = send
|
||||
@@ -281,12 +321,12 @@ class RemoteWorkerHub:
|
||||
timeout: float,
|
||||
run_id: str = "",
|
||||
on_event: Callable[[dict[str, Any]], None] | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
worker = self.pick(label)
|
||||
if worker is None:
|
||||
raise NoWorker(f"no worker labelled '{label}' is attached")
|
||||
response = worker.request(
|
||||
{
|
||||
payload = {
|
||||
"op": "run",
|
||||
"call_id": f"{run_id}:{node_id}" if run_id else node_id,
|
||||
"flow": flow,
|
||||
@@ -295,7 +335,14 @@ class RemoteWorkerHub:
|
||||
"kwargs": kwargs,
|
||||
"run": {"id": run_id} if run_id else None,
|
||||
"timeout": timeout,
|
||||
},
|
||||
}
|
||||
if env:
|
||||
# What this call was allocated: the thread caps and the devices the
|
||||
# node may see. The worker starts a process per call, so it applies
|
||||
# them where a library still reads them — before the import.
|
||||
payload["env"] = env
|
||||
response = worker.request(
|
||||
payload,
|
||||
timeout=timeout,
|
||||
on_event=on_event,
|
||||
)
|
||||
@@ -357,6 +404,7 @@ class RemoteWorkerHub:
|
||||
run_id: str = "",
|
||||
on_event: Callable[[dict[str, Any]], None] | None = None,
|
||||
fallback: Callable[..., Any] | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> Callable[..., Any]:
|
||||
"""What a node with a device runs instead of its own function.
|
||||
|
||||
@@ -377,6 +425,7 @@ class RemoteWorkerHub:
|
||||
timeout,
|
||||
run_id=run_id,
|
||||
on_event=on_event,
|
||||
env=env,
|
||||
)
|
||||
|
||||
return call
|
||||
|
||||
@@ -15,7 +15,12 @@ from collections.abc import Iterator
|
||||
import pytest
|
||||
|
||||
from fluksio.flow import remote
|
||||
from fluksio.flow.remote import NoWorker, RemoteWorker, RemoteWorkerHub
|
||||
from fluksio.flow.remote import (
|
||||
NoWorker,
|
||||
RemoteWorker,
|
||||
RemoteWorkerHub,
|
||||
WorkerInventory,
|
||||
)
|
||||
from fluksio.flow.workers import NodeTimeout, RemoteError
|
||||
|
||||
|
||||
@@ -295,3 +300,67 @@ def test_with_no_timeout_a_beating_worker_is_left_to_finish(loop, monkeypatch):
|
||||
|
||||
thread.join(timeout=5)
|
||||
assert result["value"] == {"done": True}
|
||||
|
||||
|
||||
def test_an_allocation_rides_along_with_the_call(loop):
|
||||
"""Thread caps and devices reach the worker on the frame that needs them.
|
||||
|
||||
The worker starts a process per call, so this is the only moment it can
|
||||
apply them: a library reads them when it is imported and never again.
|
||||
"""
|
||||
hub = RemoteWorkerHub()
|
||||
worker, socket = attach(hub, loop)
|
||||
|
||||
thread = call_in_thread(
|
||||
lambda: hub.run(
|
||||
"gpu",
|
||||
"flow",
|
||||
"node",
|
||||
"src",
|
||||
{},
|
||||
"flow.node",
|
||||
timeout=5,
|
||||
env={"OMP_NUM_THREADS": "4", "CUDA_VISIBLE_DEVICES": "0"},
|
||||
)
|
||||
)
|
||||
assert socket.arrived.wait(5)
|
||||
assert socket.sent[0]["env"] == {
|
||||
"OMP_NUM_THREADS": "4",
|
||||
"CUDA_VISIBLE_DEVICES": "0",
|
||||
}
|
||||
|
||||
worker.deliver({"call_id": socket.sent[0]["call_id"], "ok": True, "result": None})
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_a_call_with_nothing_allocated_carries_no_env(loop):
|
||||
hub = RemoteWorkerHub()
|
||||
worker, socket = attach(hub, loop)
|
||||
|
||||
thread = call_in_thread(
|
||||
lambda: hub.run("gpu", "flow", "node", "src", {}, "flow.node", timeout=5)
|
||||
)
|
||||
assert socket.arrived.wait(5)
|
||||
assert "env" not in socket.sent[0]
|
||||
|
||||
worker.deliver({"call_id": socket.sent[0]["call_id"], "ok": True, "result": None})
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_a_worker_that_reports_nothing_is_the_machine_it_used_to_be():
|
||||
"""Inventory is read leniently: absent is a default, junk is a default.
|
||||
|
||||
A worker saying nothing has to keep being scheduled by its label, and one
|
||||
that learns to report something new must not need this engine taught about
|
||||
it first.
|
||||
"""
|
||||
assert WorkerInventory.from_hello({}) == WorkerInventory(cpus=1, gpus=0)
|
||||
assert WorkerInventory.from_hello(None) == WorkerInventory()
|
||||
|
||||
read = WorkerInventory.from_hello(
|
||||
{"cpus": "8", "gpus": 2, "ram_mb": 64000, "tpus": 4}
|
||||
)
|
||||
assert (read.cpus, read.gpus, read.ram_mb) == (8, 2, 64000)
|
||||
|
||||
junk = WorkerInventory.from_hello({"cpus": "many", "gpus": None, "ram_mb": 0})
|
||||
assert (junk.cpus, junk.gpus, junk.ram_mb) == (1, 0, None)
|
||||
|
||||
+38
-2
@@ -35,11 +35,37 @@ driver in order to run a training step. An engine host already has it, and
|
||||
| `--python` | this interpreter | the interpreter node code runs on |
|
||||
| `--parallel` | `1` | how many node calls it will take at once |
|
||||
| `--artifact-url` | derived from `--url` | where the artifact store is, if not beside the socket |
|
||||
| `--cpus` | what the job or the machine has | cores to advertise |
|
||||
| `--gpus` | what the job says, else none | GPUs to advertise; never probed |
|
||||
| `--ram-mb` | what the job or the machine has | memory to advertise, in MB |
|
||||
| `--max-idle` | never | stop after this many seconds with nothing running |
|
||||
|
||||
`--python` is the important one. It is how this machine keeps its own wheels —
|
||||
the CUDA build, the vendor SDK, the thing that will not install anywhere else —
|
||||
without the engine ever installing them or knowing about them.
|
||||
|
||||
### What it says it has
|
||||
|
||||
A worker reports its inventory when it attaches — cores, GPUs and memory — and
|
||||
the engine schedules against it: a node asking for two cores and a GPU goes to
|
||||
a machine that has them free, not merely to one carrying the right label.
|
||||
|
||||
Cores and memory are read off the machine, or off the batch job that started
|
||||
this worker (`SLURM_CPUS_ON_NODE`, `SLURM_MEM_PER_NODE`). **GPUs are never
|
||||
probed.** Asking a vendor tool would make the one dependency two, so a GPU is
|
||||
something the job says it was given (`SLURM_GPUS_ON_NODE`, `SLURM_JOB_GPUS`,
|
||||
or `FLUKSIO_WORKER_GPUS`) or something you say with `--gpus`. A worker that
|
||||
reports nothing still attaches and is scheduled by its label alone, as every
|
||||
worker was before any of them reported anything.
|
||||
|
||||
The engine tells each call what it may use — thread caps, and the devices it
|
||||
may see. The worker starts a process per call, so it applies them at the only
|
||||
moment a numerical library still reads them: before the import.
|
||||
|
||||
`--max-idle` is for a worker something else started for one job — a batch
|
||||
scheduler, say. It exits when nothing has run for that long, so the allocation
|
||||
goes back rather than idling until its walltime.
|
||||
|
||||
## Mint the token
|
||||
|
||||
On the engine, as a superuser:
|
||||
@@ -122,8 +148,18 @@ curl -s $FLUKSIO/workers -H "Authorization: Bearer $TOKEN" | jq
|
||||
```
|
||||
|
||||
Name, labels, how many calls it will take at once, how many are in flight, when
|
||||
it attached, when it was last seen, its Python version, and a digest of its
|
||||
environment.
|
||||
it attached, when it was last seen, its Python version, a digest of its
|
||||
environment, and what it says it has: cores, GPUs and memory.
|
||||
|
||||
## Upgrading
|
||||
|
||||
The engine and the worker speak a version-matched protocol, and a worker
|
||||
announcing anything else is refused rather than half-understood. Protocol 2 —
|
||||
the one that carries inventory — is `fluksio-worker` 0.2.0. An older agent is
|
||||
told so on the socket and stops, rather than retrying against an engine that
|
||||
will never accept it; `pip install -U fluksio-worker` on that host is the whole
|
||||
upgrade. Nothing changed in the runner served at `GET /api/v1/workers/runtime`,
|
||||
so a host that copies its two files copies the same one as before.
|
||||
|
||||
## What a worker is not
|
||||
|
||||
|
||||
@@ -948,7 +948,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "fluksio-worker"
|
||||
version = "0.1.4"
|
||||
version = "0.2.0"
|
||||
source = { editable = "worker" }
|
||||
dependencies = [
|
||||
{ name = "websockets" },
|
||||
|
||||
+167
-10
@@ -34,6 +34,7 @@ import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -47,13 +48,21 @@ except ImportError: # pragma: no cover - the one dependency, named plainly
|
||||
|
||||
log = logging.getLogger("fluksio-worker")
|
||||
|
||||
PROTOCOL = 1
|
||||
PROTOCOL = 2
|
||||
#: Sent while a call is running, so the engine can tell working from wedged.
|
||||
HEARTBEAT_S = 10.0
|
||||
#: Reconnection backs off to this and no further.
|
||||
MAX_BACKOFF_S = 30.0
|
||||
|
||||
|
||||
class Refused(RuntimeError):
|
||||
"""The engine will not have this worker, and retrying will not change it."""
|
||||
|
||||
|
||||
class Idle(Exception):
|
||||
"""Nothing has run here for long enough that this worker is done."""
|
||||
|
||||
|
||||
#: The user-code runner, beside this file — installed together, or copied
|
||||
#: together onto a host where pip is not an option.
|
||||
WORKER_MAIN = Path(__file__).resolve().with_name("worker_main.py")
|
||||
@@ -121,6 +130,8 @@ class Agent:
|
||||
)
|
||||
self.env["FLUKSIO_ARTIFACT_TOKEN"] = args.token
|
||||
self.running: dict[str, Subprocess] = {}
|
||||
self.last_done = time.monotonic()
|
||||
self._idled = False
|
||||
|
||||
async def serve_forever(self) -> None:
|
||||
backoff = 1.0
|
||||
@@ -128,6 +139,11 @@ class Agent:
|
||||
try:
|
||||
await self._session()
|
||||
backoff = 1.0
|
||||
except Idle:
|
||||
log.info("nothing to do for %.0fs — stopping", self.args.max_idle)
|
||||
return
|
||||
except Refused:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.warning("disconnected: %s — retrying in %.0fs", exc, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
@@ -146,12 +162,15 @@ class Agent:
|
||||
"python": self.args.python,
|
||||
"max_parallel": self.args.parallel,
|
||||
"venv_digest": _venv_digest(self.args.python),
|
||||
"inventory": _inventory(self.args),
|
||||
}
|
||||
)
|
||||
)
|
||||
welcome = json.loads(await socket.recv())
|
||||
if welcome.get("op") != "welcome":
|
||||
raise RuntimeError(str(welcome.get("reason") or "refused"))
|
||||
# Nothing about this machine will change the answer, so
|
||||
# reconnecting would only be a quiet loop in the job's log.
|
||||
raise Refused(str(welcome.get("reason") or "refused"))
|
||||
log.info(
|
||||
"attached to %s as '%s' with labels %s",
|
||||
self.args.url,
|
||||
@@ -159,28 +178,73 @@ class Agent:
|
||||
self.args.labels,
|
||||
)
|
||||
|
||||
self.last_done = time.monotonic()
|
||||
watchdog = (
|
||||
asyncio.create_task(self._idle_watch(socket))
|
||||
if self.args.max_idle > 0
|
||||
else None
|
||||
)
|
||||
try:
|
||||
async for raw in socket:
|
||||
message = json.loads(raw)
|
||||
op = message.get("op")
|
||||
if op in ("run", "compile"):
|
||||
# Compiling is loading the source, which is the same trip
|
||||
# through a subprocess a call is — and has to happen here
|
||||
# rather than on the engine, because "does this import"
|
||||
# is a question about *this* machine's packages.
|
||||
# Compiling is loading the source, which is the same
|
||||
# trip through a subprocess a call is — and has to
|
||||
# happen here rather than on the engine, because "does
|
||||
# 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.
|
||||
# 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)
|
||||
elif op == "cancel":
|
||||
self._cancel(str(message.get("call_id") or ""))
|
||||
except Exception:
|
||||
# The watchdog closing the socket is a stop, not a disconnect.
|
||||
if not self._idled:
|
||||
raise
|
||||
finally:
|
||||
if watchdog is not None:
|
||||
watchdog.cancel()
|
||||
if self._idled:
|
||||
raise Idle
|
||||
|
||||
async def _idle_watch(self, socket: Any) -> None:
|
||||
"""Close the socket once nothing has run here for ``--max-idle``.
|
||||
|
||||
A worker a batch scheduler started for one node has no other way to
|
||||
know it is finished, and a job that idles until its walltime is a job
|
||||
somebody else was queued behind.
|
||||
"""
|
||||
tick = min(30.0, max(1.0, self.args.max_idle / 4))
|
||||
while True:
|
||||
await asyncio.sleep(tick)
|
||||
if self.running:
|
||||
continue
|
||||
if time.monotonic() - self.last_done >= self.args.max_idle:
|
||||
self._idled = True
|
||||
with contextlib.suppress(Exception):
|
||||
await socket.close()
|
||||
return
|
||||
|
||||
async def _run(self, socket: Any, request: dict[str, Any]) -> None:
|
||||
"""Execute one call in a subprocess, streaming what it says back."""
|
||||
call_id = str(request.get("call_id") or "")
|
||||
loop = asyncio.get_running_loop()
|
||||
worker = Subprocess(self.args.python, self.env)
|
||||
# What the engine allocated this call: thread caps and the GPUs it may
|
||||
# see. Taken off the request because a library reads those once, when
|
||||
# it is imported — which for this agent is a process that does not
|
||||
# exist yet, so there is nothing to reconfigure and no pool to key.
|
||||
extra = {str(k): str(v) for k, v in (request.pop("env", None) or {}).items()}
|
||||
mask = self.env.get("CUDA_VISIBLE_DEVICES")
|
||||
if mask and extra.get("CUDA_VISIBLE_DEVICES"):
|
||||
extra["CUDA_VISIBLE_DEVICES"] = _remap_cuda(
|
||||
mask, extra["CUDA_VISIBLE_DEVICES"]
|
||||
)
|
||||
worker = Subprocess(self.args.python, {**self.env, **extra})
|
||||
self.running[call_id] = worker
|
||||
|
||||
async def beat() -> None:
|
||||
@@ -221,6 +285,7 @@ class Agent:
|
||||
finally:
|
||||
heartbeat.cancel()
|
||||
self.running.pop(call_id, None)
|
||||
self.last_done = time.monotonic()
|
||||
worker.kill()
|
||||
|
||||
def _cancel(self, call_id: str) -> None:
|
||||
@@ -241,6 +306,68 @@ def _artifacts_from(url: str) -> str:
|
||||
return base.rsplit("/workers/attach", 1)[0] + "/artifacts"
|
||||
|
||||
|
||||
def _detect_cpus() -> int:
|
||||
"""Cores this worker may use — what the batch job was given, or the box."""
|
||||
given = os.environ.get("SLURM_CPUS_ON_NODE", "")
|
||||
return int(given) if given.isdigit() else (os.cpu_count() or 1)
|
||||
|
||||
|
||||
def _detect_ram_mb() -> int | None:
|
||||
"""Memory in MB, or None where it cannot be asked — which is allowed."""
|
||||
given = os.environ.get("SLURM_MEM_PER_NODE", "")
|
||||
if given.isdigit():
|
||||
return int(given)
|
||||
try:
|
||||
return os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE") // 2**20
|
||||
except (AttributeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _detect_gpus() -> int:
|
||||
"""What this worker was *given*, never what the machine has.
|
||||
|
||||
Nothing is probed: asking a vendor tool would make the one dependency two,
|
||||
and the engine does not probe its own GPUs either. A batch scheduler says
|
||||
so in the environment; anywhere else it is ``--gpus``.
|
||||
"""
|
||||
for name in ("SLURM_GPUS_ON_NODE", "FLUKSIO_WORKER_GPUS"):
|
||||
given = os.environ.get(name, "")
|
||||
if given.isdigit():
|
||||
return int(given)
|
||||
listed = os.environ.get("SLURM_JOB_GPUS", "")
|
||||
return len([part for part in listed.split(",") if part.strip()])
|
||||
|
||||
|
||||
def _inventory(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""What this machine holds, for the engine to schedule against."""
|
||||
return {
|
||||
"cpus": args.cpus or _detect_cpus(),
|
||||
"gpus": _detect_gpus() if args.gpus is None else args.gpus,
|
||||
"ram_mb": args.ram_mb or _detect_ram_mb(),
|
||||
}
|
||||
|
||||
|
||||
def _remap_cuda(mask: str, requested: str) -> str:
|
||||
"""Turn engine-assigned device numbers into the ones this job may see.
|
||||
|
||||
The engine counts GPUs from the inventory reported here, so it asks for
|
||||
device 0. Under a scheduler's gres this agent already runs with something
|
||||
like ``CUDA_VISIBLE_DEVICES=2,3``, where 0 is a device the job was never
|
||||
given — so each index is read as a position in that mask.
|
||||
"""
|
||||
devices = [part.strip() for part in mask.split(",") if part.strip()]
|
||||
out = []
|
||||
for part in requested.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
out.append(devices[int(part)])
|
||||
except (ValueError, IndexError):
|
||||
out.append(part)
|
||||
return ",".join(out)
|
||||
|
||||
|
||||
def _venv_digest(python: str) -> str:
|
||||
"""What is installed here, so the engine can say when it has drifted."""
|
||||
try:
|
||||
@@ -285,6 +412,33 @@ 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(
|
||||
"--cpus",
|
||||
type=int,
|
||||
default=0,
|
||||
help="cores to advertise (default: what the job or the machine has)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gpus",
|
||||
type=int,
|
||||
default=None,
|
||||
help="GPUs to advertise — nothing is probed, so say so here or in the job",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ram-mb",
|
||||
type=int,
|
||||
default=0,
|
||||
help="memory in MB to advertise (default: what the job or the machine has)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-idle",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help=(
|
||||
"stop after this many seconds with nothing running — for a worker "
|
||||
"a batch scheduler started for one job (default: never)"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.token:
|
||||
@@ -298,6 +452,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
asyncio.run(agent.serve_forever())
|
||||
except KeyboardInterrupt:
|
||||
log.info("stopping")
|
||||
except Refused as exc:
|
||||
log.error("the engine refused this worker: %s", exc)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fluksio-worker"
|
||||
version = "0.1.4"
|
||||
version = "0.2.0"
|
||||
description = "Runs Fluksio nodes on a machine the engine cannot reach"
|
||||
readme = "README.md"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
Reference in New Issue
Block a user