Merge branch 'main' of git.stroblme.de:Fluksio/app
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m44s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m48s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m32s
Compose Smoke Test / test-compose (push) Successful in 34s
Playwright Tests / merge-reports (push) Canceled after 0s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
This commit is contained in:
2026-08-29 16:42:07 +02:00
co-authored by Claude Opus 5
101 changed files with 4423 additions and 717 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ ENV NODE_VENV=managed
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-workspace --package fluksio
uv sync --frozen --no-install-workspace --package fluksio --extra server
COPY ./backend/scripts /app/backend/scripts
@@ -52,7 +52,7 @@ COPY ./worker /app/worker
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --package fluksio
uv sync --frozen --package fluksio --extra server
# Connectors are ordinary installed packages found through the
# `fluksio.node_types` entry point. `make connectors` builds them into here;
+2
View File
@@ -15,6 +15,7 @@ from fluksio.api.routes import (
panels,
private,
runs,
search,
secrets,
users,
utils,
@@ -38,6 +39,7 @@ api_router.include_router(observability.router)
api_router.include_router(runs.router)
api_router.include_router(artifacts.router)
api_router.include_router(workers.router)
api_router.include_router(search.router)
# Remote access through a portal. Always mounted; with no enrolment the
# endpoints only ever report that there is none.
api_router.include_router(cloud.router)
+11 -1
View File
@@ -176,10 +176,16 @@ async def read_summary(request: Request, controller: FlowControllerDep) -> Any:
paused = set(controller.paused_flows())
entries = list(controller.loaded.values())
errored = [e for e in entries if e.status is NodeStatus.ERROR]
unhealthy = [e for e in entries if e.health == "down"]
if quarantined:
problems.append(f"{len(quarantined)} flow(s) quarantined")
if errored:
problems.append(f"{len(errored)} node(s) failed to load")
if unhealthy:
problems.append(
f"{len(unhealthy)} node(s) down: "
f"{', '.join(sorted(e.id for e in unhealthy))}"
)
# What the canvas flags on a flow — a dependency loop, an input nothing
# feeds — stops that flow running just as surely as a node that will not
@@ -219,7 +225,11 @@ async def read_summary(request: Request, controller: FlowControllerDep) -> Any:
"quarantined": len(quarantined),
"invalid": len(invalid),
},
nodes={"total": len(entries), "error": len(errored)},
nodes={
"total": len(entries),
"error": len(errored),
"unhealthy": len(unhealthy),
},
queue=queue,
loop_lag=(
watchdog.snapshot()
+64 -25
View File
@@ -8,20 +8,22 @@ or to listen on the flow socket, which carries its start and finish.
import csv
import io
import json
import time
from collections.abc import Iterator
from datetime import UTC, datetime
from itertools import groupby
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import func
from sqlalchemy import delete, func
from sqlalchemy import select as sa_select
from sqlmodel import Session, col, select
from fluksio.api.deps import CurrentUser, SessionDep, get_current_user
from fluksio.flow.events import event_bus
from fluksio.flow.messages import requalify
from fluksio.flow.runs import RunRejected, RunService, new_run_id
from fluksio.flow.store import FlowNotFound
@@ -102,6 +104,10 @@ class ArtifactRow(BaseModel):
digest: str
size: int
media_type: str
#: What it was called where it was written, so something downloading it can
#: give it that name back rather than the message's. Absent when the node
#: never said one.
filename: str | None = None
class RunRow(BaseModel):
@@ -420,25 +426,6 @@ def _dig(record: dict[str, Any], path: str) -> Any:
return value
def _varying(runs: list[Run]) -> list[str]:
"""The inputs that differ across these runs — the axis of a sweep.
What a reader comparing arms wants as columns, compared leaf by leaf: two
configurations differing in one field give that field as a column rather
than two blobs that are not the same. Under two runs nothing can differ,
and a table of one run with none of its inputs in it is not worth reading,
so all of them are kept.
"""
keys = sorted({path for run in runs for path, _ in _leaves(run.params)})
if len(runs) < 2:
return keys
return [
key
for key in keys
if len({json.dumps(_dig(run.params, key), sort_keys=True) for run in runs}) > 1
]
def _scored(runs: list[Run]) -> list[str]:
"""A run's final numbers: every number its declared outputs carry, however
deep it sits. A flag is not a number, and neither is a label."""
@@ -555,9 +542,9 @@ def export_runs(
"""One row per run: what it was given, what it scored, what code it ran.
The arm-comparison table. Inputs are columns rather than one JSON blob —
by default the ones that vary across the selection, which is the sweep
axis; ``params`` names them instead. ``metrics`` narrows the final numbers
to a few of a run's declared outputs.
every input the selection recorded, so the schema is the same whichever
runs are asked for; ``params`` narrows it. ``metrics`` narrows the final
numbers to a few of a run's declared outputs.
Both take dotted paths into a record a node returned:
``metrics=final_metrics.train_loss,test_metrics.known.perfect`` selects
@@ -565,7 +552,9 @@ def export_runs(
depth.
"""
runs = _selected(session, flow, status, group, ids, since, until)
inputs = [part for part in params.split(",") if part] or _varying(runs)
inputs = [part for part in params.split(",") if part] or sorted(
{path for run in runs for path, _ in _leaves(run.params)}
)
scores = [part for part in metrics.split(",") if part] or _scored(runs)
columns = [
*RUN_COLUMNS,
@@ -613,6 +602,56 @@ async def cancel_run(run_id: str, request: Request, session: SessionDep) -> Any:
return run
@router.delete("/{run_id}", status_code=204)
def delete_run(run_id: str, session: SessionDep, user: CurrentUser) -> Response:
"""Forget a run and everything hanging off it.
The same four statements ``_forget_runs`` uses when a flow goes: the run
tables carry a plain string ``run_id`` and no foreign key, so nothing
cascades on its own. ``flow_run``, ``metric_minute`` and ``engine_event``
stay — they are the observability record and are pruned on their own window.
A live run is refused rather than raced: the driver writes its nodes back
when it finishes, and those rows would arrive for a run that no longer
exists. Cancel it first.
Two things this costs, both deliberate. ``RunNode.outputs`` *is* the stage
cache, so a later run loses hits this one would have served. And a node
restored from this run points here through ``cached_from`` — ``_series``
already reads a missing source as an empty curve, which is what ``NO_CURVE``
explains on the screen. The artifact bytes need no help: ``sweep_artifacts``
keeps whatever a ``run_artifact`` row or a live message still names, so
dropping the rows is enough and the hourly sweep reclaims the blobs.
"""
run = session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="No such run")
if run.status in ("running", "queued"):
raise HTTPException(
status_code=409,
detail=(
f"Run {run_id} is {run.status}. Cancel it, or wait for it to "
"finish, before deleting it."
),
)
flow = run.flow
session.execute(delete(RunNode).where(col(RunNode.run_id) == run_id))
session.execute(delete(RunMetric).where(col(RunMetric.run_id) == run_id))
session.execute(delete(RunArtifact).where(col(RunArtifact.run_id) == run_id))
session.execute(delete(Run).where(col(Run.id) == run_id))
session.commit()
event_bus.publish(
{
"type": "audit",
"action": f"deleted run {run_id}",
"flow": flow,
"user": user.email,
"ts": time.time(),
}
)
return Response(status_code=204)
def _series(session: Session, run_id: str, name: str = "") -> list[RunMetric]:
"""A run's numbers, including the ones a cached node points at.
+156
View File
@@ -0,0 +1,156 @@
"""One index of everything in this installation worth jumping to by name."""
from typing import Any, Literal
from fastapi import APIRouter, Depends, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import (
CurrentUser,
DashboardStoreDep,
FlowControllerDep,
get_current_user,
)
from fluksio.api.routes.alerts import read_config as read_alerts_config
from fluksio.flow import modules, panels
from fluksio.flow.controller import FlowController
from fluksio.flow.dashboards import DashboardNotFound, DashboardStore
from fluksio.flow.secrets import get_secrets
from fluksio.flow.store import FlowNotFound
# A wall panel never reaches this route: ``deps._panel_may`` is a whitelist that
# ends in a 403, and a whole-installation index is the opposite of what a screen
# on a wall is allowed to read.
router = APIRouter(
prefix="/search", tags=["search"], dependencies=[Depends(get_current_user)]
)
Category = Literal[
"flow",
"node",
"dashboard",
"widget",
"panel",
"secret",
"module",
"worker",
"alert",
]
class SearchEntry(BaseModel):
"""One thing somebody might be looking for.
Deliberately not a route: where a category lands is the frontend's business,
and it already owns the router. This says what the thing is and what it is
called, which is all the matching needs.
"""
category: Category
#: The id the frontend routes on.
name: str
#: Human title, often empty — a flow is usually only its name.
title: str = ""
#: The flow a node sits in, or the dashboard a widget sits on.
parent: str = ""
#: Node type, widget type, channel kind.
kind: str = ""
def _build(
controller: FlowController, dashboards: DashboardStore, hub: Any, secrets: bool
) -> list[SearchEntry]:
"""Read every store once. Blocking: disk and git throughout.
# ponytail: rebuilt per call. Key it on ``controller.store.revision`` if a
# store large enough to feel it ever shows up in a profile.
"""
entries: list[SearchEntry] = []
for name in controller.store.list_flows():
try:
flow = controller.store.read_flow(name, draft=True)
except FlowNotFound:
continue
entries.append(SearchEntry(category="flow", name=flow.name, title=flow.title))
entries.extend(
SearchEntry(
category="node",
name=node.id,
title=node.title,
parent=flow.name,
kind=node.type,
)
for node in flow.nodes
)
for summary in dashboards.list():
try:
dashboard = dashboards.read(summary.name, draft=True)
except DashboardNotFound:
continue
entries.append(
SearchEntry(
category="dashboard", name=dashboard.name, title=dashboard.title
)
)
entries.extend(
SearchEntry(
category="widget",
name=widget.id,
title=widget.title,
parent=dashboard.name,
kind=widget.type,
)
for widget in dashboard.widgets
)
entries.extend(
SearchEntry(category="panel", name=panel.id, title=panel.title)
for panel in panels.read_config().panels
)
if secrets:
entries.extend(
SearchEntry(category="secret", name=name) for name in get_secrets().list()
)
entries.extend(
SearchEntry(category="module", name=package.name, kind=package.version)
for package in modules.info(controller.store).packages
)
entries.extend(
SearchEntry(category="worker", name=worker.name)
for worker in (hub.workers() if hub is not None else [])
)
entries.extend(
SearchEntry(category="alert", name=channel.name, kind=channel.kind)
for channel in read_alerts_config().channels
)
return entries
@router.get("/", response_model=list[SearchEntry])
async def read_search_index(
current_user: CurrentUser,
request: Request,
controller: FlowControllerDep,
dashboards: DashboardStoreDep,
) -> Any:
"""Everything searchable, for the client to match against as it is typed.
The whole index rather than a query: it is a few hundred short rows for an
installation of any ordinary size, so one fetch when the panel opens beats a
round trip per keystroke — and the client already has a matcher.
Secrets are named only to a superuser, which is who ``/secrets`` answers to.
"""
# Absent when remote workers are switched off — not a reason to fail a search.
hub = getattr(request.app.state, "worker_hub", None)
return await run_in_threadpool(
_build, controller, dashboards, hub, current_user.is_superuser
)
+189 -14
View File
@@ -17,10 +17,12 @@ from __future__ import annotations
import argparse
import copy
import json
import os
import secrets
import socket
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -80,6 +82,39 @@ def _mention_other_installation(data_dir: Path) -> None:
_say(" `fluksio serve --global` runs that one instead.")
def _mention_undeclared_cards() -> None:
"""Say when a stored flow asks for a card this engine does not have.
Cards are declared rather than detected, so an engine told nothing has
none — and a node asking for one is clamped to zero and runs beside every
other, which on a GPU is the deadlock the declaration exists to prevent.
The placer says so once it happens, into the log; this says it while
somebody is still reading the terminal.
"""
from fluksio.core.config import settings
if settings.FLOW_GPUS:
return
from fluksio.flow.runs import required_resources
from fluksio.flow.store import FlowStore
store = FlowStore(settings.FLOWS_DIR)
asking = []
for name in store.list_flows():
try:
needs = required_resources(store.read_flow(name))
except Exception:
# A flow that will not parse is the engine's to complain about.
continue
if (needs or {}).get("gpus"):
asking.append(name)
if not asking:
return
named = ", ".join(sorted(asking)[:3]) + ("" if len(asking) > 3 else "")
_say(f" Cards 0 declared, but {named} asks for one.")
_say(" Nothing detects them: `--gpus N` says how many are here.")
def _warn_if_networked(path: Path) -> None:
"""A cluster's $HOME is often NFS, and SQLite's WAL does not work there."""
try:
@@ -228,9 +263,26 @@ CONCURRENCY_FLAGS = {
"max_workers": "FLOW_MAX_WORKERS",
"max_cascades": "FLOW_MAX_CASCADES",
"max_runs": "FLOW_MAX_RUNS",
"gpus": "FLOW_GPUS",
}
def _at_least(minimum: int) -> Callable[[str], int]:
"""A flag's value, checked here rather than by the settings.
These are written into the environment before the settings are built, so a
number they refuse dies inside a pydantic import with no flag named in it.
"""
def parse(text: str) -> int:
value = int(text)
if value < minimum:
raise argparse.ArgumentTypeError(f"is {value}, needs at least {minimum}")
return value
return parse
#: What `serve` listens on when nobody says. Taken often enough — another
#: engine, another framework's dev server — that dying on it is the first
#: thing a zero-config start would hit.
@@ -240,6 +292,85 @@ DEFAULT_PORT = 8000
PORT_TRIES = 20
#: Where a serving engine records itself, beside the data it is serving. Read
#: to tell "this directory's engine is already up" from "something else has the
#: port", which are the two ways a second `serve` fails to be what was wanted.
PIDFILE = "serve.pid"
def write_pidfile(data_dir: Path, port: int) -> Path:
"""Record which process is serving this directory, and where."""
path = data_dir / PIDFILE
path.write_text(json.dumps({"pid": os.getpid(), "port": port}))
return path
def read_pidfile(data_dir: Path) -> dict[str, int] | None:
"""The engine serving this directory, if one still is.
A process killed outright leaves the file behind, so the pid is checked
rather than believed — a stale file is the same as no file.
"""
try:
record = json.loads((data_dir / PIDFILE).read_text())
pid, port = int(record["pid"]), int(record["port"])
except (OSError, ValueError, KeyError, TypeError):
return None
try:
os.kill(pid, 0)
except (OSError, ProcessLookupError):
return None
return {"pid": pid, "port": port}
def _token_for(data_dir: Path) -> str:
"""The credential this directory's last engine wrote, if it wrote one."""
from fluksio.sdk.client import config_path
try:
return str(json.loads(config_path(data_dir).read_text()).get("token", ""))
except (OSError, ValueError, AttributeError):
return ""
def probe_engine(url: str, token: str, client: Any = None) -> str:
"""Who is on this port: ``ours``, ``foreign``, or ``other``.
``ours`` means an engine serving *this* data directory, which is what
makes stopping it something this command may offer. The proof is the
token: it is signed with this directory's secret key, so an engine that
accepts it is one reading this directory's database. A Fluksio belonging
to another installation answers the health check and refuses the token,
and is only ever named — never stopped from here.
"""
import logging
import httpx
# Two requests nobody asked for, on a start that is otherwise quiet until
# uvicorn's own banner. httpx logs every one of them at INFO.
noisy = logging.getLogger("httpx")
was = noisy.level
noisy.setLevel(logging.WARNING)
http = client or httpx.Client(timeout=2.0)
try:
health = http.get(f"{url}/api/v1/utils/health-check/")
if health.status_code != 200:
return "other"
# No token at all is asked unauthenticated: `Bearer ` is not a legal
# header value, and a Fluksio this directory cannot prove is its own
# is foreign — which is the answer that never stops anything.
auth = {"Authorization": f"Bearer {token}"} if token else {}
answer = http.get(f"{url}/api/v1/observability/summary", headers=auth)
return "ours" if answer.status_code == 200 else "foreign"
except Exception:
return "other"
finally:
noisy.setLevel(was)
if client is None:
http.close()
def _free_port(host: str, start: int) -> int:
"""The first port from ``start`` that nothing is listening on.
@@ -258,6 +389,14 @@ def _free_port(host: str, start: int) -> int:
def cmd_serve(args: argparse.Namespace) -> int:
# At a terminal this is a dashboard with the engine as a child of it. The
# import is here rather than at the top because it is only ever needed on
# that path, and `serve` in a container must not pay for it.
if not args.plain and sys.stdout.isatty() and sys.stdin.isatty():
from fluksio.tui import run_tui
return run_tui(args)
data_dir = _data_dir(args.data_dir, args.shared)
for flag, name in CONCURRENCY_FLAGS.items():
value = getattr(args, flag, None)
@@ -294,15 +433,34 @@ def cmd_serve(args: argparse.Namespace) -> int:
from fluksio.flow import modules
from fluksio.main import app
# The client talks to this engine, and 0.0.0.0 is not an address to talk
# to — it is a statement about which interfaces to listen on.
reachable = "127.0.0.1" if args.host in ("0.0.0.0", "::", "") else args.host
port = args.port
if port is None:
port = _free_port(args.host, DEFAULT_PORT)
if port != DEFAULT_PORT:
_say(f"Port {DEFAULT_PORT} is in use; serving on {port} instead.")
# Moving off the port quietly makes starting a second engine for
# one directory look like it worked. Two of them on one SQLite
# file is not a supported shape — two *installations* on one
# machine is — so the one already up is named instead.
url = f"http://{reachable}:{DEFAULT_PORT}"
who = probe_engine(url, _token_for(data_dir))
if who == "ours":
running = read_pidfile(data_dir)
where = f" (pid {running['pid']})" if running else ""
_say(f"An engine for {data_dir} is already serving at {url}{where}.")
_say(" fluksio status talks to it; stop it to start another.")
return 0
if who == "foreign":
_say(
f"Port {DEFAULT_PORT} holds another installation's Fluksio; "
f"serving on {port} instead."
)
else:
_say(f"Port {DEFAULT_PORT} is in use; serving on {port} instead.")
# The client talks to this engine, and 0.0.0.0 is not an address to talk
# to — it is a statement about which interfaces to listen on.
reachable = "127.0.0.1" if args.host in ("0.0.0.0", "::", "") else args.host
url = f"http://{reachable}:{port}"
token_path = _sign_in(admin_id, url, data_dir)
@@ -329,17 +487,22 @@ def cmd_serve(args: argparse.Namespace) -> int:
_say(" fluksio enroll <code>")
_say(f" Signed in as {admin_email}")
_say(f" token in {token_path}")
_mention_undeclared_cards()
_mention_other_installation(data_dir)
# One process: it holds the flow engine, and a second worker would be a
# second engine — duplicated subscriptions, cron ticks and webhooks.
uvicorn.run(
app,
host=args.host,
port=port,
log_level=args.log_level,
log_config=_log_config(args.log_level),
)
pidfile = write_pidfile(data_dir, port)
try:
uvicorn.run(
app,
host=args.host,
port=port,
log_level=args.log_level,
log_config=_log_config(args.log_level),
)
finally:
pidfile.unlink(missing_ok=True)
return 0
@@ -426,25 +589,37 @@ def _parser() -> argparse.ArgumentParser:
)
serve.add_argument(
"--max-runs",
type=int,
type=_at_least(1),
default=None,
metavar="N",
help="batch runs driven at once (default 4, FLOW_MAX_RUNS)",
)
serve.add_argument(
"--max-cascades",
type=int,
type=_at_least(1),
default=None,
metavar="N",
help="cascades in flight at once (default 4, FLOW_MAX_CASCADES)",
)
serve.add_argument(
"--max-workers",
type=int,
type=_at_least(1),
default=None,
metavar="N",
help="python worker processes (default 4, FLOW_MAX_WORKERS)",
)
serve.add_argument(
"--plain",
action="store_true",
help="the log stream rather than the dashboard (the default with no terminal)",
)
serve.add_argument(
"--gpus",
type=_at_least(0),
default=None,
metavar="N",
help="GPUs on this machine a node may be given (default 0, FLOW_GPUS)",
)
serve.set_defaults(func=cmd_serve)
enroll = subparsers.add_parser(
+8 -3
View File
@@ -9,6 +9,7 @@ from pydantic import (
BeforeValidator,
EmailStr,
HttpUrl,
PositiveInt,
computed_field,
model_validator,
)
@@ -97,15 +98,19 @@ class Settings(BaseSettings):
# rather than a person, and it can refresh unattended.
MCP_TOKEN_EXPIRE_MINUTES: int = 60
MCP_REFRESH_EXPIRE_DAYS: int = 30
FLOW_MAX_WORKERS: int = 4
# The three below are all pool sizes, so 0 says neither "none" nor
# "unlimited" — it is a pool that cannot be built and an engine that would
# accept no work. Rejected here rather than quietly read as the default,
# because a limit somebody set and did not get is the worse surprise.
FLOW_MAX_WORKERS: PositiveInt = 4
# How many cascades may be in flight at once. Sustained throughput is this
# over the mean cascade time, so an installation whose nodes wait on the
# network rather than on a CPU wants it higher than the core count.
FLOW_MAX_CASCADES: int = 4
FLOW_MAX_CASCADES: PositiveInt = 4
# How many batch runs are driven at once. A different limit from the one
# above: a run drives a whole graph, and its nodes are bounded by the worker
# pool rather than by cascade slots. A sweep is what this governs.
FLOW_MAX_RUNS: int = 4
FLOW_MAX_RUNS: PositiveInt = 4
# How long a python node may be silent before its worker is killed, unless
# the node sets its own. 0, the default, disables it: a dead worker still
# fails fast, and a slow one is left to finish. Set it where silence means
+19 -10
View File
@@ -86,7 +86,14 @@ class ConnectorNode(Node):
description="Seconds between polls; 0 polls never.",
)
__slots__ = ("config", "_poll_task", "_stop_event", "_last_published", "_artifacts")
__slots__ = (
"config",
"_poll_task",
"_stop_event",
"_last_published",
"_artifacts",
"_down",
)
def __init__(self, **kwargs: Any) -> None:
super().__init__(f=self._dispatch, **kwargs)
@@ -95,6 +102,7 @@ class ConnectorNode(Node):
self._stop_event: asyncio.Event | None = None
self._last_published: dict[str, Any] = {}
self._artifacts: ArtifactStore | None = None
self._down = False
def _dispatch(self, params: dict[str, Any], **ports: Any) -> dict[str, Any] | None:
"""The scheduler's entry point. Settings are already on ``self.config``."""
@@ -161,11 +169,7 @@ class ConnectorNode(Node):
return
self._stop_event.set()
if self._poll_task is not None:
self._poll_task.cancel()
try:
await self._poll_task
except (asyncio.CancelledError, Exception): # noqa: B014 - shutting down
pass
await self._cancel_task(self._poll_task)
self._poll_task = None
self._stop_event = None
self._last_published = {}
@@ -175,24 +179,29 @@ class ConnectorNode(Node):
Only changed ports are published: a device polled every few seconds is
usually saying the same thing, and every publication wakes everything
downstream of it.
downstream of it. What is remembered is what was *published*, not what
the poll returned — a publication that raised is retried next tick
rather than counting as said.
"""
while not (self._stop_event and self._stop_event.is_set()):
try:
values = await self.poll()
self.report_health("ok")
changed = {
port: value
for port, value in (values or {}).items()
if self._last_published.get(port, object()) != value
}
if changed:
self._last_published.update(changed)
# inject runs the graph, which is blocking work.
await asyncio.to_thread(self.inject, changed)
self._last_published.update(changed)
self.report_health("ok")
self._down = False
except asyncio.CancelledError:
break
except Exception as exc:
logger.warning("Connector '%s' failed to poll: %s", self.id, exc)
if not self._down:
logger.warning("Connector '%s' failed to poll: %s", self.id, exc)
self._down = True
self.report_health("down", f"{type(exc).__name__}: {exc}")
await asyncio.sleep(self.config.poll_interval)
+26 -3
View File
@@ -916,12 +916,12 @@ class FlowController:
placer, pool = self.placer, self.workers
def call(**kwargs: Any) -> Any:
with placer.claim(
with placer.claim( # type: ignore[union-attr]
wanted, device=device, policy=policy, node=node_id, run=run_id
) as (target, allocation):
env = derive_env(wanted, allocation)
if target.worker is None:
return pool.for_env(env).run(
return pool.for_env(env).run( # type: ignore[union-attr]
owner,
local,
code,
@@ -1224,8 +1224,31 @@ class FlowController:
}
)
def _health_issues(self, flow: str | None = None) -> list[ValidationIssue]:
"""Nodes that are running but not working, as issues on their flow.
Not part of `self.issues`: that list is what a build found, and this is
what is happening now. Derived on read, so a node reporting itself well
again clears it with nothing to remember.
"""
return [
ValidationIssue(
code="node_unhealthy",
message=(
f"Node '{entry.id.rpartition('.')[2]}' is down: "
f"{entry.health_detail or 'no detail given'}"
),
flow=entry.flow,
node=entry.id,
)
for entry in self.loaded.values()
if entry.health == "down" and (flow is None or entry.flow == flow)
]
def flow_issues(self, flow: str) -> list[ValidationIssue]:
return [issue for issue in self.issues if not issue.flow or issue.flow == flow]
return [
issue for issue in self.issues if not issue.flow or issue.flow == flow
] + self._health_issues(flow)
def preview(self, name: str) -> Preview:
"""Build a flow's unpublished draft without deploying it.
+8 -2
View File
@@ -46,6 +46,11 @@ DELAYED_INTERVAL_S = 1.0
#: whose nodes wait on a network rather than a CPU may want more of them —
#: `FLOW_MAX_CASCADES` is where that is said.
MAX_CASCADES = 4
#: Node threads, unless the service is given a number. Both this and the one
#: above are taken as written: only ``None`` means "nobody said", so a number
#: that reached here is one somebody chose, and an unusable one is the pool's
#: ``ValueError`` rather than a silent 4.
MAX_WORKERS = 4
# How long a reload waits for claimed work to finish before rebuilding anyway.
DRAIN_TIMEOUT_S = 10.0
# Work waiting in the stream, undelivered. A burst is normal — the pool claims
@@ -68,7 +73,7 @@ class ExecutionService:
) -> None:
self.queue = queue
self._events = events
self.max_cascades = max_cascades or MAX_CASCADES
self.max_cascades = MAX_CASCADES if max_cascades is None else max_cascades
self._pipeline: Pipeline | None = None
self._stop = threading.Event()
# Set when a deadline moves closer, so the timer thread stops waiting
@@ -82,7 +87,8 @@ class ExecutionService:
# Entry ids claimed and still running, under _inflight_lock.
self._active: set[str] = set()
self.node_pool = ThreadPoolExecutor(
max_workers=max_workers or 4, thread_name_prefix="node"
max_workers=MAX_WORKERS if max_workers is None else max_workers,
thread_name_prefix="node",
)
self._cascade_pool = ThreadPoolExecutor(
max_workers=self.max_cascades, thread_name_prefix="cascade"
+16
View File
@@ -212,6 +212,22 @@ class Node:
return None
return asyncio.create_task(factory())
@staticmethod
async def _cancel_task(task: asyncio.Task[None]) -> None:
"""Stop an unsupervised loop and wait for it to be gone.
`wait` keeps whatever the task raises on its way out to itself, and
lets a cancellation aimed at *this* coroutine through — the
`except CancelledError` around `await task` it replaces swallowed that,
which left whoever asked for the teardown unkillable. The same trap
`Supervisor._cancel` documents.
"""
task.cancel()
await asyncio.wait([task])
if not task.cancelled():
# Retrieved so a crash on the way out is not reported at exit.
task.exception()
def report_health(self, status: str, detail: str | None = None) -> None:
"""Say how this node's connection is doing: ok, degraded or down."""
if self._on_health is not None:
+1 -5
View File
@@ -211,11 +211,7 @@ class DelayNode(Node):
self._stop_cron.set()
if self._cron_task is not None:
self._cron_task.cancel()
try:
await self._cron_task
except asyncio.CancelledError:
pass
await self._cancel_task(self._cron_task)
self._cron_task = None
self._stop_cron = None
+51 -7
View File
@@ -14,6 +14,22 @@ from fluksio.flow.nodes.base import Node, NodeResult
logger = logging.getLogger(__name__)
def _influxdb() -> Any:
"""The client library, which is a `fluksio[server]` extra.
Imported per use rather than at module level, because the node type is
registered at boot and an installation with no InfluxDB behind it should
not have to carry the library to start.
"""
try:
import influxdb_client
except ImportError:
raise RuntimeError(
"the influxdb node needs the server extra: pip install 'fluksio[server]'"
) from None
return influxdb_client
class InfluxDbNode(Node):
"""
InfluxDB node for writing to and reading from InfluxDB.
@@ -58,6 +74,7 @@ class InfluxDbNode(Node):
- ``bucket`` (str): Bucket name (required)
- ``write_precision`` (str): Write precision ("ns", "us", "ms", "s"), default "ms"
- ``query_range`` (str): Default time range for queries, e.g., "-1h", "-24h"
- ``timeout`` (float): Deadline for a request in seconds (default: 10.0)
- ``writes`` (dict): Write configurations keyed by message name, each with:
- ``measurement`` (str): Measurement name to write to
- ``field`` (str): Field name to write (default: "value")
@@ -154,6 +171,7 @@ class InfluxDbNode(Node):
"bucket",
"write_precision",
"query_range",
"timeout",
"writes",
"queries",
"_write_client",
@@ -169,6 +187,14 @@ class InfluxDbNode(Node):
bucket: str
write_precision: str = "ms"
query_range: str = "-1h"
# Bounds every request to the server. Without one the client falls
# back to its own default, which no flow can see or change. Held in
# seconds like every other node; the client counts in milliseconds.
timeout: float = Field(
default=10.0,
gt=0,
description="Give up on a query or write after this many seconds.",
)
# Per-port write and query configuration.
writes: dict[str, dict[str, Any]] = {}
queries: dict[str, dict[str, Any]] = {}
@@ -201,6 +227,7 @@ class InfluxDbNode(Node):
self.bucket = cfg.bucket
self.write_precision = cfg.write_precision
self.query_range = cfg.query_range
self.timeout = cfg.timeout
self.writes = cfg.writes
self.queries = cfg.queries
@@ -279,13 +306,18 @@ class InfluxDbNode(Node):
:returns: ``{"rows": [...], **echo}``.
:rtype: dict
"""
from influxdb_client import InfluxDBClient
InfluxDBClient = _influxdb().InfluxDBClient
flux = str(request["flux"])
echo = {key: value for key, value in request.items() if key != "flux"}
logger.info("Running Flux for node '%s': %s", self.name, flux)
with InfluxDBClient(url=self.url, token=self.token, org=self.org) as client:
with InfluxDBClient(
url=self.url,
token=self.token,
org=self.org,
timeout=int(self.timeout * 1000),
) as client:
tables = client.query_api().query(flux, org=self.org)
rows = [
@@ -322,11 +354,18 @@ class InfluxDbNode(Node):
- Dict with "value" and "tags" keys: Value written with merged tags
:type data: dict
"""
from influxdb_client import InfluxDBClient, Point, WritePrecision
from influxdb_client.client.write_api import SYNCHRONOUS
influxdb_client = _influxdb()
InfluxDBClient = influxdb_client.InfluxDBClient
Point, WritePrecision = influxdb_client.Point, influxdb_client.WritePrecision
SYNCHRONOUS = influxdb_client.client.write_api.SYNCHRONOUS
try:
with InfluxDBClient(url=self.url, token=self.token, org=self.org) as client:
with InfluxDBClient(
url=self.url,
token=self.token,
org=self.org,
timeout=int(self.timeout * 1000),
) as client:
write_api = client.write_api(write_options=SYNCHRONOUS)
precision_map = {
@@ -417,12 +456,17 @@ class InfluxDbNode(Node):
:returns: Dict of port name to queried value.
:rtype: dict
"""
from influxdb_client import InfluxDBClient
InfluxDBClient = _influxdb().InfluxDBClient
results = {}
try:
with InfluxDBClient(url=self.url, token=self.token, org=self.org) as client:
with InfluxDBClient(
url=self.url,
token=self.token,
org=self.org,
timeout=int(self.timeout * 1000),
) as client:
query_api = client.query_api()
for spec in self.output_ports:
+51 -17
View File
@@ -19,9 +19,21 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Deep enough to ride out a broker hiccup, shallow enough that a publisher
# which cannot keep up drops old values instead of growing without bound.
PUBLISH_QUEUE_SIZE = 256
def _aiomqtt() -> Any:
"""The client library, which is a `fluksio[server]` extra.
Imported per use rather than at module level, because the node type is
registered at boot and an installation that talks to no broker should not
have to carry the library to start.
"""
try:
import aiomqtt
except ImportError:
raise RuntimeError(
"the mqtt node needs the server extra: pip install 'fluksio[server]'"
) from None
return aiomqtt
def topic_matches(filter_: str, topic: str) -> bool:
@@ -83,6 +95,9 @@ class MqttNode(Node):
- ``qos`` (int): Quality of Service level 0, 1, or 2 (default: 0)
- ``retain`` (bool): Retain flag for published messages (default: False)
- ``keepalive`` (int): Keepalive interval in seconds (default: 60)
- ``timeout`` (float): Deadline for a broker operation in seconds
(default: 10.0)
- ``publish_queue_size`` (int): Publisher backlog depth (default: 256)
:type params: dict
:param name: Optional name for the node.
:type name: str | None
@@ -151,6 +166,8 @@ class MqttNode(Node):
"qos",
"retain",
"keepalive",
"timeout",
"publish_queue_size",
"json_keys",
"_topic_to_ports",
"_wildcards",
@@ -175,6 +192,26 @@ class MqttNode(Node):
qos: int = 0
retain: bool = False
keepalive: int = 60
# Bounds every broker operation: subscribe, publish, and the
# disconnect acknowledgement on the way out. Without one a client
# whose socket died waits for that ack forever, and the task never
# finishes unwinding. Brokers differ, so it is per node.
timeout: float = Field(
default=10.0,
gt=0,
description="Give up on a broker operation after this many seconds.",
)
# Deep enough to ride out a broker hiccup, shallow enough that a
# publisher which cannot keep up drops old values instead of growing
# without bound. A node that bursts wants more than one that trickles.
publish_queue_size: int = Field(
default=256,
gt=0,
description=(
"How many payloads may wait for the broker. Past this the oldest "
"is dropped and the node reports degraded."
),
)
# Which key to lift out of a JSON object payload. A device that wraps
# its reading — Victron's ``{"value": 5}`` — is otherwise a Python node
# per port. One key for every port, or a per-port mapping.
@@ -247,6 +284,8 @@ class MqttNode(Node):
self.qos = cfg.qos
self.retain = cfg.retain
self.keepalive = cfg.keepalive
self.timeout = cfg.timeout
self.publish_queue_size = cfg.publish_queue_size
# Runtime state
self._subscription_task: asyncio.Task[None] | None = None
@@ -359,7 +398,7 @@ class MqttNode(Node):
A dropped connection raises, and the supervisor decides when to
reconnect — the same arrangement the subscriber uses.
"""
import aiomqtt
aiomqtt = _aiomqtt()
queue = self._publish_queue
if queue is None:
@@ -372,6 +411,7 @@ class MqttNode(Node):
password=self.password,
identifier=self.client_id,
keepalive=self.keepalive,
timeout=self.timeout,
) as client:
self.report_health("ok")
while True:
@@ -384,7 +424,7 @@ class MqttNode(Node):
async def _publish_once(self, data: dict[str, Any]) -> None:
"""Connect, publish, disconnect — the unstarted node's path."""
import aiomqtt
aiomqtt = _aiomqtt()
async with aiomqtt.Client(
hostname=self.broker_host,
@@ -393,6 +433,7 @@ class MqttNode(Node):
password=self.password,
identifier=self.client_id,
keepalive=self.keepalive,
timeout=self.timeout,
) as client:
await self._publish_with(client, data)
@@ -452,7 +493,7 @@ class MqttNode(Node):
"""Run the task that owns this node's connection to the broker."""
if self._publish_queue is not None:
return
self._publish_queue = asyncio.Queue(maxsize=PUBLISH_QUEUE_SIZE)
self._publish_queue = asyncio.Queue(maxsize=self.publish_queue_size)
self._loop = asyncio.get_running_loop()
self._publisher_task = self._run_supervised("mqtt-out", self._publisher_loop)
@@ -461,11 +502,7 @@ class MqttNode(Node):
if self._publish_queue is None:
return
if self._publisher_task is not None:
self._publisher_task.cancel()
try:
await self._publisher_task
except (asyncio.CancelledError, Exception): # noqa: B014 - shutting down
pass
await self._cancel_task(self._publisher_task)
self._publisher_task = None
self._publish_queue = None
self._loop = None
@@ -515,11 +552,7 @@ class MqttNode(Node):
self._stop_event.set()
if self._subscription_task is not None:
self._subscription_task.cancel()
try:
await self._subscription_task
except asyncio.CancelledError:
pass
await self._cancel_task(self._subscription_task)
self._subscription_task = None
self._stop_event = None
@@ -553,7 +586,7 @@ class MqttNode(Node):
"""
import json
import aiomqtt
aiomqtt = _aiomqtt()
if not (self._stop_event and self._stop_event.is_set()):
try:
@@ -564,6 +597,7 @@ class MqttNode(Node):
password=self.password,
identifier=self.client_id,
keepalive=self.keepalive,
timeout=self.timeout,
) as client:
# Subscribe to every unique topic
for topic in self._topic_to_ports:
+1
View File
@@ -59,6 +59,7 @@ class ValidationIssue(BaseModel):
"unauthenticated_hook",
"self_loop_needs_initial",
"missing_source",
"node_unhealthy",
]
message: str
flow: str = ""
+11 -1
View File
@@ -215,9 +215,18 @@ class Placer:
said = [shape[2] for shape in capable if shape[2]]
ram = min(ram, max(said)) if said and ram else ram
if (cpus, gpus, ram) != (wanted.cpus, wanted.gpus, wanted.ram or 0):
# Cards are not detected, so a machine that has one still reports
# none until it is told — which reads as "no GPU here" to a node
# that then runs unserialised beside every other one.
hint = (
"; no machine here declares a GPU — `fluksio serve --gpus N` "
"(or FLOW_GPUS) says how many this one has"
if wanted.gpus and not gpus
else ""
)
logger.warning(
"%s asked for %d cpu(s), %d gpu(s) and %s MB; "
"the largest machine here can give %d, %d and %s",
"the largest machine here can give %d, %d and %s%s",
node or "a node",
wanted.cpus,
wanted.gpus,
@@ -225,6 +234,7 @@ class Placer:
cpus,
gpus,
ram or "no stated",
hint,
)
return cpus, gpus, ram
+47 -1
View File
@@ -769,7 +769,9 @@ class RunService:
# the isolation it wants, minus surviving the process.
self._state_factory = state_factory or (lambda _ns: MemoryState())
self.engine_name = f"{socket.gethostname()}-{os.getpid()}"[:64]
self.parallel = max(1, parallel)
# Taken as written: clamping a 0 up to 1 would hide a limit somebody
# set, and the pool below rejects an unusable one loudly anyway.
self.parallel = parallel
self._pool = ThreadPoolExecutor(
max_workers=self.parallel, thread_name_prefix="run"
)
@@ -844,6 +846,26 @@ class RunService:
# reference a python caller would have passed and every later reader —
# the digest, the cache, the run detail — sees one spelling.
params = resolve_references(flow, params, self._artifacts)
# What the run actually starts from, not only what was passed: an input
# left out takes its declared value, and a row that records `{}` cannot
# say which. Folded literally — an initial is a value from the
# definition, never a reference to resolve.
declared = {
one.spec.name: one.initial for one in flow.inputs if one.initial is not None
}
# The run's own seed fills an input of that name, outranking what the
# flow declares and outranked by one passed as a parameter — the order
# `seed_values` applies, moved to where the record is written.
if seed is not None and any(one.spec.name == "seed" for one in flow.inputs):
declared["seed"] = seed
params = {**declared, **params}
# And back the other way, so the run-level column holds the seed the
# run actually used however it arrived. Otherwise `--seed 1` fills one
# column and a declared seed the other, and that is the single field an
# export still has to coalesce.
resolved_seed = params.get("seed")
if isinstance(resolved_seed, int) and not isinstance(resolved_seed, bool):
seed = resolved_seed
# Checked here rather than in the driver: a caller who mistyped a
# parameter should be told now, not by a run that fails in a minute.
seed_values(flow, params, seed)
@@ -1174,6 +1196,7 @@ class RunService:
self._finish(run_id, status, reason, result, duration)
run.status = status
self._publish(run, "run_finished")
self._release_cards(run)
# Its values were only ever this run's; nothing reads them once it
# has a result. On Redis the namespace would expire anyway.
if state is not None and status != "error":
@@ -1182,6 +1205,29 @@ class RunService:
except Exception:
logger.warning("Could not clear state of run %s", run_id)
def _release_cards(self, run: Run) -> None:
"""Hand a GPU run's device memory back when the run is over.
The accountant frees the card the moment the node returns, but the
worker that ran on it is kept warm and a library that preallocated
most of the VRAM never gives it up — so the next process to want the
card found it taken by one sitting idle.
"""
# ponytail: retires every CUDA pool rather than the ones this run used,
# which needs no bookkeeping — a concurrent GPU run's busy worker only
# dies when it returns, which is when its own memory should go back
# anyway. What it costs is the warm worker of a *live* flow's GPU node.
# Track the pools per run if that ever matters.
if not (run.needs or {}).get("gpus"):
return
pool = getattr(self.controller, "workers", None)
if pool is None:
return
try:
pool.retire_gpu_children()
except Exception:
logger.warning("Could not retire the GPU workers of run %s", run.id)
def _record_node(self, run_id: str, outcome: NodeOutcome) -> None:
outputs = _cacheable(outcome)
row = RunNode(
+18
View File
@@ -321,6 +321,24 @@ class PythonWorkerPool:
for child in children:
child.respawn_all()
def retire_gpu_children(self) -> None:
"""Retire the pools holding a card, so the VRAM goes back.
A library like JAX takes most of the device when it imports and never
releases it, so a warm worker that has run one such node is a held
card and warm is the point of a pool, so nothing retires it. At the
end of a run there is something to key on: the environments carrying a
GPU assignment are exactly the pools that ran on one.
"""
with self._lock:
children = [
child
for key, child in self._children.items()
if any(name == "CUDA_VISIBLE_DEVICES" for name, _ in key)
]
for child in children:
child.respawn_all()
def _drain(self) -> list[_Worker | None]:
slots = []
while True:
+4 -1
View File
@@ -4,7 +4,6 @@ import logging
from collections.abc import AsyncIterator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
import sentry_sdk
from fastapi import FastAPI, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
@@ -51,6 +50,10 @@ def custom_generate_unique_id(route: APIRoute) -> str:
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
# Imported here rather than at the top: it is a `fluksio[server]` extra, so
# a pip install without one has no sentry to import — and no DSN either.
import sentry_sdk
# `enable_tracing` was removed in sentry-sdk 2.x; this is what it meant.
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), traces_sample_rate=1.0)
+17 -5
View File
@@ -388,11 +388,6 @@ def _check_signature(spec: NodeSpec) -> None:
fn, where = spec.fn, f"node '{spec.id}'"
if inspect.iscoroutinefunction(fn):
raise SyncError(f"{where}: async functions cannot be nodes")
if fn.__module__ == "__main__":
raise SyncError(
f"{where}: {fn.__name__}() is defined in a script run directly, so the "
"generated node could not import it — put it in an importable module"
)
generator = inspect.isgeneratorfunction(fn)
if generator and spec.single:
raise SyncError(
@@ -551,6 +546,21 @@ def _check(spec: NodeSpec, mode: str) -> dict[str, Any]:
}
def _refuse_main(spec: NodeSpec) -> None:
"""A node the generated body would have no way to import.
Refused where the body is written rather than where the decorator is: a
module defining nodes is then still runnable as a script, which is what a
`__main__` self-check beside them needs.
"""
if spec.fn.__module__ == "__main__":
raise SyncError(
f"node '{spec.id}': {spec.fn.__name__}() is defined in a script run "
"directly, so the generated node could not import it — put it in an "
"importable module"
)
def _code_of(spec: NodeSpec) -> dict[str, Any]:
"""What this node's function calls into, which its shim does not say.
@@ -559,6 +569,7 @@ def _code_of(spec: NodeSpec) -> dict[str, Any]:
side is the only one that can work it out at all: it has imported the
code, and the engine never does.
"""
_refuse_main(spec)
files = reached(spec.fn)
return {"code_files": files, "code_digest": digest_of(files)}
@@ -762,6 +773,7 @@ def _shim(spec: NodeSpec) -> str:
simply happens to be generated, which is why it says so and says where the
real thing is.
"""
_refuse_main(spec)
fn = spec.fn
where = inspect.getsourcefile(fn) or fn.__module__
repo = import_root(fn)
+120 -16
View File
@@ -19,6 +19,7 @@ import sys
import time
from collections.abc import Callable, Iterable, Iterator
from contextlib import contextmanager, nullcontext
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -136,8 +137,8 @@ def _import(root: str, dotted: str, expect: Path | None = None) -> None:
f" {actual}\n {expect}\n"
"Python keeps one module per name, and a node's generated body "
"imports by that name, so the second would run the first's code. "
"Put an `__init__.py` in each directory they become "
f"'<dir>.{dotted}' and stop colliding — or rename one of the files."
"Sync the directory they are both under — each then imports as "
"'<dir>.<file>' — or rename one of the files."
)
@@ -211,7 +212,14 @@ def discover(targets: list[str], keep_going: bool = False) -> list[Flow]:
if entry.is_dir():
_import_package(entry)
else:
_import(*_module_of(entry), entry)
# Named for where it sits under the directory being synced,
# so `dev/s1/study.py` imports as `s1.study` and one
# `study.py` per study collides with nothing. No
# `__init__.py` needed: the directories in between are
# namespace packages. A file at the root keeps its bare
# name, which is what it has always had.
dotted = ".".join(entry.relative_to(path).with_suffix("").parts)
_import(str(path), dotted, entry)
except SyncError:
# A name collision is never somebody else's problem: it would
# put the wrong file behind a node.
@@ -439,33 +447,52 @@ def _ask_params(definition: dict[str, Any]) -> dict[str, Any]:
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] = {}
# Collected as written and typed afterwards, so a name this flow does not
# have is refused by name rather than by whatever its value failed to parse
# as: `--param lr=0.002` is a sweep's spelling, and said so it reads as an
# input called `param` holding unparseable json.
raw: dict[str, str | bool] = {}
pending: str | None = None
for token in rest:
if token.startswith("--"):
if pending is not None:
# A flag with no value is a flag: `--resume` means true.
params[pending] = True
raw[pending] = True
name, sep, value = token[2:].partition("=")
# Only the name is spelled with dashes; a value may hold one, and
# `--lr=1e-4` is the case that says so.
pending = name.replace("-", "_")
if sep:
params[pending] = _coerce(value, types.get(pending, "json"))
raw[pending] = value
pending = None
continue
if pending is None:
raise SyncError(f"unexpected argument '{token}'")
params[pending] = _coerce(token, types.get(pending, "json"))
raw[pending] = token
pending = None
if pending is not None:
params[pending] = True
unknown = sorted(set(params) - set(types))
raw[pending] = True
unknown = sorted(set(raw) - set(types))
if unknown:
hint = ""
if unknown[0] == "param":
hint = (
" — one value is `--<name> <value>`; several is a sweep: "
"`fluksio sweep --param name=v1,v2`"
)
raise SyncError(
f"'{unknown[0]}' is not an input of this flow (it takes "
f"{', '.join(sorted(types)) or 'none'})"
f"{', '.join(sorted(types)) or 'none'}){hint}"
)
params: dict[str, Any] = {}
for key, written in raw.items():
if isinstance(written, bool):
params[key] = written
continue
try:
params[key] = _coerce(written, types[key])
except ValueError as exc:
raise SyncError(f"'{key}' takes {types[key]}: {exc}") from exc
return params
@@ -778,7 +805,8 @@ def _status_screen(client: Client) -> Any:
)
+ Text(
f"{str(row.get('flow', '')):<16}"
f"{(row.get('duration_ms') or 0) / 1000:7.1f}s",
f"{(row.get('duration_ms') or 0) / 1000:7.1f}s"
f" {_ago(row.get('finished_at') or row.get('created_at')):>9}",
style="dim",
)
)
@@ -790,6 +818,7 @@ def _status_screen(client: Client) -> Any:
)
parts.append(
Text(f" {where} ", style="red")
+ Text(f"{_ago(event.get('ts')):>9} ", style="dim")
+ Text(str(event.get("detail", ""))[:100], style="dim")
)
return Group(*parts)
@@ -831,9 +860,9 @@ def cmd_status(args: argparse.Namespace) -> int:
#: value is read.
PARAMS_WIDTH = 80
#: What the columns before the inputs take: the id, status, flow, duration and
#: stamp, with their spacing.
LISTING_WIDTH = 84
#: What the columns before the inputs take: the id, status, flow, duration,
#: age and stamp, with their spacing.
LISTING_WIDTH = 95
def _declared(client: Client, flows: Iterable[str]) -> dict[str, dict[str, Any]]:
@@ -857,6 +886,28 @@ def _declared(client: Client, flows: Iterable[str]) -> dict[str, dict[str, Any]]
return known
def _ago(stamp: Any) -> str:
"""How long ago something happened, in the notation the screens use.
A duration and an age read together, so they are spelled the same way:
seconds, then minutes, hours, days.
"""
if not stamp:
return ""
try:
then = datetime.fromisoformat(str(stamp))
except ValueError:
return ""
if then.tzinfo is None:
# Everything the engine records is UTC; only some spellings say so.
then = then.replace(tzinfo=UTC)
seconds = max((datetime.now(UTC) - then).total_seconds(), 0)
for span, unit in ((86400, "d"), (3600, "h"), (60, "min")):
if seconds >= span:
return f"{seconds / span:.0f}{unit} ago"
return f"{seconds:.0f}s ago"
def _stamp(row: dict[str, Any]) -> str:
"""What code a run ran: the commit, whether it was dirty, and the digest.
@@ -899,11 +950,49 @@ def cmd_runs(args: argparse.Namespace) -> int:
params = params[: room - 3] + "..."
_say(
f"{row['id']} {_status(row['status'], 9)} {row['flow']:<16} "
f"{row['duration_ms'] / 1000:7.1f}s {_stamp(row):<22} {params}"
f"{row['duration_ms'] / 1000:7.1f}s {_ago(row.get('created_at')):>9} "
f"{_stamp(row):<22} {params}"
)
return 0
def _artifacts(client: Client, args: argparse.Namespace) -> int:
"""List a run's files, or write one of them here."""
handle = RunHandle(client, args.run_id, client.run(args.run_id))
rows = handle.artifacts
if not args.name:
if not rows:
_say("This run produced no artifacts.")
return 0
for row in rows:
named = row.get("filename") or ""
_say(
f"{str(row['name']):<24} {row['size']:>10} B "
f"{row.get('media_type', ''):<24} {named}"
)
return 0
data = handle.download(args.name)
# The name it was written under reads better than the message's, which is
# chosen for the graph; `--out` beats both.
match = next((row for row in rows if row.get("name") == args.name), {})
out = Path(args.out or match.get("filename") or args.name)
out.write_bytes(data)
_say(f"{out} {len(data)} bytes")
return 0
def cmd_artifacts(args: argparse.Namespace) -> int:
try:
with _client_for(args, retries=0) as client:
return _artifacts(client, args)
except KeyError as exc:
return _fail(str(exc.args[0]))
except (SyncError, ApiError) as exc:
return _fail(str(exc))
except httpx.HTTPError as exc:
return _unreachable(exc)
def cmd_flavors(args: argparse.Namespace) -> int:
"""The named sizes a node can ask for."""
try:
@@ -1267,6 +1356,21 @@ def add_parsers(subparsers: Any) -> None:
with_engine(parser, local=True)
parser.set_defaults(func=cmd_runs)
parser = subparsers.add_parser(
"artifacts", help="the files a run produced; name one to download it"
)
parser.add_argument("run_id")
parser.add_argument("name", nargs="?", default="")
parser.add_argument(
"-o",
"--out",
default="",
metavar="PATH",
help="where to write it (default: the name it was saved under)",
)
with_engine(parser, local=True)
parser.set_defaults(func=cmd_artifacts)
parser = subparsers.add_parser(
"flavors", help="the named resource sizes a node can ask for"
)
@@ -1375,7 +1479,7 @@ def add_parsers(subparsers: Any) -> None:
metavar="A,B",
help=(
"the inputs to put in columns, dotted into a record "
"(default: the ones that vary)"
"(default: every input the runs recorded)"
),
)
sub.add_argument(
+3 -3
View File
@@ -428,9 +428,9 @@ class Client:
) -> list[dict[str, Any]]:
"""One row per run: its inputs as columns, its final numbers, its code.
The arm-comparison table. The inputs kept are the ones that vary
across the selection unless ``params`` names them, which is the axis a
sweep is read along.
The arm-comparison table. Every input the selection recorded is a
column, so the schema does not depend on which runs were asked for;
``params`` narrows it to the axis a sweep is read along.
"""
query: dict[str, Any] = {}
if params:
+342
View File
@@ -0,0 +1,342 @@
"""The dashboard `fluksio serve` opens at a terminal.
A supervisor, not an engine: it starts `serve --plain` as a child and talks to
it over the same HTTP API every other client uses. So the engine is a process
that can be stopped and started under the screen watching it, an engine
somebody else started can be adopted rather than duplicated, and quitting the
dashboard is not the same as stopping the engine.
Nothing of the engine is imported here that lives in the child.
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
from pathlib import Path
from typing import Any
from textual import work
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, DataTable, Footer, Header, Input, Label, RichLog
from fluksio.cli import (
DEFAULT_PORT,
DEFAULT_PORTAL,
_data_dir,
_token_for,
probe_engine,
read_pidfile,
)
from fluksio.sdk.cli import WATCH_INTERVAL_S, _status_screen
from fluksio.sdk.client import Client, config_path
#: How long to keep asking the child for the credential it writes on the way
#: up, before giving up and letting the panels say so.
STARTUP_TRIES = 60
def child_argv(argv: list[str]) -> list[str]:
"""The same command, told to serve without a dashboard of its own.
Every flag is passed through: whatever `serve` was asked for is what the
engine under this screen is running with.
"""
passed = [flag for flag in argv if flag != "--plain"]
return [sys.executable, "-m", "fluksio.cli", *passed, "--plain"]
class Enroll(ModalScreen[tuple[str, str] | None]):
"""The claim code a portal minted, and which portal minted it."""
BINDINGS = [("escape", "dismiss(None)", "cancel")]
def compose(self) -> ComposeResult:
with Vertical(id="enroll"):
yield Label("Pair this installation with a portal")
yield Input(placeholder="claim code", id="code")
yield Input(value=DEFAULT_PORTAL, id="portal")
with Horizontal():
yield Button("Enroll", variant="primary", id="go")
yield Button("Cancel", id="cancel")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel":
self.dismiss(None)
return
code = self.query_one("#code", Input).value.strip()
portal = self.query_one("#portal", Input).value.strip()
self.dismiss((code, portal or DEFAULT_PORTAL) if code else None)
class ServeApp(App[int]):
"""One screen: how the engine is, what has run, and what it is saying."""
CSS = """
#status { height: auto; padding: 0 1; }
#runs { height: 2fr; }
#log { height: 1fr; border-top: solid $panel; }
#enroll { width: 60; height: auto; padding: 1 2; background: $surface; }
"""
BINDINGS = [
("q", "quit", "quit (engine keeps running)"),
("s", "stop_start", "stop/start"),
("r", "restart", "restart"),
("c", "cancel_run", "cancel run"),
("e", "enroll", "enroll"),
]
def __init__(self, args: argparse.Namespace) -> None:
super().__init__()
self.args = args
self.data_dir: Path = _data_dir(args.data_dir, args.shared)
self.child: subprocess.Popen[str] | None = None
#: The pid of an engine this dashboard did not start. Only ever one
#: whose data directory is this one — the probe is what proves it.
self.adopted: int | None = None
self.client: Client | None = None
self.url = ""
# -- layout ---------------------------------------------------------------
def compose(self) -> ComposeResult:
yield Header()
yield RichLog(id="status", markup=True, wrap=True)
table: DataTable[str] = DataTable(id="runs", cursor_type="row")
yield table
yield RichLog(id="log", markup=False, highlight=False, max_lines=2000)
yield Footer()
def on_mount(self) -> None:
self.title = f"fluksio — {self.data_dir}"
table = self.query_one("#runs", DataTable)
table.add_columns("run", "status", "flow", "took", "params")
self.start_engine(first=True)
self.set_interval(WATCH_INTERVAL_S, self.refresh_panels)
# -- the engine under the screen ------------------------------------------
def note(self, message: str) -> None:
self.query_one("#log", RichLog).write(message)
def start_engine(self, first: bool = False) -> None:
"""Adopt whatever is already serving this directory, or start one."""
host = self.args.host
reachable = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
wanted = self.args.port or DEFAULT_PORT
url = f"http://{reachable}:{wanted}"
who = probe_engine(url, _token_for(self.data_dir)) if first else "other"
if who == "ours":
running = read_pidfile(self.data_dir)
self.adopted = running["pid"] if running else None
self.url = url
named = f" (pid {self.adopted})" if self.adopted else ""
self.note(f"Adopted the engine already serving this directory{named}.")
if self.adopted is None:
self.note("It wrote no pidfile, so this screen cannot stop it.")
self.connect()
return
if who == "foreign":
self.note(f"Port {wanted} holds another installation's Fluksio.")
self.child = subprocess.Popen( # noqa: S603
child_argv(sys.argv[1:]),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
self.tail_child(self.child)
self.await_engine()
@work(thread=True, exclusive=False)
def tail_child(self, child: subprocess.Popen[str]) -> None:
"""The engine's own output, which is why a second terminal was needed."""
if child.stdout is None:
return
for line in child.stdout:
self.call_from_thread(self.note, line.rstrip())
self.call_from_thread(self.note, f"The engine stopped ({child.wait()}).")
@work(thread=True, exclusive=True, group="startup")
def await_engine(self) -> None:
"""Wait for the child to say where it landed, then talk to it.
The port is the child's to choose — it moves off a taken one — and
`client.json` is where it says which it took.
"""
import time
for _ in range(STARTUP_TRIES):
if self.child is not None and self.child.poll() is not None:
return
try:
stored = json.loads(config_path(self.data_dir).read_text())
except (OSError, ValueError):
stored = {}
if stored.get("url") and stored.get("token"):
self.url = str(stored["url"])
self.call_from_thread(self.connect)
return
time.sleep(0.5)
self.call_from_thread(self.note, "The engine did not come up.")
def connect(self) -> None:
stored = config_path(self.data_dir)
try:
config = json.loads(stored.read_text())
self.client = Client(
url=str(config["url"]), token=str(config["token"]), retries=0
)
except (OSError, ValueError, KeyError) as exc:
self.note(f"No credential to talk to the engine with: {exc}")
return
self.url = self.client.url
self.refresh_panels()
def engine_pid(self) -> int | None:
return self.child.pid if self.child is not None else self.adopted
def stop_engine(self) -> None:
if self.child is not None:
self.note(f"Stopping the engine (pid {self.child.pid}).")
self.child.terminate()
self.child = None
elif self.adopted is not None:
self.note(f"Stopping the adopted engine (pid {self.adopted}).")
try:
os.kill(self.adopted, signal.SIGTERM)
except OSError as exc:
self.note(f"Could not stop it: {exc}")
self.adopted = None
self.client = None
# -- what the panels show -------------------------------------------------
def refresh_panels(self) -> None:
if self.client is not None:
self.read_engine()
@work(thread=True, exclusive=True, group="poll")
def read_engine(self) -> None:
client = self.client
if client is None:
return
try:
screen = _status_screen(client)
rows = client.runs(limit=20)
except Exception as exc:
self.call_from_thread(self.show_offline, exc)
return
self.call_from_thread(self.show, screen, rows)
def show(self, screen: Any, rows: list[dict[str, Any]]) -> None:
status = self.query_one("#status", RichLog)
status.clear()
status.write(screen)
table = self.query_one("#runs", DataTable)
table.clear()
for row in rows:
table.add_row(
str(row.get("id", ""))[-8:],
str(row.get("status", "")),
str(row.get("flow", "")),
f"{(row.get('duration_ms') or 0) / 1000:.1f}s",
json.dumps(row.get("params") or {})[:60],
key=str(row.get("id", "")),
)
def show_offline(self, exc: Exception) -> None:
status = self.query_one("#status", RichLog)
status.clear()
which = "starting" if self.engine_pid() is not None else "not running"
status.write(f"[yellow]The engine is {which}.[/] ({type(exc).__name__})")
# -- keys -----------------------------------------------------------------
def action_stop_start(self) -> None:
if self.engine_pid() is not None:
self.stop_engine()
else:
self.start_engine()
def action_restart(self) -> None:
self.stop_engine()
self.start_engine()
def action_cancel_run(self) -> None:
table = self.query_one("#runs", DataTable)
if self.client is None or not table.row_count:
return
# The cell holds the tail of the id, which is what fits; the row's key
# is the whole of it, which is what the engine is asked about.
key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
if key.value:
self.cancel_run(str(key.value))
@work(thread=True)
def cancel_run(self, run_id: str) -> None:
client = self.client
if client is None:
return
try:
client.cancel(run_id)
except Exception as exc:
self.call_from_thread(self.note, f"Could not cancel {run_id}: {exc}")
return
self.call_from_thread(self.note, f"Cancelled {run_id}.")
self.call_from_thread(self.refresh_panels)
def action_enroll(self) -> None:
self.push_screen(Enroll(), self.enrolled)
def enrolled(self, answer: tuple[str, str] | None) -> None:
if answer is None:
return
code, portal = answer
self.run_enroll(code, portal)
@work(thread=True)
def run_enroll(self, code: str, portal: str) -> None:
"""`fluksio enroll`, as its own process for the same reason serve is.
A running engine picks the configuration up on its own; this screen
only has to report what the command said.
"""
done = subprocess.run( # noqa: S603
[
sys.executable,
"-m",
"fluksio.cli",
"enroll",
code,
"--portal",
portal,
"--data-dir",
str(self.data_dir),
],
capture_output=True,
text=True,
)
for line in (done.stdout + done.stderr).splitlines():
self.call_from_thread(self.note, line)
def run_tui(args: argparse.Namespace) -> int:
"""Open the dashboard, and say what is still running when it closes."""
app = ServeApp(args)
app.run()
pid, url = app.engine_pid(), app.url
if pid is not None:
print(f"The engine is still running: pid {pid} at {url or 'its port'}.")
print(f" fluksio serve reattaches to it; kill {pid} stops it.")
return 0
+6 -1
View File
@@ -4,7 +4,6 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import emails # type: ignore
import jwt
from jinja2 import Template
from jwt.exceptions import InvalidTokenError
@@ -37,6 +36,12 @@ def send_email(
html_content: str = "",
) -> None:
assert settings.emails_enabled, "no provided configuration for email variables"
try:
import emails # type: ignore
except ImportError:
raise RuntimeError(
"sending mail needs the server extra: pip install 'fluksio[server]'"
) from None
message = emails.Message(
subject=subject,
html=html_content,
+17 -6
View File
@@ -1,6 +1,6 @@
[project]
name = "fluksio"
version = "0.1.4"
version = "0.1.4+dev"
description = "Node-based automation engine: flows, dashboards, batch runs"
readme = "README.md"
license = "AGPL-3.0-or-later"
@@ -25,15 +25,12 @@ dependencies = [
"fastapi[standard]<1.0.0,>=0.114.2",
"python-multipart<1.0.0,>=0.0.7",
"email-validator<3.0.0.0,>=2.1.0.post1",
"tenacity<9.0.0,>=8.2.3",
"pydantic>2.0",
"emails<1.0,>=0.6",
"jinja2<4.0.0,>=3.1.4",
"alembic<2.0.0,>=1.12.1",
"httpx<1.0.0,>=0.25.1",
"sqlmodel<1.0.0,>=0.0.21",
"pydantic-settings<3.0.0,>=2.2.1",
"sentry-sdk[fastapi]>=2.20.0",
"pyjwt<3.0.0,>=2.8.0",
"pwdlib[argon2,bcrypt]>=0.3.0",
"numpy>=2.2.6",
@@ -43,8 +40,6 @@ dependencies = [
# copied onto other people's machines and stays dependency-free.
"orjson>=3.10",
"cryptography>=44.0.0",
"aiomqtt>=2.0.0",
"influxdb-client[async]>=1.40.0",
"croniter>=1.3.0",
"mcp>=1.29,<2",
"fluksio-worker>=0.1,<0.2",
@@ -55,6 +50,9 @@ dependencies = [
# `fluksio status` draws with it. Already here underneath fastapi's CLI,
# named because a command that depends on it should say so.
"rich>=13",
# `fluksio serve` opens a dashboard with it at a terminal. Pure python and
# mostly rich underneath, which is already here.
"textual>=1.0",
]
[project.optional-dependencies]
@@ -62,6 +60,17 @@ dependencies = [
# extra rather than a dependency: csv and jsonl need nothing, and pyarrow is
# tens of megabytes for whoever wants dtypes kept.
parquet = ["pyarrow>=17"]
# What a deployment has and a laptop does not: the device connectors, outbound
# mail and error reporting. The engine, the CLI and every python node work
# without them — `pip install fluksio` in a data-science environment is the
# case this exists for, and lxml and the aiohttp stack are most of its wait.
# Each import is guarded and names this extra. The image installs it.
server = [
"emails<1.0,>=0.6",
"sentry-sdk[fastapi]>=2.20.0",
"aiomqtt>=2.0.0",
"influxdb-client[async]>=1.40.0",
]
[project.urls]
Homepage = "https://fluksio.com"
@@ -146,6 +155,8 @@ ignore = [
# It talks to whoever ran it; that is what a command line is.
"fluksio/cli.py" = ["T201"]
"fluksio/sdk/cli.py" = ["T201"]
# What it prints is the line left in the terminal after the dashboard closes.
"fluksio/tui.py" = ["T201"]
# Node functions take `params` whether or not they use it — that is the
# contract the engine calls them with.
"fluksio/flow/nodes.py" = ["ARG001", "ARG002"]
@@ -0,0 +1,88 @@
"""Dashboards over HTTP: saving one, which is also how the first one is made."""
from fastapi.testclient import TestClient
from fluksio.core.config import settings
PREFIX = f"{settings.API_V1_STR}/dashboards"
def a_dashboard(name: str) -> dict:
return {
"name": name,
"title": "Hall",
"icon": "gauge",
"widgets": [
{
"id": "temperature",
"type": "stat",
"title": "Temperature",
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
"config": {"message": "house.temperature", "dtype": "float"},
}
],
"settings": {"theme": {"value": "dark", "message": "", "dtype": "str"}},
"version": 0,
}
def test_a_save_creates_a_dashboard_that_does_not_exist_yet(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A first draft, not a 404: creating one *is* saving it at version 0."""
body = a_dashboard("put_creates")
response = client.put(
f"{PREFIX}/put_creates", headers=superuser_token_headers, json=body
)
assert response.status_code == 200, response.text
saved = response.json()
assert saved["version"] == 1 and saved["has_draft"] is True
# A draft alone: nothing was published, so no panel can be shown it.
assert (
client.get(f"{PREFIX}/put_creates", headers=superuser_token_headers).status_code
== 404
)
draft = client.get(
f"{PREFIX}/put_creates?draft=true", headers=superuser_token_headers
)
assert draft.json()["widgets"] == body["widgets"]
def test_a_save_of_an_existing_dashboard_keeps_what_it_did_not_change(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
seeded = a_dashboard("put_updates")
first = client.put(
f"{PREFIX}/put_updates", headers=superuser_token_headers, json=seeded
).json()
renamed = {**first, "widgets": [{**first["widgets"][0], "title": "Outside"}]}
second = client.put(
f"{PREFIX}/put_updates", headers=superuser_token_headers, json=renamed
)
assert second.status_code == 200, second.text
stored = second.json()
assert stored["version"] == first["version"] + 1
assert stored["widgets"][0]["title"] == "Outside"
# Everything the edit did not name is still what was first written.
assert stored["icon"] == seeded["icon"]
assert stored["settings"] == seeded["settings"]
assert stored["widgets"][0]["layout"] == seeded["widgets"][0]["layout"]
assert stored["widgets"][0]["config"] == seeded["widgets"][0]["config"]
def test_a_save_based_on_a_version_someone_moved_past_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
body = a_dashboard("put_conflicts")
client.put(f"{PREFIX}/put_conflicts", headers=superuser_token_headers, json=body)
stale = client.put(
f"{PREFIX}/put_conflicts", headers=superuser_token_headers, json=body
)
assert stale.status_code == 409
assert stale.json()["detail"]["current_version"] == 1
@@ -127,6 +127,37 @@ def test_a_flow_that_cannot_run_makes_the_summary_degraded(
assert not any("hooky" in problem for problem in body["problems"])
def test_a_down_node_makes_the_summary_degraded(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A connector that cannot reach its device is not a flow that cannot run.
It is counted on its own, so the flow keeps running and "invalid" stays
about validation.
"""
from fluksio.flow.controller import LoadedNode
controller = client.app.state.flow_controller
before = controller.loaded
controller.loaded = {
"house.owm": LoadedNode(
id="house.owm",
flow="house",
health="down",
health_detail="ConnectionError: name resolution failed",
)
}
try:
body = client.get(f"{PREFIX}/summary", headers=superuser_token_headers).json()
finally:
controller.loaded = before
assert body["status"] == "degraded"
assert body["nodes"]["unhealthy"] == 1
assert any("down" in problem for problem in body["problems"])
assert body["flows"]["invalid"] == 0
def test_the_history_reads_back(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
+195 -8
View File
@@ -400,6 +400,97 @@ def test_a_repeated_submit_returns_the_run_it_already_made():
assert again.id == "dedup-1"
class _OneFlow:
"""A controller that has exactly one flow and no engine behind it."""
def __init__(self, flow):
self.store = self
self._flow = flow
def read_flow(self, name, draft=False):
return self._flow
def head(self):
return ""
class _Collect:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def test_a_run_records_the_inputs_it_actually_starts_from():
"""An input left out takes its declared value, and the row says so.
`params = {}` could not tell a run that took every default from one
submitted with those same numbers spelled out and an export of the
first had a blank cell where its `lr` should be.
"""
flow = FlowDef(
name="study",
mode="batch",
inputs=[
FlowInput(spec=MessageSpec(name="lr", dtype=DType.FLOAT), initial=0.01),
FlowInput(spec=MessageSpec(name="epochs", dtype=DType.INT)),
],
)
service = RunService(controller=_OneFlow(flow), queue=_Collect())
made = []
try:
defaulted = service.submit("study", {"epochs": 5})
made.append(defaulted.id)
assert defaulted.params == {"lr": 0.01, "epochs": 5}
# Spelling out the declared value is the same run, and now reads as it.
spelled = service.submit("study", {"lr": 0.01, "epochs": 5})
made.append(spelled.id)
assert spelled.params_digest == defaulted.params_digest
finally:
with Session(db_engine) as session:
for run in session.exec(select(Run).where(col(Run.id).in_(made))).all():
session.delete(run)
session.commit()
def test_the_seed_is_recorded_the_same_way_however_it_arrived():
"""One field an export should not have to coalesce two columns for.
`--seed 1` fills the run's own column; a flow declaring a `seed` input
fills the parameter. Both are the seed the run used, so both are written.
"""
flow = FlowDef(
name="seeded",
mode="batch",
inputs=[FlowInput(spec=MessageSpec(name="seed", dtype=DType.INT), initial=42)],
)
service = RunService(controller=_OneFlow(flow), queue=_Collect())
made = []
try:
passed = service.submit("seeded", {}, seed=1)
made.append(passed.id)
assert (passed.seed, passed.params) == (1, {"seed": 1})
# Nothing passed: the declared value is the seed it ran with, and the
# run-level column says so rather than staying empty.
defaulted = service.submit("seeded", {})
made.append(defaulted.id)
assert (defaulted.seed, defaulted.params) == (42, {"seed": 42})
# A parameter still outranks the run's own seed, as it always has —
# and the column follows it rather than reporting the one that lost.
both = service.submit("seeded", {"seed": 7}, seed=1)
made.append(both.id)
assert (both.seed, both.params) == (7, {"seed": 7})
finally:
with Session(db_engine) as session:
for run in session.exec(select(Run).where(col(Run.id).in_(made))).all():
session.delete(run)
session.commit()
def test_a_key_nobody_used_submits_normally(
client, superuser_token_headers, monkeypatch
):
@@ -432,6 +523,99 @@ def test_overview_is_not_read_as_a_run_id(client, superuser_token_headers):
assert isinstance(answer.json(), list)
# -----------------------------------------------------------------------------
# Deleting a run
#
# The route owns the four statements; what these guard is that it takes the
# children with it and refuses a run the driver is still writing to.
# -----------------------------------------------------------------------------
@pytest.fixture
def deletable_run():
"""One finished run with a node, a number and an artifact row hanging off it."""
run_id = "del-1"
with Session(db_engine) as session:
session.add(
Run(id=run_id, flow="deleted", status="ok", created_at=datetime.now(UTC))
)
session.add(RunNode(run_id=run_id, node="deleted.a", status="ok"))
session.add(RunMetric(run_id=run_id, name="deleted.loss", step=0, value=1.0))
session.add(
RunArtifact(
run_id=run_id,
name="deleted.out",
filename="out.bin",
node="a",
digest="d" * 64,
size=7,
)
)
session.commit()
yield run_id
with Session(db_engine) as session:
run = session.get(Run, run_id)
if run is not None:
session.delete(run)
session.commit()
def test_deleting_a_run_takes_its_children_with_it(
client, superuser_token_headers, deletable_run
):
"""No foreign key cascades here, so the route has to do it itself."""
answer = client.delete(
f"{settings.API_V1_STR}/runs/{deletable_run}", headers=superuser_token_headers
)
assert answer.status_code == 204
with Session(db_engine) as session:
assert session.get(Run, deletable_run) is None
for table in (RunNode, RunMetric, RunArtifact):
left = session.exec(
select(table).where(col(table.run_id) == deletable_run)
).all()
assert left == [], f"{table.__name__} rows outlived the run"
def test_deleting_a_run_that_is_not_there_is_a_404(client, superuser_token_headers):
answer = client.delete(
f"{settings.API_V1_STR}/runs/nope-1", headers=superuser_token_headers
)
assert answer.status_code == 404
def test_a_running_run_is_refused_rather_than_raced(client, superuser_token_headers):
"""The driver writes its nodes back at the end; they would have no run."""
run_id = "del-live"
with Session(db_engine) as session:
session.add(
Run(
id=run_id,
flow="deleted",
status="running",
created_at=datetime.now(UTC),
)
)
session.commit()
try:
answer = client.delete(
f"{settings.API_V1_STR}/runs/{run_id}", headers=superuser_token_headers
)
assert answer.status_code == 409
assert "Cancel it" in answer.json()["detail"]
with Session(db_engine) as session:
assert session.get(Run, run_id) is not None
finally:
with Session(db_engine) as session:
run = session.get(Run, run_id)
if run is not None:
session.delete(run)
session.commit()
# -----------------------------------------------------------------------------
# A cached node's curve
#
@@ -723,10 +907,10 @@ def test_an_export_strides_each_series_and_names_its_run(
assert all(steps == [0, 2] for steps in curves.values())
def test_an_exported_run_row_carries_the_inputs_that_vary(
def test_an_exported_run_row_carries_every_recorded_input(
client, superuser_token_headers, exported
):
"""The sweep axis becomes columns; what every run shares stays out of them."""
"""Every input is a column, so the schema does not move with the selection."""
def export(**extra):
answer = client.get(
@@ -740,12 +924,15 @@ def test_an_exported_run_row_carries_the_inputs_that_vary(
rows = _lines(export(format="jsonl"))
assert [row["id"] for row in rows] == ["exp-1", "exp-0"]
assert {row["param.lr"] for row in rows} == {0.1, 0.01}
# `epochs` is the same on both runs, so it is not what they differ by, and
# neither is the model inside the config — but the depth beside it is.
assert "param.epochs" not in rows[0]
assert "param.config.model" not in rows[0]
# `epochs` is the same on both runs and stays a column anyway: which runs
# were asked for is not something a downstream filter should have to know.
assert rows[0]["param.epochs"] == 10
assert rows[0]["param.config.model"] == "mlp"
assert {row["param.config.depth"] for row in rows} == {1, 2}
assert "param.epochs" in _lines(export(format="jsonl", params="epochs"))[0]
narrowed = _lines(export(format="jsonl", params="epochs"))[0]
assert "param.epochs" in narrowed
assert "param.lr" not in narrowed
# A number inside a record is a column of its own, however deep; a string
# is not one of the run's numbers wherever it sits.
@@ -762,7 +949,7 @@ def test_an_exported_run_row_carries_the_inputs_that_vary(
header = export().text.splitlines()[0]
assert header.startswith("id,flow,status,")
assert header.endswith(
"param.config.depth,param.lr,"
"param.config.depth,param.config.model,param.epochs,param.lr,"
"metric.acc,metric.final_metrics.train_loss,"
"metric.test_metrics.known.perfect"
)
+80
View File
@@ -0,0 +1,80 @@
"""The one index the global search matches against."""
from fastapi.testclient import TestClient
from fluksio.core.config import settings
PREFIX = f"{settings.API_V1_STR}/search"
FLOWS = f"{settings.API_V1_STR}/flows"
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
SECRETS = f"{settings.API_V1_STR}/secrets"
def test_search_requires_authentication(client: TestClient) -> None:
assert client.get(f"{PREFIX}/").status_code == 401
def test_index_reaches_inside_flows_and_dashboards(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A node and a widget are the point: neither is on any list endpoint."""
client.put(
f"{FLOWS}/searchable",
headers=superuser_token_headers,
json={
"name": "searchable",
"title": "Searchable",
"nodes": [{"id": "sensor", "type": "python", "title": "Hall sensor"}],
},
)
client.put(
f"{DASHBOARDS}/hall",
headers=superuser_token_headers,
json={
"name": "hall",
"title": "Hall",
"widgets": [
{
"id": "temperature",
"type": "stat",
"title": "Temperature",
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
"config": {"message": "hall.temperature", "dtype": "float"},
}
],
"version": 0,
},
)
entries = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
# Keyed on the parent too: an id is only unique within the document it is
# in, and the other suites seed their own `sensor` and `temperature`.
found = {
(entry["category"], entry["parent"], entry["name"]): entry for entry in entries
}
assert found[("flow", "", "searchable")]["title"] == "Searchable"
assert found[("node", "searchable", "sensor")]["title"] == "Hall sensor"
assert found[("node", "searchable", "sensor")]["kind"] == "python"
assert found[("dashboard", "", "hall")]["title"] == "Hall"
assert found[("widget", "hall", "temperature")]["title"] == "Temperature"
assert found[("widget", "hall", "temperature")]["kind"] == "stat"
def test_secrets_are_named_only_to_a_superuser(
client: TestClient,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
) -> None:
client.put(
f"{SECRETS}/broker_password",
headers=superuser_token_headers,
json={"value": "hunter2"},
)
def secrets(headers: dict[str, str]) -> set[str]:
entries = client.get(f"{PREFIX}/", headers=headers).json()
return {e["name"] for e in entries if e["category"] == "secret"}
assert "broker_password" in secrets(superuser_token_headers)
assert secrets(normal_user_token_headers) == set()
+34
View File
@@ -104,6 +104,40 @@ def test_a_failing_poll_reports_down_and_keeps_going():
assert health[-1][0] == "ok"
def test_an_undeclared_port_keeps_failing_until_the_node_declares_it():
"""A publication that raised is retried, not remembered as published.
The loop remembers what it published. If it remembered what it read, a
value the node cannot publish would be skipped on the next poll, the poll
would succeed, and the node would go back to reporting itself healthy with
its port still dark.
"""
class Chatty(Sensor):
"""Reads a port it never declared."""
async def poll(self) -> dict[str, Any]:
self.polls += 1
return {"reading": 21.5, "lat": 48.1}
health: list[tuple[str, str | None]] = []
node = Chatty(
provides=[MessageSpec(name="reading", dtype=DType.FLOAT)],
params={"poll_interval": 0.01},
)
node.assign_flow("demo", "sensor")
node._on_health = lambda _node, status, detail: health.append((status, detail))
pipeline = Pipeline(nodes=[node])
run_briefly(node)
assert pipeline.state.get("demo.reading") is None
assert health[-1][0] == "down"
assert "NodeOutputError" in (health[-1][1] or "")
# Still failing on the last poll, not just the first.
assert len([entry for entry in health if entry[0] == "down"]) > 1
class Actuator(ConnectorNode):
"""A connector that commands something instead of reading it."""
+50
View File
@@ -0,0 +1,50 @@
"""Tearing a node down must not swallow a cancellation meant for the caller."""
import asyncio
import pytest
from fluksio.flow.connector import ConnectorNode
from fluksio.flow.nodes import DelayNode
async def stubborn() -> None:
"""A loop whose shutdown does not answer the first cancellation."""
try:
await asyncio.sleep(3600)
except asyncio.CancelledError:
await asyncio.sleep(3600)
def test_stop_cron_lets_the_callers_cancellation_through():
async def scenario() -> None:
node = DelayNode(params={"cron": "* * * * *"})
node._stop_cron = asyncio.Event()
node._cron_task = asyncio.create_task(stubborn())
stopping = asyncio.create_task(node.stop_cron())
await asyncio.sleep(0.05) # let it reach the await on the cron task
stopping.cancel()
with pytest.raises(asyncio.CancelledError):
await stopping
node._cron_task.cancel()
asyncio.run(scenario())
def test_connector_stop_lets_the_callers_cancellation_through():
async def scenario() -> None:
node = ConnectorNode()
node._stop_event = asyncio.Event()
node._poll_task = asyncio.create_task(stubborn())
stopping = asyncio.create_task(node.stop())
await asyncio.sleep(0.05) # let it reach the await on the poll task
stopping.cancel()
with pytest.raises(asyncio.CancelledError):
await stopping
node._poll_task.cancel()
asyncio.run(scenario())
+41
View File
@@ -197,6 +197,47 @@ def test_a_flux_request_is_run_rather_than_written(monkeypatch):
assert out == {"answer": {"rows": [], "range_s": 3600}}
def test_the_configured_timeout_reaches_the_influx_client(monkeypatch):
"""The client counts in milliseconds; the param is seconds like its peers."""
import influxdb_client
from fluksio.flow.nodes import InfluxDbNode
seen: dict = {}
class FakeClient:
def __init__(self, **kwargs):
seen.update(kwargs)
def __enter__(self):
return self
def __exit__(self, *_):
return False
def query_api(self):
return self
def query(self, *_, **__):
return []
monkeypatch.setattr(influxdb_client, "InfluxDBClient", FakeClient)
node = InfluxDbNode(
provides=[MessageSpec(name="answer", dtype=DType.JSON)],
params={
"url": "http://influx",
"token": "t",
"org": "o",
"bucket": "b",
"timeout": 2.5,
},
)
node._run_flux({"flux": 'from(bucket: "b")'})
assert seen["timeout"] == 2500
def test_a_falsy_return_is_a_mistake_not_silence():
"""Only None means "nothing to publish"."""
from fluksio.flow.nodes import Node
+5 -1
View File
@@ -129,7 +129,7 @@ def test_a_preferred_label_falls_back_here_and_is_still_accounted(loop):
assert placer.local.snapshot()["cpus"]["free"] == 1
def test_asking_for_more_than_anything_has_gets_what_there_is(loop):
def test_asking_for_more_than_anything_has_gets_what_there_is(loop, caplog):
"""A flow written on a cluster still has to run on a laptop."""
placer = placer_over(cpus=2)
@@ -138,6 +138,10 @@ def test_asking_for_more_than_anything_has_gets_what_there_is(loop):
assert allocation.cpus == 2
assert allocation.gpus == ()
# Cards are declared, not detected, so a machine that has one reads as
# having none until it is told — and the warning is where that is noticed.
assert "fluksio serve --gpus" in caplog.text
def test_what_is_clamped_to_is_a_machine_that_exists(loop):
"""Each dimension taken separately can describe a machine nobody has.
+62
View File
@@ -89,3 +89,65 @@ def test_a_string_goes_on_the_wire_bare():
asyncio.run(node._publish_with(client, {"plug": "ON", "level": 60}))
assert client.published == [("actor/plug", "ON"), ("light/level", "60")]
def test_the_configured_timeout_reaches_the_broker_client(monkeypatch):
"""Without one, a dead socket makes the disconnect ack wait forever."""
import aiomqtt
seen: dict = {}
class FakeClient:
def __init__(self, **kwargs):
seen.update(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def publish(self, *_, **__):
return None
monkeypatch.setattr(aiomqtt, "Client", FakeClient)
node = MqttNode(
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
params={"topic": {"setpoint": "heating/setpoint"}, "timeout": 2.5},
)
asyncio.run(node._publish_once({"setpoint": 21.0}))
assert seen["timeout"] == 2.5
def test_the_configured_backlog_reaches_the_publish_queue(monkeypatch):
"""The depth is read when the queue is built, so it has to be per node."""
import aiomqtt
class FakeClient:
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
monkeypatch.setattr(aiomqtt, "Client", FakeClient)
node = MqttNode(
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
params={"topic": {"setpoint": "heating/setpoint"}, "publish_queue_size": 8},
)
node.assign_flow("heating", "out")
async def scenario() -> int:
await node.start_publisher()
assert node._publish_queue is not None
size = node._publish_queue.maxsize
await node.stop_publisher()
return size
assert asyncio.run(scenario()) == 8
@@ -0,0 +1,42 @@
"""A node that loaded but is not working shows up as an issue on its flow.
Health used to go nowhere: the connector reported it, the controller stored it,
and no screen ever asked. These cover the derivation that closes that gap.
"""
from pathlib import Path
from fluksio.flow.controller import FlowController, LoadedNode
from fluksio.flow.store import FlowStore
def a_controller(tmp_path: Path) -> FlowController:
controller = FlowController(FlowStore(tmp_path / "flows"))
controller.loaded["house.owm"] = LoadedNode(
id="house.owm",
flow="house",
health="down",
health_detail="ConnectionError: name resolution failed",
)
return controller
def test_a_down_node_is_an_issue_on_its_flow(tmp_path: Path) -> None:
controller = a_controller(tmp_path)
issues = controller.flow_issues("house")
assert [issue.code for issue in issues] == ["node_unhealthy"]
assert issues[0].node == "house.owm"
assert "name resolution failed" in issues[0].message
# Not advisory: the canvas has to mark the node.
assert not issues[0].advisory
assert controller.flow_issues("other") == []
def test_the_issue_clears_when_the_node_reports_itself_well(tmp_path: Path) -> None:
controller = a_controller(tmp_path)
controller.loaded["house.owm"].health = "ok"
assert controller.flow_issues("house") == []
+31
View File
@@ -69,6 +69,24 @@ def test_a_node_returns_its_value_and_what_it_printed(pool, capsys):
assert "seen 21" in capsys.readouterr().out
def test_a_node_can_log_through_the_module_it_imports(pool, capsys):
"""The SDK exports `logger`; inside a worker `fluksio` is the reporter.
Without it, `fluksio.logger.info(...)` died with AttributeError after
the training it was reporting on had already succeeded.
"""
result = run(
pool,
"import fluksio\n\n\n"
"def process(value):\n"
" fluksio.logger.info('tuned %s', value)\n"
" return {'out': value}\n",
value=7,
)
assert result == {"out": 7}
assert "tuned 7" in capsys.readouterr().out
def test_a_failure_keeps_its_class_and_points_at_the_node(pool):
with pytest.raises(Exception) as caught:
run(pool, "def process():\n raise ValueError('bad input')\n")
@@ -667,6 +685,19 @@ def test_retiring_workers_reaches_the_children(pool):
assert child._generation > before
def test_retiring_the_cards_leaves_the_other_pools_warm(pool):
"""A library that preallocated the card only gives it back by dying."""
card = pool.for_env({"CUDA_VISIBLE_DEVICES": "0"})
threads = pool.for_env({"OMP_NUM_THREADS": "2"})
before = (card._generation, threads._generation)
pool.retire_gpu_children()
assert card._generation > before[0]
# Nothing to hand back, so nothing pays a cold start for it.
assert threads._generation == before[1]
def test_cancelling_reaches_a_node_running_in_a_child(pool):
child = pool.for_env({"OMP_NUM_THREADS": "2"})
started = threading.Event()
+25 -1
View File
@@ -6,7 +6,7 @@ from fluksio.flow.schemas import FlowDef
from fluksio.sdk import MARKER, Flow, Port, SyncError, node, use
# The functions live here rather than in each test: a node's module is what the
# generated body imports, and `__main__` is refused for exactly that reason.
# generated body imports, and `__main__` is refused at sync for that reason.
@node(provides=[Port("dataset", "artifact"), Port("rows", "int")])
@@ -316,3 +316,27 @@ def test_a_negative_timeout_is_refused():
def test_a_zero_timeout_means_no_limit():
decorated = node(requires=["a"], timeout=0)(one_default)
assert decorated.__fluksio__.timeout == 0
def test_a_node_in_a_script_run_directly_is_refused_at_sync_not_at_import():
"""A study module has to be runnable as a script for a self-check.
The refusal belongs where the body is generated: the decorator hands the
function back untouched, so `python study.py` declares its nodes, calls
them and checks itself. What cannot be done is syncing them, because
nothing could import `__main__`.
"""
def probe(rows=1):
return {"score": float(rows)}
probe.__module__ = "__main__"
decorated = node(provides=[Port("score", "float")])(probe)
# Declared and callable, which is the whole point of running the file.
assert decorated(rows=2) == {"score": 2.0}
flow = Flow("mainflow", nodes=[decorated], outputs=["score"])
with pytest.raises(SyncError, match="run directly"):
flow.document()
with pytest.raises(SyncError, match="run directly"):
flow.shims()
+249 -1
View File
@@ -88,6 +88,24 @@ def test_run_arguments_are_typed_by_the_flow_they_are_for() -> None:
_params(definition, ["--nonesuch", "1"])
def test_a_sweeps_param_spelling_is_refused_by_name() -> None:
"""`run --param lr=0.002` is a name this flow has not got, and says so."""
import pytest
from fluksio.sdk import SyncError
from fluksio.sdk.cli import _params
definition = {"inputs": [{"spec": {"name": "lr", "dtype": "float"}}]}
# Not a JSONDecodeError over `lr=0.002`, which is what reading the value
# before the name used to give.
with pytest.raises(SyncError, match="sweep --param"):
_params(definition, ["--param", "lr=0.002"])
with pytest.raises(SyncError, match="'lr' takes float"):
_params(definition, ["--lr", "fast"])
def test_serve_uses_the_installation_the_directory_belongs_to(
tmp_path: Path, monkeypatch
) -> None:
@@ -372,6 +390,43 @@ def test_the_metric_names_are_asked_for_rather_than_guessed() -> None:
assert _list_names(Engine(), args) == 0
def test_a_runs_artifact_is_listed_and_downloaded(tmp_path, monkeypatch) -> None:
"""`save_artifact` had no counterpart: the bytes were API-only."""
from fluksio.cli import _parser
from fluksio.sdk.cli import _artifacts
row = {
"name": "weights",
"node": "fit",
"digest": "sha256:abc",
"size": 3,
"media_type": "application/octet-stream",
"filename": "weights.npz",
}
class Engine:
def run(self, run_id):
assert run_id == "r-1"
return {"id": run_id, "status": "ok", "artifacts": [row]}
def download(self, digest):
assert digest == "sha256:abc"
return b"abc"
parser = _parser()
monkeypatch.chdir(tmp_path)
assert _artifacts(Engine(), parser.parse_args(["artifacts", "r-1"])) == 0
# Written under the name the node saved it as, not the message's.
assert _artifacts(Engine(), parser.parse_args(["artifacts", "r-1", "weights"])) == 0
assert (tmp_path / "weights.npz").read_bytes() == b"abc"
args = parser.parse_args(["artifacts", "r-1", "weights", "-o", "here.bin"])
assert _artifacts(Engine(), args) == 0
assert (tmp_path / "here.bin").read_bytes() == b"abc"
def test_an_engine_without_the_route_is_named_rather_than_404() -> None:
"""A client ships ahead of the engine; a flat 404 does not say so."""
from fluksio.sdk.cli import _too_old
@@ -417,8 +472,38 @@ def test_a_study_in_a_subfolder_is_found(tmp_path) -> None:
}
def test_a_study_per_directory_imports_under_its_own_name(tmp_path) -> None:
"""One `study.py` per folder is a layout people have, and it works.
Named for where each sits under the directory being synced, so nothing
collides and no `__init__.py` has to be added which would break the
bare `from study import ...` a test beside it does.
"""
import sys
from fluksio.sdk.cli import discover
for study in ("s1", "s2"):
(tmp_path / "dev" / study).mkdir(parents=True)
(tmp_path / "dev" / study / "study.py").write_text(f"VALUE = {study!r}\n")
try:
discover([str(tmp_path / "dev")])
assert sys.modules["s1.study"].VALUE == "s1"
assert sys.modules["s2.study"].VALUE == "s2"
finally:
for name in ("s1.study", "s2.study", "s1", "s2"):
sys.modules.pop(name, None)
sys.path[:] = [entry for entry in sys.path if entry != str(tmp_path / "dev")]
def test_two_files_of_one_name_are_refused(tmp_path) -> None:
"""Python keeps one module per name, and a node's body imports by it."""
"""Python keeps one module per name, and a node's body imports by it.
Unreachable from one sync of a directory now that a file is named for
where it sits; this is the spelling that still gets there two files
named on the command line, each rooted at its own directory.
"""
import pytest
from fluksio.sdk import SyncError
@@ -435,6 +520,77 @@ def test_two_files_of_one_name_are_refused(tmp_path) -> None:
_import(*_module_of(second), second)
def test_how_long_ago_reads_like_a_duration() -> None:
"""A failure with no time on it says nothing about whether it is current."""
from datetime import UTC, datetime, timedelta
from fluksio.sdk.cli import _ago
def then(**delta):
return (datetime.now(UTC) - timedelta(**delta)).isoformat()
assert _ago(then(seconds=5)) == "5s ago"
assert _ago(then(minutes=3)) == "3min ago"
assert _ago(then(hours=2)) == "2h ago"
assert _ago(then(days=3)) == "3d ago"
# What the engine stores is UTC whether or not the spelling says so.
assert _ago(datetime.now(UTC).replace(tzinfo=None).isoformat()) == "0s ago"
assert _ago(None) == ""
def test_serve_says_when_a_flow_wants_a_card_nobody_declared(
tmp_path, monkeypatch, capsys
) -> None:
"""The clamp warning goes to the log; this is said while someone is reading.
Cards are declared rather than detected, so a fresh install that forgets
`--gpus` clamps a GPU node to zero and runs them all at once.
"""
from fluksio import cli
from fluksio.core.config import settings
from fluksio.flow.schemas import FlowDef, NodeDef, Resources
from fluksio.flow.store import FlowStore
store = FlowStore(tmp_path / "flows")
store.write_flow(
FlowDef(
name="finetune",
mode="batch",
nodes=[NodeDef(id="fit", type="python", resources=Resources(gpus=1))],
)
)
monkeypatch.setattr(settings, "FLOWS_DIR", tmp_path / "flows")
monkeypatch.setattr(settings, "FLOW_GPUS", 0)
cli._mention_undeclared_cards()
said = capsys.readouterr().out
assert "finetune asks for one" in said
assert "--gpus" in said
# Told how many there are, it has nothing to say.
monkeypatch.setattr(settings, "FLOW_GPUS", 1)
cli._mention_undeclared_cards()
assert capsys.readouterr().out == ""
def test_a_serve_limit_is_refused_as_a_flag_not_as_a_traceback(capsys) -> None:
"""These are written into the environment before the settings are built."""
import pytest
from fluksio.cli import _parser
parser = _parser()
assert parser.parse_args(["serve", "--max-workers", "2"]).max_workers == 2
# A machine may genuinely have no card, so zero is a number here.
assert parser.parse_args(["serve", "--gpus", "0"]).gpus == 0
assert parser.parse_args(["serve"]).gpus is None
for flag, value in (("--max-workers", "0"), ("--gpus", "-1")):
with pytest.raises(SystemExit):
parser.parse_args(["serve", flag, value])
assert "at least" in capsys.readouterr().err
def test_run_and_sweep_take_what_to_sync() -> None:
from fluksio.cli import _parser
@@ -446,6 +602,98 @@ def test_run_and_sweep_take_what_to_sync() -> None:
assert parser.parse_args(["sweep", "train"]).sync == []
def test_the_dashboard_runs_the_engine_as_a_child_of_itself(monkeypatch) -> None:
"""At a terminal `serve` is a dashboard; the engine is a plain serve.
Every flag is passed through, so what the child runs with is what serve
was asked for and `--plain` is what stops it opening a second one.
"""
import sys
from fluksio import cli
from fluksio.tui import child_argv
argv = child_argv(["serve", "--port", "8123", "--gpus", "1"])
assert argv[:3] == [sys.executable, "-m", "fluksio.cli"]
assert argv[3:] == ["serve", "--port", "8123", "--gpus", "1", "--plain"]
# Already plain: told once, not twice.
assert child_argv(["serve", "--plain"])[3:] == ["serve", "--plain"]
opened: list[str] = []
monkeypatch.setattr(
"fluksio.tui.run_tui", lambda args: opened.append("tui") or 0, raising=False
)
monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False)
monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False)
parser = cli._parser()
assert cli.cmd_serve(parser.parse_args(["serve"])) == 0
assert opened == ["tui"]
# `--plain` goes past it, which is what the child and every container does.
# Nothing else of serve runs here, so it fails on the data directory it is
# given rather than opening a dashboard.
opened.clear()
monkeypatch.setattr(
cli, "_data_dir", lambda *a, **k: (_ for _ in ()).throw(SystemExit(3))
)
with __import__("pytest").raises(SystemExit):
cli.cmd_serve(parser.parse_args(["serve", "--plain"]))
assert opened == []
def test_a_serving_engine_records_itself_until_it_stops(tmp_path) -> None:
"""A pid nobody is running is the same as no pidfile at all."""
import os
from fluksio.cli import read_pidfile, write_pidfile
assert read_pidfile(tmp_path) is None
written = write_pidfile(tmp_path, 8000)
assert read_pidfile(tmp_path) == {"pid": os.getpid(), "port": 8000}
# Killed outright: the file outlives the process it names.
written.write_text('{"pid": 2147483646, "port": 8000}')
assert read_pidfile(tmp_path) is None
written.write_text("not json")
assert read_pidfile(tmp_path) is None
def test_who_holds_the_port_is_told_apart_by_the_token() -> None:
"""Only this directory's own engine may be reported as already up.
The token is signed with this directory's secret key, so an engine that
accepts it is one reading this directory's database. Another
installation's Fluksio answers the health check and refuses it.
"""
import httpx
from fluksio.cli import probe_engine
def engine(health: int, summary: int):
def handle(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/health-check/"):
return httpx.Response(health)
return httpx.Response(summary)
return httpx.Client(transport=httpx.MockTransport(handle))
with engine(200, 200) as client:
assert probe_engine("http://x", "t", client) == "ours"
with engine(200, 401) as client:
assert probe_engine("http://x", "t", client) == "foreign"
# A directory with no credential yet cannot prove anything is its own, and
# `Bearer ` is not a legal header value — so it asks without one.
with engine(200, 401) as client:
assert probe_engine("http://x", "", client) == "foreign"
# Somebody else's dev server, or nothing listening at all.
with engine(404, 404) as client:
assert probe_engine("http://x", "t", client) == "other"
assert probe_engine("http://127.0.0.1:1", "t") == "other"
def test_serve_moves_off_a_port_that_is_taken() -> None:
"""A first start should not die on somebody else's dev server."""
import socket