Let a pinned minute read runs from the whole day

/observability/runs gains since/until, so the throughput chart's pin asks
the server for its minute instead of filtering a fixed recent list. This
engine writes ~60 runs a minute, so any minute but the newest read empty.

since is inclusive and until exclusive, matching the minute buckets the
charts are drawn from. Hover stays the client-side preview it was:
scrubbing a day would otherwise be a request per minute rested on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
This commit is contained in:
2026-08-17 00:27:40 +02:00
co-authored by Claude Fable 5
parent affb0a5f8c
commit 0b91379914
7 changed files with 94 additions and 12 deletions
-1
View File
@@ -51,7 +51,6 @@ is what M4 still waits on, together with porting the flows.
- CHORE/API: the metrics collector is a bus subscriber, so a storm that overflows the bus queue undercounts. The events dropped are the same ones the websocket drops; exact accounting would need the collector to be fed from the engine rather than the bus. - CHORE/API: the metrics collector is a bus subscriber, so a storm that overflows the bus queue undercounts. The events dropped are the same ones the websocket drops; exact accounting would need the collector to be fed from the engine rather than the bus.
- CHORE/API: `/observability/summary` reports the work queue's `depth` as the Redis stream length, which is the journal size (capped at `STREAM_MAXLEN`) rather than a backlog. The health screen shows `pending` instead; the field name still invites the wrong reading. - CHORE/API: `/observability/summary` reports the work queue's `depth` as the Redis stream length, which is the journal size (capped at `STREAM_MAXLEN`) rather than a backlog. The health screen shows `pending` instead; the field name still invites the wrong reading.
- FEAT/UI: the health screen's window is fixed at 24 hours and the charts fold minute buckets in Python. A range picker (and `date_bin()` behind it) is the next step if anyone wants a week. - FEAT/UI: the health screen's window is fixed at 24 hours and the charts fold minute buckets in Python. A range picker (and `date_bin()` behind it) is the next step if anyone wants a week.
- FEAT/API: `/observability/runs` takes no time range and caps at 200 rows, so picking a minute on the throughput chart can only filter the runs the list happens to hold — on a busy engine that is its last minute or so, and any earlier minute reads as empty. A `since`/`until` parameter would let a moment on a chart reach the whole day. Failures are sparse enough that the same pairing works there.
- CHORE/FLOW: run records for a deleted flow stay until the retention window passes, so a flow that no longer exists keeps appearing in the history. Deliberate — it is a record of what ran — but `forget_flow` could offer to clear it. - CHORE/FLOW: run records for a deleted flow stay until the retention window passes, so a flow that no longer exists keeps appearing in the history. Deliberate — it is a record of what ran — but `forget_flow` could offer to clear it.
- CHORE/API: nothing can ask the collector to flush now, so anything needing the tables to be current has to wait out `FLUSH_INTERVAL_S` — which is what the soak harness does before clearing its own rows. - CHORE/API: nothing can ask the collector to flush now, so anything needing the tables to be current has to wait out `FLUSH_INTERVAL_S` — which is what the soak harness does before clearing its own rows.
- BUG/UI: `MemoryWorkQueue.stats()` hard-codes `pending: 0`, so the health tile always reads zero on a stack without Redis. - BUG/UI: `MemoryWorkQueue.stats()` hard-codes `pending: 0`, so the health tile always reads zero on a stack without Redis.
+16 -1
View File
@@ -97,6 +97,11 @@ def _since(hours: int) -> datetime:
return datetime.now(timezone.utc) - timedelta(hours=hours) return datetime.now(timezone.utc) - timedelta(hours=hours)
def _aware(when: datetime) -> datetime:
"""A bound as the columns store it. A naive one is read as UTC."""
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
@router.get("/summary", response_model=HealthSummary) @router.get("/summary", response_model=HealthSummary)
async def read_summary( async def read_summary(
request: Request, controller: FlowControllerDep, session: SessionDep request: Request, controller: FlowControllerDep, session: SessionDep
@@ -275,14 +280,24 @@ def read_runs(
session: SessionDep, session: SessionDep,
flow: str | None = None, flow: str | None = None,
status: str | None = None, status: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 50, limit: int = 50,
) -> Any: ) -> Any:
"""Recent cascades, newest first.""" """Recent cascades, newest first.
``since`` is inclusive and ``until`` exclusive, so a window of one minute
holds exactly the runs of the minute bucket the charts are drawn from.
"""
statement = select(FlowRun).order_by(col(FlowRun.started_at).desc()) statement = select(FlowRun).order_by(col(FlowRun.started_at).desc())
if flow: if flow:
statement = statement.where(col(FlowRun.flow) == flow) statement = statement.where(col(FlowRun.flow) == flow)
if status: if status:
statement = statement.where(col(FlowRun.status) == status) statement = statement.where(col(FlowRun.status) == status)
if since:
statement = statement.where(col(FlowRun.started_at) >= _aware(since))
if until:
statement = statement.where(col(FlowRun.started_at) < _aware(until))
return list(session.exec(statement.limit(min(limit, 200)))) return list(session.exec(statement.limit(min(limit, 200))))
@@ -102,3 +102,35 @@ def test_the_history_reads_back(
dead = client.get(f"{PREFIX}/dead-letter", headers=superuser_token_headers) dead = client.get(f"{PREFIX}/dead-letter", headers=superuser_token_headers)
assert dead.status_code == 200 assert dead.status_code == 200
assert isinstance(dead.json(), list) assert isinstance(dead.json(), list)
def test_runs_narrow_to_one_minute(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
"""A minute picked off a chart reaches past what the recent list holds."""
minute = datetime.now(timezone.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-after",
flow=FLOW,
started_at=minute + timedelta(minutes=1),
status="ok",
)
)
db.commit()
runs = client.get(
f"{PREFIX}/runs",
headers=superuser_token_headers,
params={
"since": minute.isoformat(),
"until": (minute + timedelta(minutes=1)).isoformat(),
},
).json()
# The upper bound is exclusive, so the run starting the next minute is not
# in this one.
assert [run["id"] for run in runs] == ["minute-in"]
+7
View File
@@ -1147,9 +1147,14 @@ export class ObservabilityService {
/** /**
* Read Runs * Read Runs
* Recent cascades, newest first. * Recent cascades, newest first.
*
* ``since`` is inclusive and ``until`` exclusive, so a window of one minute
* holds exactly the runs of the minute bucket the charts are drawn from.
* @param data The data for the request. * @param data The data for the request.
* @param data.flow * @param data.flow
* @param data.status * @param data.status
* @param data.since
* @param data.until
* @param data.limit * @param data.limit
* @returns RunRow Successful Response * @returns RunRow Successful Response
* @throws ApiError * @throws ApiError
@@ -1161,6 +1166,8 @@ export class ObservabilityService {
query: { query: {
flow: data.flow, flow: data.flow,
status: data.status, status: data.status,
since: data.since,
until: data.until,
limit: data.limit limit: data.limit
}, },
errors: { errors: {
+2
View File
@@ -1072,7 +1072,9 @@ export type ObservabilityReadFlowRollupsResponse = (Array<FlowRollup>);
export type ObservabilityReadRunsData = { export type ObservabilityReadRunsData = {
flow?: (string | null); flow?: (string | null);
limit?: number; limit?: number;
since?: (string | null);
status?: (string | null); status?: (string | null);
until?: (string | null);
}; };
export type ObservabilityReadRunsResponse = (Array<RunRow>); export type ObservabilityReadRunsResponse = (Array<RunRow>);
@@ -17,6 +17,7 @@ import {
failuresQueryOptions, failuresQueryOptions,
healthKeys, healthKeys,
minuteOf, minuteOf,
minuteRunsQueryOptions,
runsQueryOptions, runsQueryOptions,
timeseriesQueryOptions, timeseriesQueryOptions,
} from "./queries" } from "./queries"
@@ -174,6 +175,7 @@ export function HealthActivity() {
const { data: series } = useQuery(timeseriesQueryOptions()) const { data: series } = useQuery(timeseriesQueryOptions())
const { data: runs } = useQuery(runsQueryOptions()) const { data: runs } = useQuery(runsQueryOptions())
const { data: pinnedRuns } = useQuery(minuteRunsQueryOptions(runsAt.pinned))
const { data: failures } = useQuery(failuresQueryOptions()) const { data: failures } = useQuery(failuresQueryOptions())
const { data: audit } = useQuery(auditQueryOptions()) const { data: audit } = useQuery(auditQueryOptions())
const { data: dead } = useQuery(deadLetterQueryOptions()) const { data: dead } = useQuery(deadLetterQueryOptions())
@@ -201,8 +203,14 @@ export function HealthActivity() {
): HistoryPoint[] => ): HistoryPoint[] =>
points.map((point) => ({ ts: point.ts, value: pick(point) })) points.map((point) => ({ ts: point.ts, value: pick(point) }))
// A pin is read back from the server, so it reaches a minute the recent list
// is nowhere near deep enough to hold. A hover stays the client-side preview
// it is: scrubbing a day's chart would otherwise be a request per minute the
// pointer rests on.
const shownRuns = const shownRuns =
runsAt.at === null runsAt.pinned !== null
? (pinnedRuns ?? [])
: runsAt.at === null
? (runs ?? []).slice(0, RUNS_SHOWN) ? (runs ?? []).slice(0, RUNS_SHOWN)
: (runs ?? []).filter((run) => minuteOf(run.started_at) === runsAt.at) : (runs ?? []).filter((run) => minuteOf(run.started_at) === runsAt.at)
const shownFailures = const shownFailures =
@@ -282,6 +290,8 @@ export function HealthActivity() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{runsAt.at === null {runsAt.at === null
? "No runs recorded yet." ? "No runs recorded yet."
: runsAt.pinned !== null
? "Nothing ran in this minute."
: "No run from this minute is in the recent list."} : "No run from this minute is in the recent list."}
</p> </p>
)} )}
+23 -6
View File
@@ -9,13 +9,10 @@ const REFRESH = 30_000
/** /**
* How deep the run and failure lists are read. * How deep the run and failure lists are read.
* *
* Deeper than they are shown: picking a minute off a chart filters these rows * Deeper than they are shown: hovering a minute on a chart previews these rows
* client-side, and a list holding only the newest handful would have nothing * client-side, and a list holding only the newest handful would have nothing
* to find for any minute but the current one. * to show for any minute but the current one. Pinning asks the server for the
* * minute instead, which is what reaches past this depth.
* ponytail: the runs endpoint caps at 200 and takes no time range, so a busy
* engine still only covers its last minute or so. A `since` parameter is what
* would let a moment on the chart reach the whole day.
*/ */
const RUN_DEPTH = 200 const RUN_DEPTH = 200
const EVENT_DEPTH = 100 const EVENT_DEPTH = 100
@@ -53,6 +50,26 @@ export const runsQueryOptions = () => ({
refetchInterval: REFRESH, refetchInterval: REFRESH,
}) })
/**
* The runs of one minute, wherever it sits in the day.
*
* A busy engine writes more runs per minute than the recent list is deep, so a
* pinned moment is asked for rather than filtered out of what is already held.
* `at` is a minute start, and the window is that minute.
*/
export const minuteRunsQueryOptions = (at: number | null) => ({
queryKey: ["observability", "runs", at] as const,
queryFn: () =>
ObservabilityService.readRuns({
since: new Date((at ?? 0) * 1000).toISOString(),
until: new Date(((at ?? 0) + 60) * 1000).toISOString(),
limit: RUN_DEPTH,
}),
// A minute that has passed does not change, and the current one is refreshed
// by the unpinned list anyway.
enabled: at !== null,
})
export const failuresQueryOptions = () => ({ export const failuresQueryOptions = () => ({
queryKey: [...healthKeys.events, "failure"] as const, queryKey: [...healthKeys.events, "failure"] as const,
queryFn: () => queryFn: () =>