Name the sizes a node can ask for

Raw cpus and gpus are a property of the machines an installation has, so a node
written against a cluster quietly stops meaning anything when the cluster is
replaced. A node says "gpu-small" instead, and what that is stored here —
editable, and read again every time the node is built, so changing the flavor
changes what the next run gets.

Memory joins the schema properly (`ram`, in MB, accepting "2G"), along with
`duration_s` for how long a node is expected to take. That one is recorded and
shown and nothing else yet: a statement for whoever is planning around the node,
not a limit — the limit is still `timeout`.

A flavor and a number for the same thing is refused, compared by value so an
editor writing the whole object back with its defaults still round-trips. A name
nothing stores is refused at the save, which covers the canvas and `fluksio
sync` at once, and deleting one a node still asks for says which node.

Four sizes are seeded on an installation that has none, and never re-seeded:
re-adding one somebody deliberately removed is an argument nobody wins.

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:59:10 +02:00
co-authored by Claude Opus 5
parent 6ff56533f5
commit a82f88cf0a
15 changed files with 720 additions and 19 deletions
+23 -7
View File
@@ -64,7 +64,12 @@ from fluksio.flow.pipeline import (
)
from fluksio.flow.placement import Placer
from fluksio.flow.remote import RemoteWorkerHub
from fluksio.flow.resources import ResourceAccountant, derive_env
from fluksio.flow.resources import (
ResourceAccountant,
UnknownFlavor,
derive_env,
resolve_flavor,
)
from fluksio.flow.schemas import (
BrainEdge,
BrainGraph,
@@ -869,11 +874,10 @@ class FlowController:
# Building
# -------------------------------------------------------------------------
def _runs_elsewhere(self, node_def: NodeDef) -> bool:
def _runs_elsewhere(self, wanted: Resources | None) -> bool:
"""Whether this node asks for more than this machine could ever give."""
if node_def.resources is None or self.placer is None:
if wanted is None or self.placer is None:
return False
wanted = node_def.resources
return not self.placer.local.fits(wanted.cpus, wanted.gpus, wanted.ram or 0)
def _placed(
@@ -1016,6 +1020,18 @@ class FlowController:
# one importing torch is correct on the GPU box and a
# missing module here, so checking it here would fail a
# node that is fine.
try:
# A named size is read now rather than stored on the
# node, so editing the flavor edits the next run.
wanted = (
resolve_flavor(node_def.resources)
if node_def.resources is not None
else None
)
except UnknownFlavor as exc:
entry.status = NodeStatus.ERROR
entry.error = str(exc)
return entry
remote_only = (
node_def.device
and node_def.device_policy == "require"
@@ -1025,7 +1041,7 @@ class FlowController:
problem = self.remote.compile(
node_def.device or "", owner, local, code
)
elif self._runs_elsewhere(node_def):
elif self._runs_elsewhere(wanted):
# Asks for more than this machine has, so it will run on
# one that has it. Same reason as a device: checking the
# import here would fail a node that is fine there.
@@ -1043,13 +1059,13 @@ class FlowController:
if node_def.timeout is not None
else settings.FLOW_NODE_TIMEOUT
)
if node_def.resources is not None and self.placer is not None:
if wanted is not None and self.placer is not None:
# Says how much of a machine it takes, so which machine
# and how much of it are one decision — including when
# it also names a device, which used to mean the two
# answers disagreed and nothing was accounted at all.
function = self._placed(
node_def.resources,
wanted,
node_def.device,
node_def.device_policy,
owner,
+43
View File
@@ -26,14 +26,21 @@ from __future__ import annotations
import logging
import os
import re
import threading
from collections.abc import Callable
from dataclasses import dataclass
from sqlmodel import Session
from fluksio.flow.schemas import Resources
logger = logging.getLogger(__name__)
#: What a flavor may be called. Dashes allowed, unlike a flow or node name —
#: "gpu-small" reads better than "gpu_small" on a dropdown.
FLAVOR_NAME = re.compile(r"^[a-z][a-z0-9_-]*$")
#: Every spelling of "how many threads may you use" that a scientific stack
#: reads out of the environment at import. Set together, because a process
#: usually pulls in more than one of them.
@@ -175,6 +182,42 @@ def derive_env(wanted: Resources, allocation: Allocation) -> dict[str, str]:
return env
class UnknownFlavor(ValueError):
"""A node asks for a size that is not stored here."""
def resolve_flavor(wanted: Resources) -> Resources:
"""The concrete numbers behind a declaration.
Read when the node is built rather than stored on it, so editing a flavor
changes what the next run gets. A node that names one that has been deleted
is an error rather than a default: running a training step against a size
nobody chose is worse than a node that says what is wrong with it.
"""
if not wanted.flavor:
return wanted
# Imported here: this module is the books, and the books have no business
# knowing about the database until somebody asks for a stored size.
from fluksio.core.db import engine
from fluksio.models import Flavor
with Session(engine) as session:
row = session.get(Flavor, wanted.flavor)
if row is None:
raise UnknownFlavor(
f"flavor '{wanted.flavor}' does not exist — `fluksio flavors` lists them"
)
# Built fresh rather than copied, so the flavor-and-numbers check runs on
# the result and this cannot quietly produce something invalid.
return Resources(
cpus=row.cpus,
gpus=row.gpus,
ram=row.ram,
env=wanted.env,
duration_s=wanted.duration_s,
)
def fair_share_env(cpus: int, workers: int) -> dict[str, str]:
"""Thread caps for the shared pool, where nothing declared anything.
+68 -5
View File
@@ -9,12 +9,18 @@ from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from fluksio.flow.messages import MessageSpec
NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
#: Megabytes, gigabytes, or a bare number already in megabytes.
_SIZE = re.compile(r"^\s*(\d+)\s*([mg]b?)?\s*$", re.IGNORECASE)
#: Seconds, minutes, hours or days — one unit, so there is one thing to read.
_SPAN = re.compile(r"^\s*(\d+)\s*([smhd])?\s*$", re.IGNORECASE)
_SPAN_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
def _validate_name(value: str) -> str:
if not NAME_PATTERN.match(value):
@@ -24,6 +30,27 @@ def _validate_name(value: str) -> str:
return value
def _megabytes(value: Any) -> Any:
"""``2G`` and ``512M`` and ``512`` all mean a number of megabytes."""
if not isinstance(value, str):
return value
match = _SIZE.match(value)
if not match:
raise ValueError(f"'{value}' is not a size — write 512M, 2G, or 512")
amount, unit = int(match.group(1)), (match.group(2) or "m").lower()
return amount * 1024 if unit.startswith("g") else amount
def _seconds(value: Any) -> Any:
"""``2h``, ``30m``, ``90s`` and ``90`` all mean a number of seconds."""
if not isinstance(value, str):
return value
match = _SPAN.match(value)
if not match:
raise ValueError(f"'{value}' is not a duration — write 90s, 30m, 2h, or 90")
return int(match.group(1)) * _SPAN_SECONDS[(match.group(2) or "s").lower()]
class Resources(BaseModel):
"""What one execution of a node needs to have to itself.
@@ -64,10 +91,27 @@ class Resources(BaseModel):
default=None,
ge=1,
description=(
"Megabytes held for the whole execution. Counted against machines "
"that said how much they have, and ignored by those that did not — "
"which is a machine with nothing to say about memory, not one with "
"none."
"Megabytes held for the whole execution; accepts '512M' or '2G'. "
"Counted against machines that said how much they have, and "
"ignored by those that did not — which is a machine with nothing "
"to say about memory, not one with none."
),
)
flavor: str | None = Field(
default=None,
description=(
"A stored size by name, standing in for cpus, gpus and ram. Read "
"again every time the node is built, so editing the flavor edits "
"what the next run gets."
),
)
duration_s: int | None = Field(
default=None,
ge=1,
description=(
"How long one execution is expected to take; accepts '30m' or "
"'2h'. A statement about the node for whoever is planning around "
"it, not a limit — the limit is `timeout`."
),
)
env: dict[str, str] = Field(
@@ -80,6 +124,25 @@ class Resources(BaseModel):
),
)
_parse_ram = field_validator("ram", mode="before")(_megabytes)
_parse_duration = field_validator("duration_s", mode="before")(_seconds)
@model_validator(mode="after")
def _a_flavor_says_it_all(self) -> Resources:
"""A flavor and a number for the same thing is two answers.
Compared by value rather than by what was set, because an editor that
writes the whole object back sends the defaults with it — and a node
that says `flavor` and `cpus: 1` has not actually asked for anything
the flavor does not already cover.
"""
if self.flavor and (self.cpus != 1 or self.gpus != 0 or self.ram is not None):
raise ValueError(
f"flavor '{self.flavor}' already says how much — "
"drop cpus, gpus and ram, or drop the flavor"
)
return self
class NodeDef(BaseModel):
"""A node as stored: identity, configuration and ports.