Skip to content

keyviz admin: apply default rows budget when omitted (PR #660 follow-up)#672

Merged
bootjp merged 3 commits intomainfrom
feat/keyviz-admin-rows-default
Apr 26, 2026
Merged

keyviz admin: apply default rows budget when omitted (PR #660 follow-up)#672
bootjp merged 3 commits intomainfrom
feat/keyviz-admin-rows-default

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented Apr 26, 2026

Summary

Follow-up to PR #660, which was merged at the round-2 commit before this Codex P1 fix propagated. The orphaned change:

Codex round-3 P1: ?rows= was optional and parsed to 0 when omitted; applyKeyVizRowBudget then treats budget <= 0 as "no cap" and returns every tracked route in one payload — defeating the 1024-row resource guard the cap was supposed to provide. A normal SPA poll without ?rows= would fall into this trap.

  • parseKeyVizParams now pre-seeds rows = keyVizRowBudgetCap so an omitted query parameter inherits the cap.
  • setKeyVizRowsParam collapses an explicit rows=0 to the cap so callers cannot disable the budget by passing 0.
  • Godoc updated: omitted / 0 / negative all yield the cap; explicit values above the cap are silently clamped down.

Test plan

  • TestKeyVizHandlerOmittedRowsAppliesDefaultCap — stages keyVizRowBudgetCap+5 routes and confirms both the omitted ("") and ?rows=0 forms truncate down to keyVizRowBudgetCap.
  • Existing TestKeyVizHandlerHonorsRowsBudget and TestKeyVizHandlerClampsRowsBudgetToCap still pass — the change is additive at the default end.
  • go test -race -count=1 ./internal/admin/... clean.
  • golangci-lint run ./internal/admin/... clean.

Codex round-3 P1 on PR #660: omitting ?rows= left the parsed
budget at the zero value, and applyKeyVizRowBudget treats
budget <= 0 as "no cap" — so a normal SPA poll without rows
would receive the full route catalogue (potentially every
MaxTrackedRoutes route) in a single payload, defeating the
1024-row resource guard the cap was supposed to provide.

Pre-seed parseKeyVizParams.rows to keyVizRowBudgetCap so an
omitted query parameter inherits the cap. setKeyVizRowsParam
now also collapses an explicit rows=0 to the cap so callers
cannot disable the budget by passing 0.

Test TestKeyVizHandlerOmittedRowsAppliesDefaultCap stages
keyVizRowBudgetCap+5 routes and confirms both the omitted
and rows=0 forms truncate down to keyVizRowBudgetCap.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

Warning

Rate limit exceeded

@bootjp has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 57 minutes and 36 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 57 minutes and 36 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 31f0da8c-5054-4558-a0ad-4341765376ce

📥 Commits

Reviewing files that changed from the base of the PR and between ee521be and b629f2b.

📒 Files selected for processing (2)
  • internal/admin/keyviz_handler.go
  • internal/admin/keyviz_handler_test.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/keyviz-admin-rows-default

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the KeyViz handler to enforce a row budget cap of 1024 for the 'rows' query parameter, ensuring that omitted or zero values default to this limit to prevent resource exhaustion. The changes include documentation updates, logic to apply the default cap in the parameter parser, and a new test case. Feedback focuses on a discrepancy where negative values currently trigger an error instead of defaulting to the cap as documented, and suggests improving test coverage and resource cleanup in the test suite.

Comment thread internal/admin/keyviz_handler.go Outdated
Comment on lines 223 to 230
if err != nil || n < 0 {
return errors.New("rows must be a non-negative integer")
}
if n > keyVizRowBudgetCap {
if n == 0 || n > keyVizRowBudgetCap {
// Explicit 0 collapses to the cap (same as omitting the
// param) so callers can't disable the budget by passing 0.
n = keyVizRowBudgetCap
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation of setKeyVizRowsParam returns an error for negative values, which contradicts the updated documentation and the PR description stating that negative values should yield the default cap. Applying a fixed bound to this parameter prevents potential resource exhaustion from large or invalid requests.

Suggested change
if err != nil || n < 0 {
return errors.New("rows must be a non-negative integer")
}
if n > keyVizRowBudgetCap {
if n == 0 || n > keyVizRowBudgetCap {
// Explicit 0 collapses to the cap (same as omitting the
// param) so callers can't disable the budget by passing 0.
n = keyVizRowBudgetCap
}
if err != nil {
return errors.New("rows must be an integer")
}
if n <= 0 || n > keyVizRowBudgetCap {
// Explicit 0 or negative collapses to the cap (same as
// omitting the param) so callers can't disable the budget.
n = keyVizRowBudgetCap
}
References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

Comment thread internal/admin/keyviz_handler_test.go Outdated
Comment on lines +316 to +324
for _, query := range []string{"", "?rows=0"} {
resp := keyVizGet(t, srv.URL+query)
require.Equal(t, http.StatusOK, resp.StatusCode)
var matrix KeyVizMatrix
require.NoError(t, json.NewDecoder(resp.Body).Decode(&matrix))
resp.Body.Close()
require.Len(t, matrix.Rows, keyVizRowBudgetCap,
"omitted/0 rows must apply the default cap (query=%q)", query)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test loop to include a negative rows value, ensuring it also correctly defaults to the cap as specified in the documentation. Additionally, ensure the error from Close() is not ignored to maintain visibility into resource cleanup issues.

Suggested change
for _, query := range []string{"", "?rows=0"} {
resp := keyVizGet(t, srv.URL+query)
require.Equal(t, http.StatusOK, resp.StatusCode)
var matrix KeyVizMatrix
require.NoError(t, json.NewDecoder(resp.Body).Decode(&matrix))
resp.Body.Close()
require.Len(t, matrix.Rows, keyVizRowBudgetCap,
"omitted/0 rows must apply the default cap (query=%q)", query)
}
for _, query := range []string{"", "?rows=0", "?rows=-1"} {
resp := keyVizGet(t, srv.URL+query)
require.Equal(t, http.StatusOK, resp.StatusCode)
var matrix KeyVizMatrix
require.NoError(t, json.NewDecoder(resp.Body).Decode(&matrix))
require.NoError(t, resp.Body.Close())
require.Len(t, matrix.Rows, keyVizRowBudgetCap,
"omitted/0/negative rows must apply the default cap (query=%q)", query)
}
References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.
  2. Do not silently ignore errors from Close() methods on resources like network connections. Log them to ensure resource leaks or other cleanup problems are visible.

Round-1 review fixes for PR #672 (Gemini medium):

- setKeyVizRowsParam previously rejected negative rows with a 400
  invalid_query — but the godoc and PR description state that
  omitted / 0 / negative all yield the cap. Align the code with the
  contract: any non-positive (or above-cap) value collapses to
  keyVizRowBudgetCap. Caller can never disable or invert the budget.
- Extend TestKeyVizHandlerOmittedRowsAppliesDefaultCap to include
  ?rows=-1 in the table so the negative branch is pinned alongside
  the omitted and 0 cases.
- Test now asserts on resp.Body.Close()'s error so a future
  regression that breaks response-stream cleanup surfaces.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 2m 38s —— View job


Code Review

  • Read changed files
  • Analyze correctness and API contract
  • Check test coverage
  • Check concurrency / resource safety
  • Post full review

The fix is correct and the core logic is sound. The two Gemini suggestions (negative-rows-to-cap collapse, resp.Body.Close() check, ?rows=-1 test case) have already been incorporated in commit c489eec. A few items still worth addressing:


1. Missing test for above-cap clamping — PR description references a test that doesn't exist

The PR description says:

Existing TestKeyVizHandlerClampsRowsBudgetToCap still passes

There is no TestKeyVizHandlerClampsRowsBudgetToCap anywhere in the repo. The above-cap clamping path (?rows=9999 → 1024) has no dedicated test. The only rows test pre-existing is TestKeyVizHandlerHonorsRowsBudget, which exercises ?rows=2 — a value well under the cap, so it never touches the clamping branch. Per CLAUDE.md conventions, new/changed branches need unit tests, and the n > keyVizRowBudgetCap branch in setKeyVizRowsParam is one of them.

Suggested addition to TestKeyVizHandlerOmittedRowsAppliesDefaultCap (or a separate test):

// above-cap is silently clamped down
resp := keyVizGet(t, srv.URL+"?rows=9999")
require.Equal(t, http.StatusOK, resp.StatusCode)
var m KeyVizMatrix
require.NoError(t, json.NewDecoder(resp.Body).Decode(&m))
require.NoError(t, resp.Body.Close())
require.Len(t, m.Rows, keyVizRowBudgetCap, "above-cap rows must clamp to cap")

Fix this →


2. //nolint:gosec in test code — violates CLAUDE.md conventions

internal/admin/keyviz_handler_test.go:303:

idx := uint64(i + 1) //nolint:gosec // bounded by keyVizRowBudgetCap+5

CLAUDE.md explicitly says: "Avoid //nolint — refactor instead." The lint hit here is a G115 integer overflow conversion. The conversion is safe (the values are bounded by keyVizRowBudgetCap+5 = 1029), but the suppression should be removed in favour of a refactor. The simplest fix is to use the byte values directly:

rows[i] = keyviz.MatrixRow{
    RouteID: uint64(i + 1),
    Start:   []byte{byte(i >> 8), byte(i)},
    End:     []byte{byte((i + 1) >> 8), byte(i + 1)},
    Writes:  uint64(i + 1),
}

uint64(i+1) where i is a loop index over a small slice is not a G115 target (it's not a narrowing conversion), so the conversion that gosec objects to is only byte(i / 256) / byte(i % 256) vs byte(i >> 8) / byte(i). Using the bitwise forms may resolve the lint hit without any suppression.

Fix this →


3. applyKeyVizRowBudgetbudget <= 0 path is now unreachable

keyviz_handler.go:359:

func applyKeyVizRowBudget(rows []KeyVizRow, budget int) []KeyVizRow {
    if budget <= 0 || len(rows) <= budget {

Since parseKeyVizParams now pre-seeds rows = keyVizRowBudgetCap and setKeyVizRowsParam collapses any n <= 0 to the cap, budget reaching applyKeyVizRowBudget as ≤ 0 is impossible through the normal path. The check is harmless but it's dead code. Not a blocker, but worth noting so a future reader doesn't wonder why it's there.


4. capturingKeyVizSource has no mutex — potential trap for test authors

keyviz_handler_test.go:374-388:

type capturingKeyVizSource struct {
    from time.Time
    to   time.Time
}
func (c *capturingKeyVizSource) Snapshot(from, to time.Time) []keyviz.MatrixColumn {
    c.from = from
    c.to = to
    ...
}

If a future test exercises concurrent requests (or the handler ever calls Snapshot from a goroutine pool), this will data-race. Currently safe because TestKeyVizHandlerTimeBoundsParam makes sequential requests. Adding a sync.Mutex would guard against accidental future misuse. Not a blocker.


Overall assessment

The primary fix — seeding rows: keyVizRowBudgetCap and collapsing <= 0 in setKeyVizRowsParam — is correct and closes the resource-guard bypass. Items 1 and 2 above are the ones worth addressing before merging; items 3 and 4 are minor.

Adds TestKeyVizHandlerClampsRowsBudgetToCap to lock the n>cap branch of
setKeyVizRowsParam, and extracts stagedRowsForBudgetTest so the omitted
and above-cap tests share the same fixture. The shared helper iterates
with a uint64 counter and uses byte-shift key encoding, removing the
//nolint:gosec at the previous int->uint64 conversion.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

Round-2 follow-ups (b629f2b):

  • Item 1 — Added TestKeyVizHandlerClampsRowsBudgetToCap (above-cap branch). Both budget tests now share stagedRowsForBudgetTest() so the fixture stays in one place.
  • Item 2 — Refactored the staged-rows helper to a uint64 loop counter with byte(i >> 8) / byte(i) key encoding. The //nolint:gosec at the old int->uint64 conversion is gone.
  • Items 3 & 4 left as-is per your note (budget <= 0 defensive path, capturingKeyVizSource is single-goroutine in tests).

go test -race -count=1 ./internal/admin/... and golangci-lint run ./internal/admin/... clean.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 1m 53s —— View job


Code Review (Round 2)

  • Read changed files
  • Analyze round-2 fixes (Item 1: above-cap test, Item 2: nolint removal)
  • Check test coverage completeness
  • Verify lint/correctness
  • Post full review

Both round-2 items are correctly addressed. Here's the detailed pass:


Item 1 — TestKeyVizHandlerClampsRowsBudgetToCap

The new test (keyviz_handler_test.go:321-335) correctly exercises the above-cap clamping branch. It stages keyVizRowBudgetCap+5 rows via stagedRowsForBudgetTest(), sends ?rows=9999, and asserts len(matrix.Rows) == keyVizRowBudgetCap. The assertion message is descriptive. Clean.

The shared stagedRowsForBudgetTest() helper (keyviz_handler_test.go:342-355) is the right design: pure function, no test-framework coupling, returns a fresh slice each call. Both budget tests create independent servers and share only the fixture data — no shared mutable state.


Item 2 — //nolint:gosec removed ✅

The refactor to uint64 loop counter (for i := uint64(0); i < total; i++) cleanly eliminates the int→uint64 conversion that gosec's G115 flagged. The byte encoding:

Start: []byte{byte(i >> 8), byte(i)},
End:   []byte{byte(n >> 8), byte(n)},

is semantically identical to the previous i/256 / i%256 form but avoids the sign-extension concern — uint64→byte is an intentional truncation, not a potential negative-to-unsigned wrap. No suppression needed.

A quick sanity-check on the boundary values:

  • i=0: Start={0,0}, End={0,1}
  • i=255: Start={0,255}, End={1,0}
  • i=1028 (last): Start={4,4}, End={4,5}

All 1029 rows have distinct RouteID (1..1029) and strictly ascending Writes (1..1029), which makes the budget truncation deterministic and the test assertions reliable.


Item 3 — applyKeyVizRowBudget budget <= 0 dead path ✅ (left intentionally)

Correct call. The budget <= 0 guard at keyviz_handler.go:359 is unreachable through the normal path but harmless as a defensive backstop. Leaving it is fine.


Item 4 — capturingKeyVizSource mutex ✅ (left intentionally)

Correct call. TestKeyVizHandlerTimeBoundsParam is sequential (t.Parallel() is on the parent only; the subtests are not parallelised). No data-race risk in the current usage.


One small cleanup opportunity (non-blocking)

In TestKeyVizHandlerTimeBoundsParam (pre-existing), resp.Body.Close() at lines 379 and 387 silently swallows the error. This isn't part of this PR's changes and doesn't affect correctness, but worth a follow-up ticket if you're tightening the test hygiene baseline.


Summary

The fix is correct and complete:

Path Before After
?rows= omitted rows=0 → no cap → all routes returned rows=1024 → cap applied
?rows=0 rows=0 → no cap rows=1024 → cap applied
?rows=-1 400 error rows=1024 → cap applied
?rows=9999 rows=1024 (clamped) rows=1024 (clamped)
?rows=2 rows=2 rows=2 (unchanged)

All branches are now covered by tests. Both the primary fix and the round-2 feedback have been addressed cleanly. LGTM — ready to merge.

@bootjp bootjp merged commit f3cb756 into main Apr 26, 2026
9 checks passed
@bootjp bootjp deleted the feat/keyviz-admin-rows-default branch April 26, 2026 13:41
bootjp added a commit that referenced this pull request Apr 26, 2026
## Summary

Phase 2-B of the Key Visualizer design
(`docs/admin_ui_key_visualizer_design.md`): integrate the heatmap into
the existing `web/admin/` SPA rather than building a parallel dashboard.
Doc + implementation in one PR (doc commit first, per CLAUDE.md
design-doc-first workflow).

- New page at `/keyviz` — canvas heatmap polling
`/admin/api/v1/keyviz/matrix` with series picker (writes / reads /
write_bytes / read_bytes), row-budget input (clamped at 1024), and off /
5 s / 30 s auto-refresh.
- Cold cells (value 0) render as the page background, not a faint blue —
spotting actually-cold routes stays the dominant visual signal.
- Row-detail flyout on hover: bucket_id, start, end, aggregate,
route_count, route_ids (with truncation indicator). `start` / `end`
decode through a printable-or-hex preview so binary keys do not render
as mojibake.
- Dependency-free: hand-rolled five-stop colour ramp in
`lib/colorRamp.ts`. No d3, no ECharts. Bundle grew from ~155 kB to ~208
kB (raw), 64 kB gzipped.

Backend is unchanged. The handler at `/admin/api/v1/keyviz/matrix` and
its sampler wiring already shipped under Phase 2-A (PRs #639 / #645 /
#646 / #647 / #651 / #660 / #661 / #672).

## Design

`docs/design/2026_04_27_proposed_keyviz_spa_integration.md` — proposed
status, lands with this PR. Parent design §12 phase table is split into
2-A (server, shipped) / 2-B (SPA, this PR) / 2-C (cluster fan-out,
open).

## Five-lens self-review

1. **Data loss** — n/a; SPA is read-only against an existing handler.
2. **Concurrency / distributed** — n/a; single browser tab polling a
single handler. Sampler concurrency was already covered by Phase 2-A
tests.
3. **Performance** — Canvas + per-cell `fillRect` runs under §10 budget
at 1024 × 500. Auto-refresh defaults off; 5-second cadence is the lower
bound (sampler flush is 1 s).
4. **Data consistency** — SPA renders whatever the handler returns;
consistency guarantees come from the existing leader-issued counters in
the sampler.
5. **Test coverage** — `tsc -b --noEmit` clean; `vite build` clean; `go
build ./internal/admin/...` clean (embed glob unaffected); `go test
./internal/admin/...` clean. Manual verification documented in the
design doc §5.

## Test plan

- [x] `npm run lint` (`tsc -b --noEmit`) — clean
- [x] `npm run build` (Vite) — clean, output goes to
`internal/admin/dist`
- [x] `go build ./internal/admin/...` — clean
- [x] `go test ./internal/admin/...` — clean
- [ ] Manual: `make run` + `make client`, navigate to `/keyviz`, see hot
routes light up red within ~5 s of write traffic
- [ ] Manual: series picker swaps the displayed counter; row-budget
input clamps at 1024; auto-refresh polls without flicker

## Out of scope

- **Cluster fan-out** — handler is currently node-local. Phase 2-C will
add a cross-node admin RPC; this PR will pick up the aggregate view
automatically once that ships.
- **Drill-down per-route sparkline** — Phase 3.
- **Routes / Raft Groups correlation** — Phase 1 SPA pages not yet
built; correlation lands when those pages do.
- **`localStorage` for series / rows / refresh** — punt to follow-up.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant