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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user