Runs export: a truncated one counts the selection into X-Total-Count; the SDK and fluksio export warn how many runs were left out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-14 23:29:48 +02:00
co-authored by Claude Opus 5.5
parent 02f43bf7c4
commit 8eec58d1d6
6 changed files with 66 additions and 17 deletions
+19 -15
View File
@@ -512,15 +512,22 @@ def _selected(
ids: str,
since: datetime | None,
until: datetime | None,
) -> list[Run]:
"""The runs an export covers, newest first — the filters the list takes.
) -> tuple[list[Run], int]:
"""The runs an export covers, newest first, and how many the filters match.
Capped: the filters bound a *sensible* request and nothing bounded an
unfiltered one, so asking for everything read every row of the table into
memory before a byte was sent. A truncated export says so in a header.
memory before a byte was sent. A truncated export says so in a header, and
only then is the selection counted, since otherwise the rows are the count.
"""
statement = _selection(flow, status, group, ids, since, until)
return list(session.exec(statement.limit(EXPORT_CAP + 1)))
runs = list(session.exec(statement.limit(EXPORT_CAP + 1)))
if len(runs) <= EXPORT_CAP:
return runs, len(runs)
total = session.exec(
select(func.count()).select_from(statement.order_by(None).subquery())
).one()
return runs[:EXPORT_CAP], total
def _cell(value: Any) -> Any:
@@ -613,7 +620,7 @@ def _stream(
name: str,
columns: list[str],
chunks: Iterator[list[dict[str, Any]]],
truncated: bool = False,
total: int = 0,
) -> StreamingResponse:
"""The rows out, a chunk at a time rather than a list.
@@ -629,11 +636,12 @@ def _stream(
body = _csv(columns, chunks)
media = "text/csv; charset=utf-8"
headers = {"Content-Disposition": f'attachment; filename="{name}.{fmt}"'}
if truncated:
if total > EXPORT_CAP:
# The body is otherwise indistinguishable from a complete one, and an
# export quietly missing its oldest runs is the wrong thing to hand
# somebody who is about to plot it.
# somebody who is about to plot it. The total says how many it missed.
headers["X-Truncated"] = str(EXPORT_CAP)
headers["X-Total-Count"] = str(total)
return StreamingResponse(body, media_type=media, headers=headers)
@@ -656,9 +664,7 @@ def export_metrics(
the metrics it lists; ``stride`` thins each curve — per series, so asking
for every tenth point of two metrics gives every tenth point of both.
"""
runs = _selected(session, flow, status, group, ids, since, until)
truncated = len(runs) > EXPORT_CAP
runs = runs[:EXPORT_CAP]
runs, total = _selected(session, flow, status, group, ids, since, until)
wanted = [part for part in name.split(",") if part]
step = max(1, stride)
@@ -682,7 +688,7 @@ def export_metrics(
)
yield rows
return _stream(format, "metrics", list(METRIC_COLUMNS), chunks(), truncated)
return _stream(format, "metrics", list(METRIC_COLUMNS), chunks(), total)
@router.get("/export/runs")
@@ -710,9 +716,7 @@ def export_runs(
three fields rather than two blobs, and the defaults reach the same
depth.
"""
runs = _selected(session, flow, status, group, ids, since, until)
truncated = len(runs) > EXPORT_CAP
runs = runs[:EXPORT_CAP]
runs, total = _selected(session, flow, status, group, ids, since, until)
inputs = [part for part in params.split(",") if part] or sorted(
{path for run in runs for path, _ in _leaves(run.params)}
)
@@ -730,7 +734,7 @@ def export_runs(
row.update((f"metric.{k}", _cell(_dig(run.result, k))) for k in scores)
yield [row]
return _stream(format, "runs", columns, chunks(), truncated)
return _stream(format, "runs", columns, chunks(), total)
@router.get("/metrics/names", response_model=list[str])