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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user