Skip to content

Commit 7db3c87

Browse files
committed
keyviz admin: doc + aggregate fallback + time-bounds tests
Round-2 review fixes for PR #660 (Claude bot): - buildAPIMux layout comment was missing the keyviz route entry — add it next to the s3 routes so the documented surface matches the actual mux dispatch. - KeyVizHandler godoc now spells out that from_unix_ms / to_unix_ms treat 0 (or omitted) as "unbounded on that side" — NOT the Unix epoch. The behaviour was internally consistent with ringBuffer.Range but a caller could reasonably read 0 as 1970. - newKeyVizRowFrom now has a defensive fallback for an aggregate row with MemberRoutesTotal == 0: render route_count = len(MemberRoutes) instead of 0, so a virtual bucket never reports "0 routes" to the SPA. Should never happen with the current sampler, but the guard costs nothing. Tests: - TestKeyVizHandlerTimeBoundsParam — exercises a real time pair (parsed and forwarded to Snapshot via a capturingKeyVizSource), the 0 → unbounded contract, and the non-numeric → 400 invalid_query branch. - TestKeyVizHandlerAggregateFallbackWhenTotalZero — pins the aggregate-row defensive fallback.
1 parent 25ac777 commit 7db3c87

3 files changed

Lines changed: 116 additions & 3 deletions

File tree

internal/admin/keyviz_handler.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,9 @@ type KeyVizRow struct {
8585
// Query parameters (all optional):
8686
//
8787
// series - reads | writes | read_bytes | write_bytes (default: writes)
88-
// from_unix_ms - lower bound in unix ms (default: unbounded)
89-
// to_unix_ms - upper bound in unix ms (default: unbounded)
88+
// from_unix_ms - lower bound in unix ms; 0 or omitted means unbounded
89+
// on that side (NOT the Unix epoch)
90+
// to_unix_ms - upper bound in unix ms; same 0 = unbounded contract
9091
// rows - row budget; 0 means no cap, capped at 1024 (default: 0)
9192
//
9293
// Returns 503 codes.Unavailable when no sampler is configured so the
@@ -298,8 +299,19 @@ func keyVizSeriesPicker(series KeyVizSeries) func(keyviz.MatrixRow) uint64 {
298299

299300
func newKeyVizRowFrom(mr keyviz.MatrixRow, numCols int) *KeyVizRow {
300301
total := mr.MemberRoutesTotal
301-
if !mr.Aggregate && total == 0 {
302+
switch {
303+
case !mr.Aggregate && total == 0:
304+
// Individual slots with the field zero-initialised — every
305+
// real route contributes exactly one member to itself.
302306
total = 1
307+
case mr.Aggregate && total == 0:
308+
// Defensive fallback: a virtual bucket should always carry a
309+
// non-zero MemberRoutesTotal once foldIntoBucket has run, but
310+
// if a sampler ever serialises a just-coalesced bucket before
311+
// the count is set the SPA would render "0 routes" — which is
312+
// nonsense for an aggregate row. Fall back to the visible
313+
// MemberRoutes length so route_count stays meaningful.
314+
total = uint64(len(mr.MemberRoutes))
303315
}
304316
row := &KeyVizRow{
305317
BucketID: bucketIDFor(mr),

internal/admin/keyviz_handler_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8+
"strconv"
89
"testing"
910
"time"
1011

@@ -156,6 +157,8 @@ func TestKeyVizHandlerSeriesParam(t *testing.T) {
156157
{"write_bytes", 4444},
157158
} {
158159
t.Run(tc.series, func(t *testing.T) {
160+
// Sequential: the parent's `defer srv.Close()` would fire
161+
// before parallel subtests get to dial.
159162
resp := keyVizGet(t, srv.URL+"?series="+tc.series)
160163
defer resp.Body.Close()
161164
require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -287,3 +290,100 @@ func TestKeyVizHandlerRowsBudgetTieBreakDeterministic(t *testing.T) {
287290
require.Equal(t, "route:2", matrix.Rows[1].BucketID, "iteration %d", i)
288291
}
289292
}
293+
294+
// TestKeyVizHandlerTimeBoundsParam exercises the from_unix_ms /
295+
// to_unix_ms query parameters: a non-zero pair filters columns to the
296+
// requested half-open window, while 0 means "unbounded on that side"
297+
// (NOT the Unix epoch). The fakeKeyVizSource here does not actually
298+
// honour the bounds (its Snapshot ignores them) — what we're pinning
299+
// is the parse/dispatch contract: a parsable pair must yield 200,
300+
// 0 must reach the source as the zero Time, and a non-numeric value
301+
// must surface as 400 invalid_query.
302+
func TestKeyVizHandlerTimeBoundsParam(t *testing.T) {
303+
t.Parallel()
304+
captured := &capturingKeyVizSource{}
305+
h := NewKeyVizHandler(captured).WithClock(func() time.Time {
306+
return time.Unix(1_700_000_000, 0).UTC()
307+
})
308+
srv := httptest.NewServer(h)
309+
defer srv.Close()
310+
311+
from := time.Unix(1_699_000_000, 0)
312+
to := time.Unix(1_700_500_000, 0)
313+
u := srv.URL + "?from_unix_ms=" + strconv.FormatInt(from.UnixMilli(), 10) +
314+
"&to_unix_ms=" + strconv.FormatInt(to.UnixMilli(), 10)
315+
resp := keyVizGet(t, u)
316+
resp.Body.Close()
317+
require.Equal(t, http.StatusOK, resp.StatusCode)
318+
require.True(t, captured.from.Equal(from.UTC()), "from = %v, want %v", captured.from, from.UTC())
319+
require.True(t, captured.to.Equal(to.UTC()), "to = %v, want %v", captured.to, to.UTC())
320+
321+
// 0 → unbounded (zero Time), not Unix epoch.
322+
captured.reset()
323+
resp = keyVizGet(t, srv.URL+"?from_unix_ms=0&to_unix_ms=0")
324+
resp.Body.Close()
325+
require.Equal(t, http.StatusOK, resp.StatusCode)
326+
require.True(t, captured.from.IsZero(), "from = %v, want zero", captured.from)
327+
require.True(t, captured.to.IsZero(), "to = %v, want zero", captured.to)
328+
329+
// Non-numeric → 400 invalid_query.
330+
resp = keyVizGet(t, srv.URL+"?from_unix_ms=notanumber")
331+
defer resp.Body.Close()
332+
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
333+
var body map[string]string
334+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
335+
require.Equal(t, "invalid_query", body["error"])
336+
}
337+
338+
// capturingKeyVizSource records the from/to bounds the handler
339+
// forwarded so tests can assert on the parse step without rendering
340+
// any matrix data.
341+
type capturingKeyVizSource struct {
342+
from time.Time
343+
to time.Time
344+
}
345+
346+
func (c *capturingKeyVizSource) Snapshot(from, to time.Time) []keyviz.MatrixColumn {
347+
c.from = from
348+
c.to = to
349+
return nil
350+
}
351+
352+
func (c *capturingKeyVizSource) reset() {
353+
c.from = time.Time{}
354+
c.to = time.Time{}
355+
}
356+
357+
// TestKeyVizHandlerAggregateFallbackWhenTotalZero pins the defensive
358+
// fallback for the unlikely case where the sampler emits an aggregate
359+
// row with MemberRoutesTotal == 0: the handler must not serialise
360+
// route_count = 0 (which the SPA would render as "0 routes" — nonsense
361+
// for a virtual bucket). Instead it falls back to len(MemberRoutes).
362+
func TestKeyVizHandlerAggregateFallbackWhenTotalZero(t *testing.T) {
363+
t.Parallel()
364+
srv := newKeyVizTestServer(t, &fakeKeyVizSource{cols: []keyviz.MatrixColumn{
365+
{
366+
At: time.Unix(1_700_000_000, 0),
367+
Rows: []keyviz.MatrixRow{
368+
{
369+
RouteID: ^uint64(0),
370+
Start: []byte("c"),
371+
End: []byte("d"),
372+
Aggregate: true,
373+
MemberRoutes: []uint64{2, 3},
374+
MemberRoutesTotal: 0, // pathological zero
375+
Writes: 5,
376+
},
377+
},
378+
},
379+
}})
380+
defer srv.Close()
381+
382+
resp := keyVizGet(t, srv.URL)
383+
defer resp.Body.Close()
384+
var matrix KeyVizMatrix
385+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&matrix))
386+
require.Len(t, matrix.Rows, 1)
387+
require.True(t, matrix.Rows[0].Aggregate)
388+
require.Equal(t, uint64(2), matrix.Rows[0].RouteCount, "fallback to len(MemberRoutes)")
389+
}

internal/admin/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ func (s *Server) APIHandler() http.Handler {
208208
// DELETE /admin/api/v1/dynamo/tables/{name} (auth required, full role)
209209
// GET /admin/api/v1/s3/buckets (auth required)
210210
// GET /admin/api/v1/s3/buckets/{name} (auth required)
211+
// GET /admin/api/v1/keyviz/matrix (auth required)
211212
//
212213
// Body limit applies uniformly. CSRF and Audit middleware apply to
213214
// write-capable protected endpoints; login and logout carry their own

0 commit comments

Comments
 (0)