Stage caching for batch runs, and an engine that lives in the command
Docs / docs (push) Successful in 19s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m5s
Playwright Tests / test-playwright (2, 2) (push) Failing after 20s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Successful in 2m7s
Compose Smoke Test / test-compose (push) Failing after 20s
Playwright Tests / merge-reports (push) Failing after 1m3s
Publish / publish (push) Failing after 12s

A code node in a batch run is now fingerprinted by its source, its raw
settings and the values it reads — an artifact input counting as its digest,
which is what the content addressing was always for. A run that finds the key
restores what the earlier one returned and skips the node, recorded as
`cached`. The run history is the cache: `run_node.outputs` beside the
`cache_key` the schema already had, no second store. On for code nodes, never
for the built-in and connector types that have side effects; off per node with
`@node(cache=False)` and per run with `--no-cache`.

Emissions are not replayed on a hit, so a cached training node returns its
result without redrawing its curve. Recorded in NOTEPAD.md with the two other
deliberate limits.

`fluksio run --local` boots the real app in the command's own process and
drives it through its ASGI interface behind the ordinary client, so a run no
longer needs a `serve` terminal beside it — same data directory, same history,
and the cache carries between the two. It always waits, because the engine it
starts lives exactly as long as the command.

Also: `fluksio sweep --param lr=0.1,0.01` for the product of the lists,
`run --follow` for a run's numbers as they arrive, Ctrl-C cancelling a waited
run rather than abandoning it, coloured statuses on a terminal, and `name`
made optional on the metrics endpoint so a follower can ask for every series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 20:31:31 +02:00
co-authored by Claude Opus 5
parent 7a9502883a
commit 400d7d9c5c
23 changed files with 1147 additions and 58 deletions
+278 -27
View File
@@ -1,7 +1,8 @@
"""The `fluksio sync`, `run`, `runs` and `login` commands.
"""The `fluksio sync`, `run`, `runs`, `sweep` and `login` commands.
Kept beside the SDK rather than in `fluksio.cli`: these are the client half of
the tool, and none of them needs the engine to be importable.
the tool, and none of them needs the engine to be importable — `--local`, which
does, imports it inside the branch that asked for it.
"""
from __future__ import annotations
@@ -9,9 +10,13 @@ from __future__ import annotations
import argparse
import getpass
import importlib
import itertools
import json
import pkgutil
import sys
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
@@ -20,6 +25,7 @@ from fluksio.sdk.client import (
GLOBAL_DATA_DIR,
ApiError,
Client,
RunHandle,
data_dir,
login,
origin_of,
@@ -29,6 +35,17 @@ from fluksio.sdk.client import (
__all__ = ["add_parsers", "discover"]
#: Statuses worth a colour, and the SGR code each gets.
_COLORS = {
"ok": "32",
"error": "31",
"cached": "36",
"cancelled": "33",
"abandoned": "31",
"running": "36",
"queued": "33",
}
def _say(message: str = "") -> None:
print(message)
@@ -39,6 +56,20 @@ def _fail(message: str) -> int:
return 1
def _status(text: str, width: int = 0) -> str:
"""A status, coloured when a terminal is reading it.
Padded before it is coloured: the escape sequences are characters as far
as `str.format` is concerned, and a column that lines up in a pipe would
not line up on screen.
"""
body = f"{text:<{width}}" if width else text
code = _COLORS.get(text)
if not code or not sys.stdout.isatty():
return body
return f"\033[{code}m{body}\033[0m"
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
@@ -112,6 +143,72 @@ def discover(targets: list[str]) -> list[Flow]:
return list(FLOWS.values())
# ---------------------------------------------------------------------------
# Which engine
# ---------------------------------------------------------------------------
@contextmanager
def _engine_client() -> Iterator[Client]:
"""The engine itself, in this process, behind the ordinary client.
Everything `fluksio serve` does apart from listening on a socket: the same
data directory, the same database, the same admin, the same lifespan. The
app is driven through its ASGI interface, so a run costs what it costs on
a served engine and lands in the same history — which is what makes the
stage cache carry across the two.
The engine only exists for the length of the command, so nothing here is
written back as a login: a stored token belongs to whichever engine
`fluksio login` was pointed at.
"""
# Imported here, not at module scope: everything else in this file is the
# client half and must keep working with no engine installed.
import logging
from fluksio.cli import _data_dir, _prepare, _print_new_admin
directory = _data_dir(None)
# Settings are read when the app is imported, so this comes first.
_prepare(directory)
# Nothing here goes over a network, so httpx logging each call as a
# request to "testserver" is noise that also happens to be untrue.
logging.getLogger("httpx").setLevel(logging.WARNING)
from datetime import timedelta
from fastapi.testclient import TestClient
from sqlmodel import Session
from fluksio.core import security
from fluksio.core.bootstrap import ensure_superuser
from fluksio.core.db import engine
with Session(engine) as session:
admin, generated = ensure_superuser(session)
admin_id = admin.id
if generated:
_print_new_admin(admin.email, generated)
token = security.create_access_token(admin_id, expires_delta=timedelta(hours=12))
from fluksio.main import app
# Entering the client is what runs the lifespan: the worker pool, the
# controller and the run service all start here and stop on the way out.
with TestClient(app) as http:
yield Client(http=http, token=token)
@contextmanager
def _client_for(args: argparse.Namespace) -> Iterator[Client]:
"""The engine this command talks to: one running somewhere, or this one."""
if getattr(args, "local", False):
with _engine_client() as client:
yield client
else:
yield Client(url=args.url, token=args.token)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
@@ -196,12 +293,17 @@ def _coerce(value: str, dtype: str) -> Any:
return json.loads(value)
def _params(definition: dict[str, Any], rest: list[str]) -> dict[str, Any]:
"""Turn `--lr 0.05` into a typed parameter, using the flow's own inputs."""
types = {
def _input_types(definition: dict[str, Any]) -> dict[str, str]:
"""What each of a flow's inputs is declared to be."""
return {
str(entry["spec"]["name"]): str(entry["spec"].get("dtype", "float"))
for entry in definition.get("inputs") or []
}
def _params(definition: dict[str, Any], rest: list[str]) -> dict[str, Any]:
"""Turn `--lr 0.05` into a typed parameter, using the flow's own inputs."""
types = _input_types(definition)
params: dict[str, Any] = {}
pending: str | None = None
for token in rest:
@@ -258,42 +360,156 @@ def _sync_first(client: Client) -> None:
_say(f"synced {', '.join(r.flow for r in changed)}")
def cmd_run(args: argparse.Namespace, rest: list[str]) -> int:
def _follow(client: Client, handle: RunHandle, poll: float = 1.0) -> None:
"""Print a run's numbers as they arrive, until it is over.
Polled rather than pushed: the engine writes a metric down when it is
reported, so asking once a second draws the same curve a socket would
have, without either side holding a connection open. The status is read
before the numbers, so the last batch is never the one that gets missed.
"""
seen: set[tuple[str, int]] = set()
while True:
done = handle.refresh().done
for point in client.metrics(handle.id):
mark = (str(point.get("name", "")), int(point.get("step", -1)))
if mark in seen:
continue
seen.add(mark)
_say(f" {mark[0]}[{mark[1]}] = {point['value']:g}")
if done:
return
time.sleep(poll)
def _cancel(client: Client, handle: RunHandle) -> int:
"""Ctrl-C means stop the run, not just stop watching it."""
try:
client = Client(url=args.url, token=args.token)
if not args.no_sync:
_sync_first(client)
stored = client.get_flow(args.flow)
if stored is None:
return _fail(f"no flow '{args.flow}' on that engine")
params = _params(stored.get("definition") or {}, rest)
handle = client.submit(args.flow, params, seed=args.seed)
client.cancel(handle.id)
except (SyncError, ApiError) as exc:
return _fail(f"could not cancel {handle.id}: {exc}")
_say(f"{handle.id} {_status('cancelled')}")
return 130
def _cached_note(client: Client, handle: RunHandle) -> str:
"""How much of the run earlier ones had already answered."""
try:
nodes = client.run(handle.id).get("nodes") or []
except (SyncError, ApiError):
return ""
cached = sum(1 for node in nodes if node.get("status") == "cached")
return f" ({cached}/{len(nodes)} {_status('cached')})" if cached else ""
def cmd_run(args: argparse.Namespace, rest: list[str]) -> int:
# An in-process engine lives exactly as long as this command, so a run
# nobody waits for would be thrown away with the queue holding it.
wait = args.wait or args.follow or args.local
try:
with _client_for(args) as client:
if not args.no_sync:
_sync_first(client)
stored = client.get_flow(args.flow)
if stored is None:
return _fail(f"no flow '{args.flow}' on that engine")
params = _params(stored.get("definition") or {}, rest)
handle = client.submit(
args.flow, params, seed=args.seed, no_cache=args.no_cache
)
_say(f"{handle.id} queued {json.dumps(params)}")
if not wait:
return 0
try:
if args.follow:
_follow(client, handle)
else:
handle.wait(timeout=args.timeout)
except KeyboardInterrupt:
return _cancel(client, handle)
_say(
f"{handle.id} {_status(handle.status)} "
f"{json.dumps(handle.result)}{_cached_note(client, handle)}"
)
return 0 if handle.status == "ok" else 1
except (SyncError, ApiError) as exc:
return _fail(str(exc))
_say(f"{handle.id} queued {json.dumps(params)}")
if not args.wait:
return 0
handle.wait(timeout=args.timeout)
_say(f"{handle.id} {handle.status} {json.dumps(handle.result)}")
return 0 if handle.status == "ok" else 1
def cmd_runs(args: argparse.Namespace) -> int:
try:
rows = Client(url=args.url, token=args.token).runs(
flow=args.flow, limit=args.limit
)
with _client_for(args) as client:
rows = client.runs(flow=args.flow, limit=args.limit)
except (SyncError, ApiError) as exc:
return _fail(str(exc))
for row in rows:
commit = (row.get("origin_commit") or "")[:7]
_say(
f"{row['id']} {row['status']:<9} {row['flow']:<16} "
f"{row['id']} {_status(row['status'], 9)} {row['flow']:<16} "
f"{row['duration_ms'] / 1000:7.1f}s {commit:<8} {json.dumps(row['params'])}"
)
return 0
def _grid(
definition: dict[str, Any], values: list[str], seed: int | None
) -> list[dict[str, Any]]:
"""`--param lr=0.1,0.01 --param epochs=10,50` — every combination of them."""
types = _input_types(definition)
names: list[str] = []
columns: list[list[Any]] = []
for raw in values:
name, sep, listed = raw.partition("=")
name = name.replace("-", "_")
if not sep or not listed:
raise SyncError(f"--param takes name=value,value — got '{raw}'")
if name not in types:
raise SyncError(
f"'{name}' is not an input of this flow (it takes "
f"{', '.join(sorted(types)) or 'none'})"
)
names.append(name)
columns.append([_coerce(item, types[name]) for item in listed.split(",")])
return [
{"params": dict(zip(names, combination, strict=True)), "seed": seed}
for combination in itertools.product(*columns)
]
def cmd_sweep(args: argparse.Namespace) -> int:
wait = args.wait or args.local
try:
with _client_for(args) as client:
if not args.no_sync:
_sync_first(client)
stored = client.get_flow(args.flow)
if stored is None:
return _fail(f"no flow '{args.flow}' on that engine")
entries = _grid(stored.get("definition") or {}, args.param, args.seed)
handles = client.sweep(args.flow, entries, no_cache=args.no_cache)
for handle, entry in zip(handles, entries, strict=True):
_say(f"{handle.id} queued {json.dumps(entry['params'])}")
if not wait:
return 0
failed = 0
try:
for handle in handles:
handle.wait(timeout=args.timeout)
_say(
f"{handle.id} {_status(handle.status)} "
f"{json.dumps(handle.result)}"
)
failed += handle.status != "ok"
except KeyboardInterrupt:
for handle in handles:
if not handle.refresh().done:
_cancel(client, handle)
return 130
return 1 if failed else 0
except (SyncError, ApiError) as exc:
return _fail(str(exc))
# ---------------------------------------------------------------------------
# Wiring
# ---------------------------------------------------------------------------
@@ -302,11 +518,17 @@ def cmd_runs(args: argparse.Namespace) -> int:
def add_parsers(subparsers: Any) -> None:
"""Register the client commands on `fluksio`'s parser."""
def with_engine(sub: argparse.ArgumentParser) -> None:
def with_engine(sub: argparse.ArgumentParser, local: bool = False) -> None:
sub.add_argument(
"--url", default="", help="the engine (default: the last login)"
)
sub.add_argument("--token", default="", help="override the stored token")
if local:
sub.add_argument(
"--local",
action="store_true",
help="boot the engine in this process instead of talking to one",
)
parser = subparsers.add_parser(
"login", help="store a token for an engine elsewhere"
@@ -348,17 +570,46 @@ def add_parsers(subparsers: Any) -> None:
parser.add_argument("flow")
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--wait", action="store_true", help="block until it finishes")
parser.add_argument(
"--follow",
action="store_true",
help="wait, printing the numbers it reports as they arrive",
)
parser.add_argument("--timeout", type=float, default=0.0)
parser.add_argument(
"--no-sync",
action="store_true",
help="run what is already on the engine, without uploading first",
)
with_engine(parser)
parser.add_argument(
"--no-cache",
action="store_true",
help="execute every node, even one an earlier run already answered",
)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_run)
parser = subparsers.add_parser("runs", help="the runs an engine has recorded")
parser.add_argument("--flow", default="")
parser.add_argument("--limit", type=int, default=20)
with_engine(parser)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_runs)
parser = subparsers.add_parser(
"sweep", help="one flow, once per combination of the parameters given"
)
parser.add_argument("flow")
parser.add_argument(
"--param",
action="append",
default=[],
metavar="NAME=V1,V2",
help="an input and the values to try; repeat for a grid",
)
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--wait", action="store_true", help="block until all finish")
parser.add_argument("--timeout", type=float, default=0.0)
parser.add_argument("--no-sync", action="store_true")
parser.add_argument("--no-cache", action="store_true")
with_engine(parser, local=True)
parser.set_defaults(func=cmd_sweep)