diff --git a/backend/fluksio/api/routes/observability.py b/backend/fluksio/api/routes/observability.py index 1333baf..4959f67 100644 --- a/backend/fluksio/api/routes/observability.py +++ b/backend/fluksio/api/routes/observability.py @@ -124,6 +124,17 @@ def _since(hours: int) -> datetime: return datetime.now(UTC) - timedelta(hours=hours) +def _closed_before(stride: int) -> datetime: + """The start of the bin `now` falls in — the newest one still filling. + + A bucket is upserted every flush while its minute runs, so the newest bin + always holds part of a minute. Drawn, it reads as a fall that never + happened; excluded, the curve ends on the last bin that is all there. + """ + now = int(datetime.now(UTC).timestamp()) + return datetime.fromtimestamp(now // stride * stride, UTC) + + def _window_hours(hours: int) -> int: """A window the rollups can answer for: an hour at least, retention at most. @@ -235,7 +246,10 @@ def read_timeseries( func.max(col(MetricBucket.duration_max_ms)).label("max_ms"), func.sum(col(MetricBucket.lag_sum_ms)).label("lag_sum_ms"), func.sum(col(MetricBucket.items)).label("items"), - ).where(col(MetricBucket.bucket) >= _since(hours)) + ).where( + col(MetricBucket.bucket) >= _since(hours), + col(MetricBucket.bucket) < _closed_before(stride), + ) if flow: statement = statement.where(col(MetricBucket.flow) == flow) if node: @@ -259,9 +273,14 @@ def read_timeseries( def read_flow_rollups(session: SessionDep, hours: int = 24) -> Any: """One row per flow, with a coarse trend of how much it ran.""" hours = _window_hours(hours) - since = _since(hours) window = hours * 3600 - start = since.timestamp() + # The window ends on the last closed minute rather than on `now`, so every + # slice it hands back is a whole one. Ending at `now` cut the last slice + # wherever the request happened to land, and the trend fell off a cliff + # that was only the clock. + end = int(datetime.now(UTC).timestamp()) // 60 * 60 + start = float(end - window) + since = datetime.fromtimestamp(start, UTC) # Binned to the sparkline slice rather than the minute, so a flow costs at # most SPARK_SLICES rows however long the window is. The slice is the @@ -282,7 +301,10 @@ def read_flow_rollups(session: SessionDep, hours: int = 24) -> Any: func.sum(col(MetricBucket.lag_sum_ms)).label("lag_sum_ms"), func.sum(col(MetricBucket.items)).label("items"), ) - .where(col(MetricBucket.bucket) >= since) + .where( + col(MetricBucket.bucket) >= since, + col(MetricBucket.bucket) < datetime.fromtimestamp(float(end), UTC), + ) .group_by(col(MetricBucket.flow), slot) ) diff --git a/backend/tests/api/routes/test_observability.py b/backend/tests/api/routes/test_observability.py index 95331a5..010714f 100644 --- a/backend/tests/api/routes/test_observability.py +++ b/backend/tests/api/routes/test_observability.py @@ -195,6 +195,47 @@ def test_the_timeseries_folds_into_the_requested_bucket( assert points[0]["ts"] % 900 == 0 +def test_the_bucket_still_filling_is_held_back( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """The minute in progress is not drawn. + + A bucket is upserted every flush while its minute runs, so the newest one + only ever holds part of a minute. Charted, it reads as a fall that never + happened. + """ + flow = "open-bucket-test" + minute = int(datetime.now(UTC).timestamp()) // 60 * 60 + for offset, executions in ((-60, 3), (0, 1)): + db.add( + MetricBucket( + flow=flow, + node=f"{flow}.calc", + bucket=datetime.fromtimestamp(minute + offset, UTC), + executions=executions, + ) + ) + db.commit() + + points = client.get( + f"{PREFIX}/timeseries", + headers=superuser_token_headers, + params={"flow": flow, "hours": 1}, + ).json() + flows = client.get( + f"{PREFIX}/flows", headers=superuser_token_headers, params={"hours": 1} + ).json() + row = next(entry for entry in flows if entry["flow"] == flow) + + # Unless that minute closed while the requests were in flight — then both + # buckets are complete and counting both is right. + if int(datetime.now(UTC).timestamp()) // 60 * 60 == minute: + assert [int(point["ts"]) for point in points] == [minute - 60] + # The window ends on the last closed minute, so the final slice is a + # whole one rather than however much of this minute has arrived. + assert (row["executions"], row["spark"][-1]) == (3, 3) + + def test_a_zero_hour_window_is_still_an_hour( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: diff --git a/frontend/src/components/Health/HealthOverview.tsx b/frontend/src/components/Health/HealthOverview.tsx index a206434..7339fc6 100644 --- a/frontend/src/components/Health/HealthOverview.tsx +++ b/frontend/src/components/Health/HealthOverview.tsx @@ -41,8 +41,9 @@ function Tile({ * Sixty slices of whatever window is selected, so the curve stays the same * width and only its resolution moves. The same curve the node panel and the * edge popover draw, dot included, in the chart ramp this page's other graphs - * use. The dot marks the newest slice rather than this instant — the rollups - * are polled, so that slice is the last complete one. + * use. The dot marks the newest slice rather than this instant: the server + * holds the slice that is still filling back, so the curve ends on one that is + * all there. */ function Spark({ counts }: { counts: number[] }) { const points: HistoryPoint[] = counts.map((value, index) => ({