History is kept by flow id: runs, cascades, rollups and events file the id in their flow column and nodes as <flow id>.<node>, the API answers flow plus flow_name and shows and takes nodes as <key>.<node>; events carry flow_id from the moment they are published; run queue items, the stage cache and cached curves keep ids and keys apart; endpoints, the brain graph, device bindings and search name the id to open

This commit is contained in:
2026-11-02 19:01:19 +01:00
parent e6027af8fe
commit d4a1b1db47
34 changed files with 1374 additions and 329 deletions
+6 -3
View File
@@ -629,9 +629,12 @@ def test_a_devices_io_comes_from_its_device_nodes(
"config": {"pull": "up", "debounce_ms": 50},
},
]
assert [(b["flow"], b["node"], b["port"]) for b in read["bindings"]] == [
("porch", "porch", "backlight"),
("porch", "porch", "gpio.4"),
# The flow by key, and by the id that opens it.
assert [
(b["flow"], b["flow_id"], b["node"], b["port"]) for b in read["bindings"]
] == [
("porch", "porch", "porch", "backlight"),
("porch", "porch", "porch", "gpio.4"),
]
again = client.get(f"{PANELS}/porch_io", headers=superuser_token_headers)
assert again.content == first.content
+62 -2
View File
@@ -16,6 +16,7 @@ from fastapi.testclient import TestClient
from sqlmodel import Session, delete, select
from fluksio.core.config import settings
from fluksio.flow.dashboards import DashboardDef, WidgetDef
from fluksio.flow.store import ISSUED_ID
from fluksio.flow.tombstones import names_of
from fluksio.models import FlowTombstone, Run
@@ -235,6 +236,65 @@ def test_a_results_dashboard_is_made_from_a_flow_id_under_the_flows_key(
client.delete(f"{DASHBOARDS}/{board['id']}", headers=superuser_token_headers)
def test_what_opens_a_flow_or_dashboard_names_it_by_id(
make: Make, client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A node goes by ``<key>.<node>``, which is what the canvas matches. Where
a response leads somewhere, it says the id to go by beside it."""
headers = superuser_token_headers
key, reader_key = a_key(), a_key()
flow_id = make(key)["definition"]["id"]
for node, code in (("sensor", SENSOR), ("double", DOUBLE)):
client.put(
f"{FLOWS}/{flow_id}/nodes/{node}/source",
headers=headers,
json={"code": code},
)
client.post(
f"{FLOWS}/{flow_id}/publish",
headers=headers,
json={
"version": client.get(f"{FLOWS}/{flow_id}", headers=headers).json()[
"definition"
]["version"]
},
)
listen = {
"id": "listen",
"type": "python",
"requires": [{"name": f"{key}.reading", "dtype": "float"}],
}
reader_id = make(reader_key, nodes=[listen])["definition"]["id"]
board = client.app.state.dashboard_store.write( # type: ignore[attr-defined]
DashboardDef(
id=f"d_{reader_key}",
name=reader_key,
widgets=[
WidgetDef(
id="shown",
type="stat",
config={"message": f"{key}.reading", "dtype": "float"},
)
],
)
)
try:
far = client.get(f"{FLOWS}/{reader_id}", headers=headers).json()["endpoints"]
assert [(e["id"], e["owner_id"]) for e in far] == [
(f"flow:{key}.sensor", flow_id)
]
near = client.get(f"{FLOWS}/{flow_id}", headers=headers).json()["endpoints"]
assert (f"dashboard:{board.id}:shown", board.id) in [
(e["id"], e["owner_id"]) for e in near
]
graph = client.get(f"{FLOWS}/graph", headers=headers).json()
(sensor,) = [n for n in graph["nodes"] if f"{key}.sensor" in n["members"]]
assert (sensor["flows"], sensor["flow_ids"]) == ([key], [flow_id])
finally:
client.delete(f"{DASHBOARDS}/{board.id}", headers=headers)
def test_who_may_create_what(
client: TestClient,
superuser_token_headers: dict[str, str],
@@ -286,7 +346,7 @@ def test_a_deleted_flow_keeps_its_name_and_frees_its_key(
) -> None:
key = a_key()
old = make(key)["definition"]["id"]
db.add(Run(id=f"run-{key}", flow=old, status="ok", created_at=datetime.now(UTC)))
db.add(Run(id=f"run-{key}", flow_id=old, status="ok", created_at=datetime.now(UTC)))
db.commit()
controller = client.app.state.flow_controller # type: ignore[attr-defined]
controller.state.set(f"{key}.doubled", 1.0)
@@ -311,7 +371,7 @@ def test_a_deleted_flow_keeps_its_name_and_frees_its_key(
assert made.status_code == 201, made.text
new = made.json()["definition"]["id"]
assert new != old
assert db.exec(select(Run).where(Run.flow == new)).first() is None
assert db.exec(select(Run).where(Run.flow_id == new)).first() is None
client.delete(f"{FLOWS}/{new}", headers=superuser_token_headers)
db.exec(delete(Run).where(Run.id == f"run-{key}")) # type: ignore[call-overload]
db.commit()
+18 -8
View File
@@ -303,7 +303,7 @@ def test_delete_flow(
put_flow(client, superuser_token_headers, a_flow(), "demo")
# Finished: a flow with a live run refuses to be deleted, which is its own
# test below.
db.add(Run(id="run-1", flow="demo", status="ok", created_at=datetime.now(UTC)))
db.add(Run(id="run-1", flow_id="demo", status="ok", created_at=datetime.now(UTC)))
db.add(RunNode(run_id="run-1", node="sensor"))
db.add(RunMetric(run_id="run-1", name="loss", step=-1))
db.add(RunArtifact(run_id="run-1", name="model.pt"))
@@ -335,7 +335,12 @@ def test_delete_flow_is_refused_while_a_run_is_live(
) -> None:
put_flow(client, superuser_token_headers, a_flow(), "demo")
db.add(
Run(id="run-live", flow="demo", status="running", created_at=datetime.now(UTC))
Run(
id="run-live",
flow_id="demo",
status="running",
created_at=datetime.now(UTC),
)
)
db.commit()
@@ -506,30 +511,35 @@ def test_saving_a_header_moves_the_node_ports_in_the_draft(
def test_a_shared_node_in_use_is_retired_only_with_detach(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
put_flow(client, superuser_token_headers, a_flow("shared"), "shared")
# Under an id that is not its key: the library names nodes by key.
put_flow(client, superuser_token_headers, a_flow("shared"), "x_shared")
client.put(
f"{PREFIX}/shared/nodes/sensor/source",
f"{PREFIX}/x_shared/nodes/sensor/source",
headers=superuser_token_headers,
json={"code": WORKING_NODE},
)
client.post(
f"{PREFIX}/shared/nodes/sensor/share",
f"{PREFIX}/x_shared/nodes/sensor/share",
headers=superuser_token_headers,
json={"lib_name": "reading"},
)
(shared,) = client.get(f"{PREFIX}/library", headers=superuser_token_headers).json()
assert shared["used_by"] == ["shared.sensor"]
url = f"{PREFIX}/library/reading"
assert client.delete(url, headers=superuser_token_headers).status_code == 409
refused = client.delete(url, headers=superuser_token_headers)
assert refused.status_code == 409
assert "shared.sensor" in refused.json()["detail"]
response = client.delete(f"{url}?detach=true", headers=superuser_token_headers)
assert response.status_code == 200
assert client.get(f"{PREFIX}/library", headers=superuser_token_headers).json() == []
source = client.get(
f"{PREFIX}/shared/nodes/sensor/source", headers=superuser_token_headers
f"{PREFIX}/x_shared/nodes/sensor/source", headers=superuser_token_headers
)
assert source.json()["code"] == WORKING_NODE
client.delete(f"{PREFIX}/shared", headers=superuser_token_headers)
client.delete(f"{PREFIX}/x_shared", headers=superuser_token_headers)
def test_a_shared_node_only_the_published_flow_uses_is_kept(
@@ -0,0 +1,498 @@
"""History by id, shown by key.
A run, its nodes, its curve and its cache entry are filed under the flow's id
and named by the key the engine runs the flow under. Every test runs twice: on
a flow whose id is its key, and on one with an issued id, since a reader that
mixes the two up only shows on the second.
"""
import csv
import io
import time
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, col, delete, select
from fluksio.core.config import settings
from fluksio.core.db import engine as db_engine
from fluksio.flow.events import EventBus
from fluksio.flow.metrics import MetricsCollector
from fluksio.models import (
EngineEvent,
FlowRun,
MetricBucket,
Run,
RunMetric,
RunNode,
SQLModel,
)
API = settings.API_V1_STR
FLOWS = f"{API}/flows"
RUNS = f"{API}/runs"
#: A curve on the way, and a number at the end.
FIT = (
"def process():\n"
" yield {'loss': 2.0}\n"
" yield {'loss': 1.0}\n"
" return {'final': 1.0}\n"
)
def a_batch(key: str) -> dict[str, Any]:
return {
"name": key,
"mode": "batch",
"outputs": ["final"],
"nodes": [
{
"id": "fit",
"type": "python",
"provides": [
{"name": "loss", "dtype": "float", "stream": True},
{"name": "final", "dtype": "float"},
],
}
],
}
Make = Callable[[str], str]
@pytest.fixture(params=["id_is_key", "issued"])
def make(
request: pytest.FixtureRequest,
client: TestClient,
superuser_token_headers: dict[str, str],
) -> Make:
"""Create and publish a batch flow, its id pinned to its key or issued."""
headers = superuser_token_headers
made: list[str] = []
def create(key: str) -> str:
body = a_batch(key)
if request.param == "id_is_key":
body["id"] = key
response = client.post(f"{FLOWS}/", headers=headers, json=body)
assert response.status_code == 201, response.text
flow_id: str = response.json()["definition"]["id"]
assert (flow_id == key) is (request.param == "id_is_key")
made.append(flow_id)
client.put(
f"{FLOWS}/{flow_id}/nodes/fit/source", headers=headers, json={"code": FIT}
)
version = client.get(f"{FLOWS}/{flow_id}", headers=headers).json()[
"definition"
]["version"]
published = client.post(
f"{FLOWS}/{flow_id}/publish", headers=headers, json={"version": version}
)
assert published.status_code == 200, published.text
return flow_id
yield create # type: ignore[misc]
for flow_id in made:
client.delete(f"{FLOWS}/{flow_id}", headers=headers)
client.delete(f"{RUNS}?flow={flow_id}&history=true", headers=headers)
def a_key() -> str:
return f"hist_{uuid.uuid4().hex[:8]}"
def run(client: TestClient, headers: dict[str, str], flow_id: str) -> dict[str, Any]:
"""Submit a run over the API and wait for the engine to finish it."""
submitted = client.post(f"{RUNS}/flows/{flow_id}", headers=headers, json={})
assert submitted.status_code == 202, submitted.text
run_id = submitted.json()["id"]
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
detail: dict[str, Any] = client.get(f"{RUNS}/{run_id}", headers=headers).json()
if detail["status"] not in ("queued", "running"):
assert detail["status"] == "ok", detail["status_reason"]
return detail
time.sleep(0.1)
raise AssertionError(f"run {run_id} did not finish")
def test_a_batch_run_is_kept_by_id_and_named_by_key(
make: Make, client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Submit, run, read back by id, export: the whole way for one flow."""
headers = superuser_token_headers
key = a_key()
flow_id = make(key)
detail = run(client, headers, flow_id)
assert (detail["flow"], detail["flow_name"]) == (flow_id, key)
assert detail["result"] == {"final": 1.0}
(node,) = detail["nodes"]
assert node["node"] == f"{key}.fit"
with Session(db_engine) as session:
assert session.get(Run, detail["id"]).flow_id == flow_id # type: ignore[union-attr]
stored = session.exec(
select(RunNode).where(col(RunNode.run_id) == detail["id"])
).one()
points = session.exec(
select(RunMetric).where(col(RunMetric.run_id) == detail["id"])
).all()
# The node by the flow's id, the message by its key.
assert stored.node == f"{flow_id}.fit"
assert {(p.node, p.name) for p in points} == {(f"{flow_id}.fit", f"{key}.loss")}
outputs = client.get(
f"{RUNS}/{detail['id']}/nodes/{key}.fit/outputs", headers=headers
)
assert outputs.json() == {f"{key}.final": 1.0}
curve = client.get(f"{RUNS}/{detail['id']}/metrics", headers=headers).json()
assert [(p["name"], p["value"]) for p in curve] == [
(f"{key}.loss", 2.0),
(f"{key}.loss", 1.0),
]
# Filtered by id, strictly. The key is not a flow's id here unless it is.
(listed,) = client.get(RUNS, headers=headers, params={"flow": flow_id}).json()
assert (listed["flow"], listed["flow_name"]) == (flow_id, key)
by_key = client.get(RUNS, headers=headers, params={"flow": key}).json()
assert len(by_key) == (1 if flow_id == key else 0)
overview = client.get(f"{RUNS}/overview", headers=headers).json()
assert {"flow": flow_id, "flow_name": key} in [
{"flow": row["flow"], "flow_name": row["flow_name"]} for row in overview
]
exported = client.get(
f"{RUNS}/export/runs", headers=headers, params={"flow": flow_id}
).text
(row,) = csv.DictReader(io.StringIO(exported))
assert (row["flow"], row["flow_name"], row["metric.final"]) == (
flow_id,
key,
"1.0",
)
series = client.get(
f"{RUNS}/export/metrics",
headers=headers,
params={"flow": flow_id, "format": "jsonl"},
).text
assert f'"name": "{key}.loss"' in series
# The cascade the collector made of it, by id as well.
client.post(f"{API}/observability/flush", headers=headers)
cascades = client.get(
f"{API}/observability/runs", headers=headers, params={"flow": flow_id}
).json()["data"]
assert [(c["id"], c["flow"], c["flow_name"]) for c in cascades] == [
(detail["id"], flow_id, key)
]
# Gone, the history stays under the id and keeps the key it last had.
client.delete(f"{FLOWS}/{flow_id}", headers=headers)
(kept,) = client.get(RUNS, headers=headers, params={"flow": flow_id}).json()
assert (kept["flow"], kept["flow_name"]) == (flow_id, key)
again = client.get(f"{RUNS}/{detail['id']}", headers=headers).json()
assert again["nodes"][0]["node"] == f"{key}.fit"
kept_outputs = client.get(
f"{RUNS}/{detail['id']}/nodes/{key}.fit/outputs", headers=headers
)
assert kept_outputs.json() == {f"{key}.final": 1.0}
def test_a_cache_entry_is_restored_into_another_flow_by_its_key(
make: Make, client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""The same node in two flows: the second run restores the first's.
Outputs are named by the source flow's key and restored under the asking
flow's. The curve is read from the source run, its rows found by the
source flow's id, and renamed between the two keys. Deleting the source
flow changes neither.
"""
headers = superuser_token_headers
source_key, reuse_key = a_key(), a_key()
source_id = make(source_key)
first = run(client, headers, source_id)
reuse_id = make(reuse_key)
reused = run(client, headers, reuse_id)
(node,) = reused["nodes"]
assert (node["node"], node["status"], node["cached_from"]) == (
f"{reuse_key}.fit",
"cached",
first["id"],
)
assert reused["result"] == {"final": 1.0}
outputs = client.get(
f"{RUNS}/{reused['id']}/nodes/{reuse_key}.fit/outputs", headers=headers
).json()
assert outputs == {f"{reuse_key}.final": 1.0}
def curve() -> list[tuple[str, float]]:
points = client.get(f"{RUNS}/{reused['id']}/metrics", headers=headers).json()
return [(p["name"], p["value"]) for p in points]
assert curve() == [(f"{reuse_key}.loss", 2.0), (f"{reuse_key}.loss", 1.0)]
compared = client.get(
f"{RUNS}/series/compare",
headers=headers,
params={"ids": f"{first['id']},{reused['id']}", "metric": f"{reuse_key}.loss"},
).json()
assert [len(line["points"]) for line in compared["lines"]] == [0, 2]
# The source flow goes, its run stays, and so does what it holds.
client.delete(f"{FLOWS}/{source_id}", headers=headers)
assert curve() == [(f"{reuse_key}.loss", 2.0), (f"{reuse_key}.loss", 1.0)]
with Session(db_engine) as session:
cache_key = session.exec(
select(RunNode.cache_key).where(col(RunNode.run_id) == first["id"])
).one()
hit = client.app.state.run_service._cache.lookup(cache_key) # type: ignore[attr-defined]
# The newest entry is the restored one, named by the flow that restored it.
assert hit is not None and hit.flow == reuse_key
with Session(db_engine) as session:
for row in session.exec(
select(RunNode).where(col(RunNode.run_id) == reused["id"])
):
row.cache_key = ""
session.add(row)
session.commit()
hit = client.app.state.run_service._cache.lookup(cache_key) # type: ignore[attr-defined]
# And past it, the source run's, under the key its flow had when it went.
assert hit is not None and hit.flow == source_key
assert hit.outputs == {f"{source_key}.final": 1.0}
def test_engine_history_is_read_by_id_and_names_nodes_by_key(
make: Make, client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Rollups and failures, filtered by the flow's id, shown by its key."""
headers = superuser_token_headers
key = a_key()
flow_id = make(key)
minute = datetime.now(UTC).replace(second=0, microsecond=0) - timedelta(minutes=2)
with Session(db_engine) as session:
session.add(
MetricBucket(
flow_id=flow_id, node=f"{flow_id}.fit", bucket=minute, executions=3
)
)
session.add(
EngineEvent(
ts=minute, type="node_error", flow_id=flow_id, node=f"{flow_id}.fit"
)
)
session.commit()
try:
(event,) = client.get(
f"{API}/observability/events", headers=headers, params={"flow": flow_id}
).json()
assert (event["flow"], event["flow_name"], event["node"]) == (
flow_id,
key,
f"{key}.fit",
)
points = client.get(
f"{API}/observability/timeseries",
headers=headers,
params={"flow": flow_id, "node": f"{key}.fit"},
).json()
assert sum(point["executions"] for point in points) == 3
rollups = client.get(f"{API}/observability/flows", headers=headers).json()
(rollup,) = [row for row in rollups if row["flow"] == flow_id]
assert (rollup["flow_name"], rollup["executions"]) == (key, 3)
finally:
with Session(db_engine) as session:
session.execute(
delete(MetricBucket).where(col(MetricBucket.flow_id) == flow_id)
)
session.execute(
delete(EngineEvent).where(col(EngineEvent.flow_id) == flow_id)
)
session.commit()
def test_events_carry_the_flow_id_and_rows_are_filed_under_it() -> None:
"""The id is attached on publish, before anything buffers the event."""
bus = EventBus()
bus.flow_id_of = {"heat": "f_heat0001"}.get # type: ignore[assignment]
collector = MetricsCollector(bus)
events = [
{"type": "node_executed", "flow": "heat", "node": "heat.pump", "ts": 60.0},
{"type": "node_error", "flow": "heat", "node": "heat.pump", "ts": 60.0},
{"type": "engine_degraded", "flow": "", "reason": "lag"},
]
for event in events:
bus.publish(event)
collector.handle(event)
# What every subscriber is handed, the socket and the collector alike.
assert [e.get("flow_id") for e in events] == ["f_heat0001", "f_heat0001", ""]
((flow_id, node, _),) = collector._buckets
assert (flow_id, node) == ("f_heat0001", "f_heat0001.pump")
error, degraded = collector._pending
assert (error.flow_id, error.node) == ("f_heat0001", "f_heat0001.pump")
# The engine as a whole stays nobody's.
assert (degraded.flow_id, degraded.node) == ("", "")
# -----------------------------------------------------------------------------
# Registry guard
#
# Every column of the history tables is classified by what it names. A new
# column, or a new table, fails here until somebody says whether it holds a
# flow id, a node by flow id, a message by key or nothing of either: the one
# place where history could start naming a flow by key again unnoticed.
# -----------------------------------------------------------------------------
FLOW_ID = "flow id"
NODE = "<flow id>.<node>"
MESSAGE = "message by key"
PAYLOAD = "payload or local names, never prefixed with an id"
OTHER = "names no flow"
HISTORY: dict[str, dict[str, str]] = {
"run": {
"flow": FLOW_ID,
# A training run's target, in a flow other than the run's.
"for_node": NODE,
"params": PAYLOAD,
"passed": PAYLOAD,
"declared": PAYLOAD,
"result": PAYLOAD,
"status_reason": PAYLOAD,
**dict.fromkeys(
(
"id",
"flow_version",
"commit",
"origin_commit",
"code_digest",
"params_digest",
"seed",
"group_id",
"parent_id",
"idempotency_key",
"external_id",
"cause",
"no_cache",
"draft",
"status",
"labels",
"needs",
"created_at",
"started_at",
"finished_at",
"duration_ms",
"engine",
"lease_at",
"actor",
),
OTHER,
),
},
"run_node": {
"node": NODE,
# The keys of the JSON object are messages; the values are payload.
"outputs": MESSAGE,
"logs": PAYLOAD,
"error": PAYLOAD,
**dict.fromkeys(
(
"run_id",
"status",
"attempt",
"started_at",
"duration_ms",
"worker",
"cache_key",
"cached_from",
),
OTHER,
),
},
"run_metric": {
"node": NODE,
"name": MESSAGE,
**dict.fromkeys(("run_id", "step", "ts", "value"), OTHER),
},
"run_artifact": {
"node": NODE,
"name": MESSAGE,
"filename": PAYLOAD,
**dict.fromkeys(
("run_id", "digest", "size", "media_type", "created_at"), OTHER
),
},
"flow_run": {
"flow": FLOW_ID,
**dict.fromkeys(
(
"id",
"source",
"parent",
"started_at",
"finished_at",
"status",
"nodes",
"errors",
"duration_ms",
"deliveries",
),
OTHER,
),
},
"metric_minute": {
"flow": FLOW_ID,
"node": NODE,
**dict.fromkeys(
(
"bucket",
"executions",
"errors",
"messages",
"duration_sum_ms",
"duration_max_ms",
"lag_sum_ms",
"lag_max_ms",
"items",
),
OTHER,
),
},
"engine_event": {
"flow": FLOW_ID,
"node": NODE,
"detail": PAYLOAD,
**dict.fromkeys(("id", "ts", "type", "actor", "run"), OTHER),
},
# `<event type>:<node or flow key>`, moved by a rename like a message.
"alert_sent": {"key": MESSAGE, "id": OTHER, "ts": OTHER},
"flow_tombstone": {"id": FLOW_ID, "name": PAYLOAD, "deleted_at": OTHER},
}
#: Tables that hold no flow history at all.
UNRELATED = {
"user",
"oauth_client",
"oauth_authorization_code",
"oauth_refresh_token",
"flavor",
"connector_state",
"worker_token",
}
def test_every_history_column_says_what_it_names() -> None:
tables = SQLModel.metadata.tables
assert set(tables) == set(HISTORY) | UNRELATED
for name, columns in HISTORY.items():
assert set(tables[name].columns.keys()) == set(columns), name
# The identity columns keep their name in SQL and read as ids in Python.
for model in (Run, FlowRun, MetricBucket, EngineEvent):
assert hasattr(model, "flow_id") and not hasattr(model, "flow")
+28 -20
View File
@@ -16,7 +16,7 @@ def _seed(db: Session) -> None:
now = datetime.now(UTC).replace(second=0, microsecond=0)
db.add(
MetricBucket(
flow=FLOW,
flow_id=FLOW,
node=f"{FLOW}.calc",
bucket=now - timedelta(minutes=1),
executions=4,
@@ -33,20 +33,24 @@ def _seed(db: Session) -> None:
EngineEvent(
ts=now,
type="node_error",
flow=FLOW,
flow_id=FLOW,
node=f"{FLOW}.calc",
detail="ValueError: bad input\nTraceback",
)
)
db.add(
EngineEvent(
ts=now, type="audit", flow=FLOW, detail="published", actor="a@example.com"
ts=now,
type="audit",
flow_id=FLOW,
detail="published",
actor="a@example.com",
)
)
db.add(
FlowRun(
id="9-0",
flow=FLOW,
flow_id=FLOW,
source="external",
started_at=now,
finished_at=now,
@@ -208,7 +212,7 @@ def test_the_timeseries_folds_into_the_requested_bucket(
for offset, executions in ((0, 1), (2, 2), (5, 4), (15, 8)):
db.add(
MetricBucket(
flow=flow,
flow_id=flow,
node=f"{flow}.calc",
bucket=first + timedelta(minutes=offset),
executions=executions,
@@ -242,7 +246,7 @@ def test_the_bucket_still_filling_is_held_back(
for offset, executions in ((-60, 3), (0, 1)):
db.add(
MetricBucket(
flow=flow,
flow_id=flow,
node=f"{flow}.calc",
bucket=datetime.fromtimestamp(minute + offset, UTC),
executions=executions,
@@ -289,7 +293,7 @@ def test_the_runs_page_carries_its_total(
db.add(
FlowRun(
id=f"paged-{index}",
flow=FLOW,
flow_id=FLOW,
started_at=minute + timedelta(seconds=index),
status="ok",
)
@@ -315,11 +319,11 @@ def test_runs_narrow_to_one_minute(
) -> None:
"""A minute picked off a chart reaches past what the recent list holds."""
minute = datetime.now(UTC).replace(second=0, microsecond=0) - timedelta(hours=3)
db.add(FlowRun(id="minute-in", flow=FLOW, started_at=minute, status="ok"))
db.add(FlowRun(id="minute-in", flow_id=FLOW, started_at=minute, status="ok"))
db.add(
FlowRun(
id="minute-after",
flow=FLOW,
flow_id=FLOW,
started_at=minute + timedelta(minutes=1),
status="ok",
)
@@ -345,7 +349,7 @@ def test_a_running_cascade_reports_how_long_it_has_been_going(
) -> None:
"""The collector writes a duration at the end; until then, time since."""
started = datetime.now(UTC) - timedelta(seconds=30)
db.add(FlowRun(id="in-flight", flow=FLOW, started_at=started, status="running"))
db.add(FlowRun(id="in-flight", flow_id=FLOW, started_at=started, status="running"))
db.commit()
runs = client.get(
@@ -363,12 +367,12 @@ def test_events_narrow_to_one_minute(
) -> None:
"""The failures list reaches as far back as the charts beside it."""
minute = datetime.now(UTC).replace(second=0, microsecond=0) - timedelta(hours=4)
db.add(EngineEvent(ts=minute, type="node_error", flow=FLOW, detail="minute-in"))
db.add(EngineEvent(ts=minute, type="node_error", flow_id=FLOW, detail="minute-in"))
db.add(
EngineEvent(
ts=minute + timedelta(minutes=1),
type="node_error",
flow=FLOW,
flow_id=FLOW,
detail="minute-after",
)
)
@@ -394,16 +398,18 @@ def test_events_narrow_to_one_run(
"""Reading one run's failures without filtering the engine-wide list."""
ts = datetime.now(UTC) - timedelta(hours=6)
db.add(
EngineEvent(ts=ts, type="node_error", flow=FLOW, detail="mine", run="run-mine")
EngineEvent(
ts=ts, type="node_error", flow_id=FLOW, detail="mine", run="run-mine"
)
)
db.add(
EngineEvent(
ts=ts, type="node_error", flow=FLOW, detail="theirs", run="run-theirs"
ts=ts, type="node_error", flow_id=FLOW, detail="theirs", run="run-theirs"
)
)
# What a live cascade unrelated to any run leaves, and what every row
# written before the column existed looks like.
db.add(EngineEvent(ts=ts, type="node_error", flow=FLOW, detail="neither"))
db.add(EngineEvent(ts=ts, type="node_error", flow_id=FLOW, detail="neither"))
db.commit()
events = client.get(
@@ -423,7 +429,7 @@ def test_a_timeseries_window_can_be_named(
for minute in range(4):
db.add(
MetricBucket(
flow="windowed",
flow_id="windowed",
node="windowed.n",
bucket=base + timedelta(minutes=minute),
executions=1,
@@ -553,7 +559,7 @@ def test_a_superuser_can_flush_the_collector_now(
written = db.exec(
select(EngineEvent).where(
EngineEvent.flow == FLOW, EngineEvent.detail == "flushed"
EngineEvent.flow_id == FLOW, EngineEvent.detail == "flushed"
)
).all()
assert len(written) == 1
@@ -564,12 +570,14 @@ def test_execution_time_is_summed_per_node_type(
) -> None:
from fluksio.flow.schemas import FlowDef, NodeDef
flow = "node_type_test"
# Rows name a node by its flow's id, which here is not its key.
flow, key = "x_node_type_test", "node_type_test"
controller = client.app.state.flow_controller
before = controller.store.read_all
controller.store.read_all = lambda: [
FlowDef(
name=flow,
id=flow,
name=key,
nodes=[
NodeDef(id="a", type="python"),
NodeDef(id="b", type="python"),
@@ -587,7 +595,7 @@ def test_execution_time_is_summed_per_node_type(
):
db.add(
MetricBucket(
flow=flow,
flow_id=flow,
node=f"{flow}.{node}",
bucket=now - timedelta(minutes=2),
executions=executions,
+51 -36
View File
@@ -45,7 +45,12 @@ def test_a_run_cache_finds_what_an_earlier_run_recorded(tmp_path):
with Session(db_engine) as session:
for run_id in ("cache-1", "cache-2", "cache-3"):
session.add(
Run(id=run_id, flow="study", status="ok", created_at=datetime.now(UTC))
Run(
id=run_id,
flow_id="study",
status="ok",
created_at=datetime.now(UTC),
)
)
session.add(
RunNode(
@@ -115,7 +120,7 @@ def test_a_row_keyed_before_the_current_shape_is_unkeyed():
("shape-old", CACHE_SHAPE_SINCE - timedelta(days=1)),
("shape-new", CACHE_SHAPE_SINCE + timedelta(seconds=1)),
):
session.add(Run(id=run_id, flow="study", status="ok", created_at=made))
session.add(Run(id=run_id, flow_id="study", status="ok", created_at=made))
session.add(
RunNode(
run_id=run_id,
@@ -174,7 +179,7 @@ def made_artifact():
session.add(
Run(
id=run_id,
flow="prepare",
flow_id="prepare",
status="ok",
result={"dataset": reference},
created_at=datetime.now(UTC),
@@ -288,7 +293,7 @@ def made_config():
session.add(
Run(
id=run_id,
flow="generate",
flow_id="generate",
status="ok",
result={"meta": meta, "label": "run-7"},
created_at=datetime.now(UTC),
@@ -335,7 +340,7 @@ def test_the_overview_counts_a_flow_the_list_page_would_not_reach(
session.add(
Run(
id=f"ov-{index}",
flow="overviewed",
flow_id="overviewed",
status="ok" if index else "running",
created_at=datetime(2026, 1, 1 + index, tzinfo=UTC),
)
@@ -358,7 +363,7 @@ def test_a_running_run_reports_how_long_it_has_been_going(
session.add(
Run(
id="in-flight",
flow="timed",
flow_id="timed",
status="running",
created_at=datetime.now(UTC),
started_at=datetime.now(UTC) - timedelta(seconds=30),
@@ -390,7 +395,7 @@ def test_a_run_records_which_caller_asked_for_it(
seen.update(kwargs)
return Run(
id="cause-1",
flow=name,
flow_id=name,
cause=str(kwargs["cause"]),
created_at=datetime.now(UTC),
)
@@ -431,7 +436,7 @@ def test_a_repeated_submit_returns_the_run_it_already_made():
session.add(
Run(
id="dedup-1",
flow="study",
flow_id="study",
status="running",
idempotency_key="key-abc",
created_at=datetime.now(UTC),
@@ -449,11 +454,15 @@ class _OneFlow:
def __init__(self, flow):
self.store = self
self._flow = flow
# As the store reads one from before ids: its key is its id.
self._flow = flow.model_copy(update={"id": flow.id or flow.name})
def read_flow(self, name, draft=False):
return self._flow
def key_index(self):
return {self._flow.name: self._flow.id}
def head(self):
return ""
@@ -738,7 +747,7 @@ def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
session.add(
Run(
id="abandoned-1",
flow="study",
flow_id="study",
status="abandoned",
params={"lr": 0.3},
group_id="sweep-9",
@@ -749,7 +758,7 @@ def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
session.add(
Run(
id="going-1",
flow="study",
flow_id="study",
status="running",
created_at=datetime.now(UTC),
)
@@ -796,7 +805,7 @@ def test_runs_left_queued_are_woken_by_the_next_engine():
session.add(
Run(
id="orphan-1",
flow="study",
flow_id="study",
status="queued",
created_at=datetime.now(UTC),
)
@@ -804,7 +813,7 @@ def test_runs_left_queued_are_woken_by_the_next_engine():
session.add(
Run(
id="finished-1",
flow="study",
flow_id="study",
status="ok",
created_at=datetime.now(UTC),
)
@@ -903,7 +912,7 @@ def test_a_key_nobody_used_submits_normally(
class Recorder:
def submit(self, name, **kwargs):
seen.update(kwargs)
return Run(id="keyed-1", flow=name, created_at=datetime.now(UTC))
return Run(id="keyed-1", flow_id=name, created_at=datetime.now(UTC))
monkeypatch.setattr(client.app.state, "run_service", Recorder())
answer = client.post(
@@ -940,7 +949,7 @@ def deletable_run():
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))
Run(id=run_id, flow_id="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))
@@ -985,7 +994,11 @@ def test_a_node_finishing_after_its_run_was_deleted_is_dropped_quietly(caplog):
"""The constraint refuses its row, and that is a no-op rather than an error."""
service = RunService(controller=_OneFlow(FlowDef(name="gone")), queue=_Collect())
with caplog.at_level(logging.INFO, logger="fluksio.flow.runs"):
service._record_node("del-gone", NodeOutcome(node="gone.a", ok=True))
service._record_node(
Run(id="del-gone", flow_id="gone"),
"gone",
NodeOutcome(node="gone.a", ok=True),
)
with Session(db_engine) as session:
assert session.get(RunNode, {"run_id": "del-gone", "node": "gone.a"}) is None
@@ -1008,7 +1021,7 @@ def test_a_running_run_is_refused_rather_than_raced(client, superuser_token_head
session.add(
Run(
id=run_id,
flow="deleted",
flow_id="deleted",
status="running",
created_at=datetime.now(UTC),
)
@@ -1041,11 +1054,13 @@ def test_deleting_a_flows_runs_clears_that_flow_only(client, superuser_token_hea
made = datetime.now(UTC)
url = f"{settings.API_V1_STR}/runs"
with Session(db_engine) as session:
session.add(Run(id="bulk-1", flow="swept", status="ok", created_at=made))
session.add(Run(id="bulk-1", flow_id="swept", status="ok", created_at=made))
session.add(RunNode(run_id="bulk-1", node="swept.a", status="ok"))
session.add(RunMetric(run_id="bulk-1", name="swept.loss", step=0, value=1.0))
session.add(Run(id="bulk-2", flow="swept", status="running", created_at=made))
session.add(Run(id="keep-1", flow="other", status="ok", created_at=made))
session.add(
Run(id="bulk-2", flow_id="swept", status="running", created_at=made)
)
session.add(Run(id="keep-1", flow_id="other", status="ok", created_at=made))
session.commit()
busy = client.delete(url, params={"flow": "swept"}, headers=superuser_token_headers)
@@ -1063,7 +1078,7 @@ def test_deleting_a_flows_runs_clears_that_flow_only(client, superuser_token_hea
assert "2" in answer.json()["message"]
with Session(db_engine) as session:
assert session.exec(select(Run).where(col(Run.flow) == "swept")).all() == []
assert session.exec(select(Run).where(col(Run.flow_id) == "swept")).all() == []
for table in (RunNode, RunMetric):
assert (
session.exec(select(table).where(col(table.run_id) == "bulk-1")).all()
@@ -1079,10 +1094,10 @@ def test_history_also_clears_a_flows_cascades(client, superuser_token_headers):
made = datetime.now(UTC)
url = f"{settings.API_V1_STR}/runs"
with Session(db_engine) as session:
session.add(Run(id="hist-1", flow="gone", status="ok", created_at=made))
session.add(FlowRun(id="hist-1", flow="gone", source="run", started_at=made))
session.add(FlowRun(id="hist-2", flow="gone", started_at=made))
session.add(FlowRun(id="hist-3", flow="other", started_at=made))
session.add(Run(id="hist-1", flow_id="gone", status="ok", created_at=made))
session.add(FlowRun(id="hist-1", flow_id="gone", source="run", started_at=made))
session.add(FlowRun(id="hist-2", flow_id="gone", started_at=made))
session.add(FlowRun(id="hist-3", flow_id="other", started_at=made))
session.commit()
plain = client.delete(url, params={"flow": "gone"}, headers=superuser_token_headers)
@@ -1095,7 +1110,7 @@ def test_history_also_clears_a_flows_cascades(client, superuser_token_headers):
)
assert answer.status_code == 200
with Session(db_engine) as session:
left = session.exec(select(FlowRun).where(col(FlowRun.flow) == "gone")).all()
left = session.exec(select(FlowRun).where(col(FlowRun.flow_id) == "gone")).all()
assert left == []
session.delete(session.get(FlowRun, "hist-3"))
session.commit()
@@ -1114,8 +1129,8 @@ def reused_run():
"""A run of `quick` whose node was restored from a run of `train`."""
with Session(db_engine) as session:
made = datetime.now(UTC)
session.add(Run(id="src-1", flow="train", status="ok", created_at=made))
session.add(Run(id="reuse-1", flow="quick", status="ok", created_at=made))
session.add(Run(id="src-1", flow_id="train", status="ok", created_at=made))
session.add(Run(id="reuse-1", flow_id="quick", status="ok", created_at=made))
session.add(RunNode(run_id="src-1", node="train.fit", status="ok"))
session.add(
RunNode(
@@ -1197,7 +1212,7 @@ def test_a_node_answers_with_what_it_returned(client, superuser_token_headers):
run_id = new_run_id()
with Session(db_engine) as session:
session.add(
Run(id=run_id, flow="study", status="ok", created_at=datetime.now(UTC))
Run(id=run_id, flow_id="study", status="ok", created_at=datetime.now(UTC))
)
session.add(RunNode(run_id=run_id, node="fit", outputs='{"study.loss":0.5}'))
session.add(RunNode(run_id=run_id, node="plot", status="error"))
@@ -1233,7 +1248,7 @@ def plotted():
run_id = new_run_id()
with Session(db_engine) as session:
session.add(
Run(id=run_id, flow="study", status="ok", created_at=datetime.now(UTC))
Run(id=run_id, flow_id="study", status="ok", created_at=datetime.now(UTC))
)
for step, (loss, epoch) in enumerate([(1.0, 10.0), (0.5, 20.0), (0.25, 30.0)]):
session.add(_metric(run_id, "study.loss", step, loss, 100.0 + step * 5))
@@ -1263,7 +1278,7 @@ def test_the_metric_names_of_a_selection_are_exact(
session.add(
Run(
id=older,
flow="study",
flow_id="study",
status="ok",
created_at=datetime.now(UTC) - timedelta(hours=1),
)
@@ -1374,7 +1389,7 @@ def test_a_run_records_the_code_it_started_with_not_the_code_it_was_queued_with(
session.add(
Run(
id="stamp-1",
flow="study",
flow_id="study",
status="queued",
code_digest="at-submit",
created_at=datetime.now(UTC),
@@ -1395,7 +1410,7 @@ def test_a_tree_that_did_not_move_is_not_written_again():
session.add(
Run(
id="stamp-2",
flow="study",
flow_id="study",
status="queued",
code_digest="same",
created_at=datetime.now(UTC),
@@ -1429,7 +1444,7 @@ def exported():
session.add(
Run(
id=run_id,
flow="export-study",
flow_id="export-study",
status="ok",
params={
"lr": lr,
@@ -1531,7 +1546,7 @@ def test_an_exported_run_row_carries_every_recorded_input(
# csv is the default, and the columns are in the order the header names.
header = export().text.splitlines()[0]
assert header.startswith("id,flow,status,")
assert header.startswith("id,flow,flow_name,status,")
assert header.endswith(
"param.config.depth,param.config.model,param.epochs,param.lr,"
"metric.acc,metric.final_metrics.train_loss,"
@@ -1557,7 +1572,7 @@ def paged():
session.add(
Run(
id=f"page-{index}",
flow="paged-study",
flow_id="paged-study",
status="ok",
created_at=made + timedelta(minutes=index),
)
+14 -2
View File
@@ -31,7 +31,8 @@ def test_index_reaches_inside_flows_and_dashboards(
"title": "Searchable",
"nodes": [{"id": "sensor", "type": "python", "title": "Hall sensor"}],
},
"searchable",
# Ids that are not the keys, which is what a result opens by.
"x_searchable",
)
put_dashboard(
client,
@@ -50,7 +51,7 @@ def test_index_reaches_inside_flows_and_dashboards(
],
"version": 0,
},
"hall",
"x_hall",
)
entries = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
@@ -66,6 +67,17 @@ def test_index_reaches_inside_flows_and_dashboards(
assert found[("dashboard", "", "hall")]["title"] == "Hall"
assert found[("widget", "hall", "temperature")]["title"] == "Temperature"
assert found[("widget", "hall", "temperature")]["kind"] == "stat"
assert [
found[entry]["id"]
for entry in (
("flow", "", "searchable"),
("node", "searchable", "sensor"),
("dashboard", "", "hall"),
("widget", "hall", "temperature"),
)
] == ["x_searchable", "x_searchable", "x_hall", "x_hall"]
client.delete(f"{FLOWS}/x_searchable", headers=superuser_token_headers)
client.delete(f"{DASHBOARDS}/x_hall", headers=superuser_token_headers)
def test_index_lists_a_flows_inputs_and_outputs(
+17 -4
View File
@@ -86,15 +86,28 @@ def test_train_submits_the_node_as_wren_s_installation(
class Recorder:
def submit(self, name, **kwargs):
seen.update(kwargs, flow=name)
return Run(id="train-1", flow=name, created_at=datetime.now(UTC))
return Run(id="train-1", flow_id=name, created_at=datetime.now(UTC))
# Registered by sync, so under an id of its own: found by its key.
registered = put_flow(
client,
superuser_token_headers,
{"name": "wren_train", "mode": "batch"},
"x_wren_train",
)
assert registered.is_success, registered.text
monkeypatch.setattr(client.app.state, "run_service", Recorder())
answer = client.post(f"{PREFIX}/control/train", headers=superuser_token_headers)
try:
answer = client.post(f"{PREFIX}/control/train", headers=superuser_token_headers)
finally:
client.delete(
f"{settings.API_V1_STR}/flows/x_wren_train", headers=superuser_token_headers
)
assert answer.status_code == 202, answer.text
assert answer.json()["id"] == "train-1"
assert (seen["flow"], seen["no_cache"], seen["cause"], seen["for_node"]) == (
"wren_train",
"x_wren_train",
True,
"train",
f"{FLOW}.control",
@@ -161,7 +174,7 @@ def test_a_node_reads_its_own_latest_training(
session.add(
Run(
id=run_id,
flow="wren_train",
flow_id="wren_train",
status="ok",
for_node=f"{FLOW}.{node}",
result={"exact_match": 0.98, "coverage": 0.9},