Skip to content

perf(client): reuse usage queries within the hour and ask only selected environments - #36

Merged
andrewcai8 merged 4 commits into
mainfrom
perf/usage-client-reuse
Sep 22, 2026
Merged

andrewcai8 merged 4 commits into
mainfrom
perf/usage-client-reuse

Conversation

@andrewcai8

Copy link
Copy Markdown
Owner

Opening the Usage page almost always missed every cache. The past-24h window was built from the current minute, so the query key changed every minute, missing both the client's 60s cache and the server's in-flight dedupe. The page also asked every connected environment for usage, including ones deselected in its environment filter, and then waited for the slowest. A paused or slow cloud box took 2.5–8.5s.

How it's fixed:

  • makeWindow (shared by web and mobile) requests 24 hourly buckets ending at the end of the current hour. Reopening the page within the hour reuses the query. The server rejects hourly windows longer than 24h, so the window starts between 23 and 24 hours back, and the range label shows the exact times.
  • The usage query is keyed by window plus selected environments, and only asks the selected ones. Deselected environments stay in the filter list without a false "Scanning…". Mobile mirrors the change.

Tests: window computation with literal values (UTC, reuse across a whole hour, a Los Angeles late-evening day boundary). A query test asserts only the selected environment is asked and checks the merged cost.

🤖 Generated with Claude Code

andrewcai8 and others added 2 commits September 22, 2026 19:21
The hourly window ended at the current minute, so nearly every open of
the usage page asked for a new window and missed both the client query
cache and the server's shared scan.

The window is now 24 hour buckets ending with the hour in progress, so
it stays identical for the whole hour on web and mobile. It stays at 24
hours because servers reject longer hourly windows, and older servers
must keep answering new clients. untilDay now names the day of the last
covered instant rather than the exclusive end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The usage page subscribed every connected environment's summary query,
so a deselected environment still ran its transcript scan and held the
page's attention with a "Scanning" status.

The usage query atom is now keyed by window and selection. Deselected
environments stay listed in the filter, without a summary and without a
query, and their menu rows show no status. Web and mobile share the
change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Sep 22, 2026
@andrewcai8

Copy link
Copy Markdown
Owner Author

Verdict: FAIL

Head: 96aa1b3 (independent verifier, own worktree)

Ran

  • vp test run src/usageFormat.test.ts (shared): 10 passed.
  • vp test run for web state/usage.query.test.tsx, state/usage.test.tsx, UsagePage.test.tsx, UsagePage.refresh.test.tsx, UsageProviderChart.test.ts: 28 passed.
  • tsc --noEmit for packages/shared, apps/web, apps/mobile: 0 errors.
  • A throwaway probe of makeWindow + formatRelativeHourShort across LA (normal, DST spring/fall) and Asia/Kolkata.

Blocking finding (regression)
The hourly chart labels use referenceTime={window.untilTime} (UsagePage.tsx:472). untilTime is now the exclusive end of the current hour. During the viewer's last local hour of the day, it lands on the next calendar day. Measured in LA at 11:40 PM (now=2026-08-12T06:40Z): the current bar reads "11 PM yesterday" and the first bar reads "12 AM yesterday". Both are today. Asia/Kolkata at 11:45 PM shows the same. Before this PR the reference was the current minute, so this did not happen. Fix is one line: pass the last covered instant (untilTime - 1ms) as the reference, or render relative to now. A literal test for the 11 PM hour would pin it.

Verified OK

  • Window is exactly 24h, so the server's durationMs > MAX_HOURLY_WINDOW_MS check accepts it. 24 buckets, current partial hour included. Coverage is 23-24h back.
  • DST in LA stays 24 fixed buckets (spring 5 AM to 5 AM, fall 6 AM to 4 AM).
  • untilDay changed only in the hour branch. The server ignores it for hourly aggregation beyond the sinceDay <= untilDay check, and Cursor uses untilTime in hour mode. Day windows are unchanged.
  • The query key includes sorted selected IDs. null asks every environment. Deselected envs are not queried, show no status (web and mobile), and are excluded from merged totals and pending state.

…stant

The hour-aligned window ends at the next hour, which is already tomorrow
during the last hour of the day, so every bar read as yesterday.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@andrewcai8

Copy link
Copy Markdown
Owner Author

Verdict: FAIL

Head: 2995b31

The LA case is fixed, but the fix breaks in half-hour-offset zones. makeWindow aligns to UTC hours, so in Asia/Kolkata (+5:30) the buckets run :30 to :30 local time. From 23:30 to 23:59 IST, untilTime is 00:30 the next day, and untilTime − 1ms is still the next day. At Kolkata 23:45 on Aug 12 (2026-08-12T18:15Z), the current bar reads "11 PM yesterday" and the first bar (00:30 Aug 12) reads "12 AM yesterday". Both are Aug 12, so every bar is off by one day for 30 minutes each night. The same applies to other :30 and :45 zones (Nepal, Newfoundland, parts of Australia). Before this PR the window was minute-aligned and the reference was now, so this is a regression. Using the last bucket start as the reference doesn't work either: it mislabels 00:00–00:29 IST the other way. The reliable reference is the instant the window was made (now), kept alongside the window.

Probes (TZ=… makeWindow(1, now, "hour")):

  • LA 11:40 PM: 24 bars, 24h. First "12 AM today", current "11 PM today" (old code: "11 PM yesterday"). Fixed.
  • LA 1:20 PM: "2 PM yesterday" … "1 PM today". OK.
  • NY spring-forward day 11:30 PM: "11 PM yesterday" … "11 PM today". OK. Fall-back day 11:30 PM: "1 AM EDT today" … "11 PM today". OK. 3:10 AM on the spring-forward day and 1:30 AM EST on the fall-back day are OK.
  • Kolkata 11:45 PM: FAIL, as above.

Other checks:

  • formatRelativeHourShort has no other callers besides performance.bench.ts, and mobile does not render relative hour labels.
  • Window duration is exactly 24h, which the server's MAX_HOURLY_WINDOW_MS accepts. untilDay is the day of the last covered instant.
  • Web and mobile query keys include the environment selection, and deselected environments are neither scanned nor shown with a status. Parity holds.
  • Tests: shared usageFormat.test.ts 11/11, web usage 61/61. tsc --noEmit is clean for shared, web, and mobile.

Notes: windowReferenceTime was inserted between makeWindow's JSDoc and makeWindow, so that doc comment now sits on the wrong function. The web subtitle still formats untilTime, so it shows a future end ("to Aug 12, 12 AM" at 11:40 PM).

The window's end is a UTC hour, so in half-hour zones the instant before
it can already be tomorrow. Labels read relative to the render time, as
they did before the window was hour-aligned, and the range reads 'to now'
rather than the bucket end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@andrewcai8

Copy link
Copy Markdown
Owner Author

Verdict: PASS+NOTES

Head: 1069343

Both earlier FAILs are closed. The chart labels hours relative to render-time now. window.untilTime is no longer used for labels, and the untilTime-1ms helper is gone.

  • A scripted check ran real makeWindow + enumerateHourStarts + formatRelativeHourShort under TZ= and compared each label's today/yesterday against an independent Intl calendar-day diff. Cases: LA 11:40 PM, Kolkata 11:45 PM and 12:15 AM, St. John's 11:50 PM and 12:20 AM, New York spring-forward and fall-back, LA on the fall-back day, Lord Howe (30-min DST), and UTC 11:59 PM. Result: 0 mismatches. Every window has 24 buckets spanning exactly 24h, with the current hour as the last bucket. untilDay is the day of the last covered instant.
  • Server cap: UsageService rejects hourly durations over 24h. The window is exactly 24h and hour-aligned.
  • Selection-keyed queries: web and mobile key the atom on sorted selected IDs, and deselected environments are listed without a query or status. Mobile matches web.
  • Re-render cost: referenceTime is only read in formatTooltipPeriod at render. No memo or effect depends on it, and the chart is not memo-wrapped. React Compiler output shows UsagePage is not compiled, so the value really is fresh on every render and cannot trigger a refetch or a loop.
  • Tests: shared usageFormat and web usage state/component tests pass (9 files, 71 tests; 8 files, 65 tests). tsc passes for shared, web, and mobile.

Notes (non-blocking):

  1. In half-hour zones, for the first 30 minutes after local midnight, the in-progress bucket reads "11 PM yesterday". That is truthful, since the bucket started yesterday.
  2. The label reference only refreshes when UsagePage re-renders. Hover state lives in the chart, so a page left idle across midnight shows stale labels until the next refresh. The window itself is equally stale.

@andrewcai8
andrewcai8 enabled auto-merge (squash) September 22, 2026 23:40
@andrewcai8
andrewcai8 merged commit 4897a72 into main Sep 22, 2026
17 checks passed
@github-actions

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.5 KiB 13.5 KiB −10 B (−0.1%) 15.1 KiB ✅
Codex Thread snapshot wire 7.1 KiB 7.1 KiB +2 B (+0.0%) 7.3 KiB ✅
Codex Live turn WebSocket wire 6.5 KiB 6.4 KiB −12 B (−0.2%) 7.8 KiB ✅
Codex Live turn WebSocket decoded 56.3 KiB 56.2 KiB −44 B (−0.1%) 66.4 KiB ✅
Codex Live turn messages 10 9 −1 (−10.0%) 21 ✅
Claude Total thread wire 13.5 KiB 13.5 KiB +29 B (+0.2%) 15.1 KiB ✅
Claude Thread snapshot wire 7.1 KiB 7.1 KiB +5 B (+0.1%) 7.3 KiB ✅
Claude Live turn WebSocket wire 6.4 KiB 6.5 KiB +24 B (+0.4%) 7.8 KiB ✅
Claude Live turn WebSocket decoded 57.0 KiB 57.1 KiB +44 B (+0.1%) 66.4 KiB ✅
Claude Live turn messages 9 10 +1 (+11.1%) 21 ✅

Baseline: bf8fd20 · PR result: 1069343 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.9 KiB
  • Claude decoded thread snapshot: 114.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant