Record batch runs beside cascades so Home lists them

A batch run opens no cascade, and FlowRun was written only from
cascade_started — so `fluksio run` showed on /runs and in `fluksio status` and
was simply absent from Home. The collector now folds the run_started and
run_finished events RunService already published. Such a record is exempt from
the staleness sweep in both places: a training step of an hour is a normal one,
and only run_finished ends it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:54:15 +02:00
co-authored by Claude Opus 5
parent 6bc71ddaf3
commit 121cb2e8f0
3 changed files with 89 additions and 12 deletions
+34 -6
View File
@@ -35,6 +35,10 @@ FLUSH_INTERVAL_S = 15.0
DETAIL_CAP = 8000 DETAIL_CAP = 8000
#: A cascade still open this long after it started is never finishing. #: A cascade still open this long after it started is never finishing.
RUN_STALE_S = 600.0 RUN_STALE_S = 600.0
#: What ``flow_run.source`` says for a batch run, as against the cause a
#: cascade carries. A training step can legitimately run for hours, so this is
#: also what exempts one from the staleness sweep above.
RUN_SOURCE = "run"
#: ``metric_minute.flow``/``node`` and ``flow_run.flow`` are this wide, and a #: ``metric_minute.flow``/``node`` and ``flow_run.flow`` are this wide, and a
#: node id has no length of its own. One row over it aborts the transaction, #: node id has no length of its own. One row over it aborts the transaction,
#: which would take every other row in the flush with it. #: which would take every other row in the flush with it.
@@ -214,9 +218,18 @@ class MetricsCollector:
if kind == "cascade_finished": if kind == "cascade_finished":
if run is not None: if run is not None:
run["finished_at"] = datetime.fromtimestamp(ts, UTC) self._finish_run(run, ts)
run["duration_ms"] = round((ts - run["started_ts"]) * 1000, 2) return
run["status"] = "error" if run["errors"] else "ok"
# A batch run opens no cascade, so without these it ran, recorded its
# nodes and its failures, and appeared in no history at all.
if kind == "run_started":
self._start_run({**event, "cause": RUN_SOURCE}, ts)
return
if kind == "run_finished":
if run is not None:
self._finish_run(run, ts, str(event.get("status") or ""))
return return
if kind == "node_health": if kind == "node_health":
@@ -257,6 +270,14 @@ class MetricsCollector:
) )
) )
def _finish_run(
self, run: dict[str, Any], ts: float, status: str = ""
) -> None:
"""Close an open record. A run says how it ended; a cascade is told."""
run["finished_at"] = datetime.fromtimestamp(ts, UTC)
run["duration_ms"] = round((ts - run["started_ts"]) * 1000, 2)
run["status"] = status or ("error" if run["errors"] else "ok")
def _start_run(self, event: dict[str, Any], ts: float) -> None: def _start_run(self, event: dict[str, Any], ts: float) -> None:
run_id = str(event.get("run") or "") run_id = str(event.get("run") or "")
if not run_id: if not run_id:
@@ -305,7 +326,11 @@ class MetricsCollector:
self._last_prune = time.monotonic() self._last_prune = time.monotonic()
cutoff = time.time() - RUN_STALE_S cutoff = time.time() - RUN_STALE_S
for run_id, run in list(self._runs.items()): for run_id, run in list(self._runs.items()):
if run["status"] != "running" or run["started_ts"] < cutoff: if run["status"] != "running":
del self._runs[run_id]
# A batch run is held however long it takes: a training step of an
# hour is a normal one, and `run_finished` is what ends it.
elif run["source"] != RUN_SOURCE and run["started_ts"] < cutoff:
del self._runs[run_id] del self._runs[run_id]
# Held tracebacks survive the flush: the log and the failure it belongs # Held tracebacks survive the flush: the log and the failure it belongs
# to are two events, and a flush can fall between them. One per node, # to are two events, and a flush can fall between them. One per node,
@@ -405,12 +430,15 @@ class MetricsCollector:
session.execute(delete(MetricBucket).where(col(MetricBucket.bucket) < cutoff)) session.execute(delete(MetricBucket).where(col(MetricBucket.bucket) < cutoff))
session.execute(delete(EngineEvent).where(col(EngineEvent.ts) < cutoff)) session.execute(delete(EngineEvent).where(col(EngineEvent.ts) < cutoff))
session.execute(delete(FlowRun).where(col(FlowRun.started_at) < cutoff)) session.execute(delete(FlowRun).where(col(FlowRun.started_at) < cutoff))
# A run still open long after it started did not finish; saying so is # A cascade still open long after it started did not finish; saying so
# more honest than leaving it running forever. # is more honest than leaving it running forever. A batch run is not
# swept: it is allowed to take hours, and `Run` has its own lease sweep
# for the case where the engine died holding one.
session.execute( session.execute(
update(FlowRun) update(FlowRun)
.where( .where(
col(FlowRun.status) == "running", col(FlowRun.status) == "running",
col(FlowRun.source) != RUN_SOURCE,
col(FlowRun.started_at) < now - timedelta(seconds=RUN_STALE_S), col(FlowRun.started_at) < now - timedelta(seconds=RUN_STALE_S),
) )
.values(status="abandoned", finished_at=now) .values(status="abandoned", finished_at=now)
+9 -4
View File
@@ -279,14 +279,19 @@ class EngineEvent(SQLModel, table=True):
class FlowRun(SQLModel, table=True): class FlowRun(SQLModel, table=True):
"""One cascade, from the item that started it to the last node in it.""" """One cascade, from the item that started it to the last node in it.
Or one batch run: it opens no cascade, but it is still a thing that ran,
and the history screens are where somebody looks for it.
"""
__tablename__ = "flow_run" __tablename__ = "flow_run"
#: The queue entry id, or a `manual-` one for a run that never queued. #: The queue entry id, a `manual-` one for a run that never queued, or the
#: `Run.id` of a batch run.
id: str = Field(primary_key=True, max_length=64) id: str = Field(primary_key=True, max_length=64)
flow: str = Field(index=True, max_length=255) flow: str = Field(index=True, max_length=255)
#: What caused it: the work item's cause, or "manual". #: What caused it: the work item's cause, "manual", or "run" for a batch.
source: str = "" source: str = ""
started_at: datetime = Field( started_at: datetime = Field(
index=True, index=True,
@@ -296,7 +301,7 @@ class FlowRun(SQLModel, table=True):
default=None, default=None,
sa_type=UTCDateTime, sa_type=UTCDateTime,
) )
#: running, ok, error or abandoned. #: running, ok, error, abandoned — or whatever a batch run ended as.
status: str = "running" status: str = "running"
nodes: int = 0 nodes: int = 0
errors: int = 0 errors: int = 0
+46 -2
View File
@@ -226,11 +226,55 @@ def test_a_traceback_no_failure_ever_claims_is_dropped() -> None:
assert collector._tracebacks == {} assert collector._tracebacks == {}
def test_a_batch_run_is_recorded_beside_the_cascades(db: Session) -> None:
"""`fluksio run` showed on /runs and nowhere on Home.
It opens no cascade, so the collector saw its nodes and its failures with
no record to fold them into. The start is aged past the staleness cutoff on
purpose: a training step of an hour is a normal one, and only
`run_finished` may end a batch run.
"""
collector = MetricsCollector(EventBus())
started = time.time() - RUN_STALE_S - 60
collector.handle(
{"type": "run_started", "run": "run-batch", "flow": FLOW, "ts": started}
)
collector.handle(
{
"type": "node_executed",
"flow": FLOW,
"node": NODE,
"run": "run-batch",
"duration_ms": 5.0,
"ts": started,
}
)
asyncio.run(collector.flush())
row = db.exec(select(FlowRun).where(FlowRun.id == "run-batch")).one()
assert (row.status, row.source, row.nodes) == ("running", "run", 1)
collector.handle(
{
"type": "run_finished",
"run": "run-batch",
"flow": FLOW,
"status": "ok",
"ts": started + 30,
}
)
asyncio.run(collector.flush())
db.expire_all()
row = db.exec(select(FlowRun).where(FlowRun.id == "run-batch")).one()
assert (row.status, row.duration_ms) == ("ok", 30_000.0)
def test_a_failure_keeps_the_run_it_happened_in(db: Session) -> None: def test_a_failure_keeps_the_run_it_happened_in(db: Session) -> None:
"""The payload always carried it; the row used to drop it. """The payload always carried it; the row used to drop it.
A batch run publishes no `cascade_started`, so there is no `FlowRun` beside Nothing told this collector the run had started, so there is no `FlowRun`
this — which is exactly the case that had no way of being asked about. beside it — the failure still has to name what it happened in.
""" """
collector = MetricsCollector(EventBus()) collector = MetricsCollector(EventBus())
ts = datetime.now(UTC).replace(second=0, microsecond=0).timestamp() ts = datetime.now(UTC).replace(second=0, microsecond=0).timestamp()