perf(cost): cache the quota-week projection off the main thread - #3831
giuseppebisemi wants to merge 1 commit into
Conversation
quotaWeekSummaries rebuilt its per-day projection on every menu card build. The projection walks every exact slice and, per slice, created a Calendar, read date components, formatted a day key with String(format:), and asked the calendar for the hour interval. On a 30-day Claude history (~54k slices) that is ~140 ms on the main thread each time a Codex or Claude card is built. - Reuse the day-key and hour-start memos that OpenCodexUsageAggregator already had; they move next to CostUsageLocalDay so both callers share them. - Memoize the projection inside the snapshot. It depends only on the snapshot's immutable entries and the bucket time zone, so reset observations, `now`, and window boundaries are still evaluated per call. - Warm the projection from a utility task when UsageStore publishes or installs a snapshot, so the menu finds it ready. Release build, 54,000 slices: 142 ms -> 28 ms cold, 2.5 ms warm, same output. Refs steipete#3827
|
🦞👀 Pull request received. I will update this pull request when review starts. ClawSweeper review completeClawSweeper finished reviewing this revision. The review result is being finalized. |
|
Codex review: needs maintainer review before merge. Reviewed September 21, 2026, 9:10 AM ET / 13:10 UTC (Revision 2). ClawSweeper reviewWhat this changesThe PR caches weekly quota-history projections per snapshot and time zone, reuses calendar helpers, and warms the cache in a background task to reduce menu stalls. Merge readiness✅ Ready for maintainer review Keep open as a useful fix that current main still needs. The updated runtime excerpts satisfy the previous proof request, and the reviewed patch has no blocking correctness findings. Priority: P2 Review scores
Verification
How this fits togetherCodexBar turns local usage history into weekly token and cost totals displayed in provider menu cards. This change caches the expensive history preparation while continuing to calculate live reset boundaries for each presentation. flowchart TD
A[Local usage history] --> B[Immutable usage snapshot]
B --> C[Background cache warming]
D[Selected time zone] --> C
C --> E[Cached daily projection]
B --> E
E --> F[Calculate quota weeks]
G[Current time and reset observations] --> F
F --> H[Provider menu card]
Before mergeNone. Agent review detailsSecurityNone. Review metrics
Root-cause clusterRelationship: Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Technical reviewBest possible solution: Reuse immutable history projections while keeping live quota boundaries fresh and calendar behavior unchanged. Do we have a high-confidence way to reproduce the issue? Yes, source establishes repeated synchronous projection work when quota-history cards are built, and contributor sampling demonstrates the associated stalls. This review did not execute a current-main reproduction. Is this the best way to solve the issue? Yes. Caching only immutable preparation avoids stale reset calculations, and reusing existing calendar helpers keeps the optimization narrow. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning medium; reviewed against 7bb3dfe9697b. LabelsLabel justifications:
EvidenceWhat I checked:
Likely related people:
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (1 earlier review cycle)
|
Fixes #3827.
The problem
Every Codex or Claude menu card build calls
quotaWeekSummarieson the main thread, and that call rebuilds its per-day projection from scratch. The projection visits every exact slice. For each one it created aCalendar, read the date components, formatted a day key withString(format:), and asked the calendar for the hour interval.My 30-day Claude history has about 54,000 slices. In a release build that pass takes 142 ms, and the card pays it again on every build.
What I changed
quotaProjectionDaysandquotaHourlyResidualSlicesnow use the day-key and hour-start memos thatOpenCodexUsageAggregatoralready had. I moved the two structs next toCostUsageLocalDayso both callers share them. Nothing about them changed except the names.now, and the window boundaries are still evaluated on every call, which was the concern ClawSweeper raised about a timestamp-keyed cache. The memo is excluded from==, and copies of a snapshot share it.UsageStorewarms the projection from a utility task when it publishes or installs a snapshot, so the menu finds it ready instead of computing it while opening.Numbers
Release build, 54,000 synthetic slices over 30 days plus hourly buckets, median of 10 runs, M-series Mac, macOS 26.7:
The weekly totals are identical in all four rows.
I also sampled the running app (
sample <pid> 60 1) while switching tabs. On 0.63.0 the main thread spent about 1.9 s inquotaWeekSummaries, with single calls between 80 and 195 ms. With this branch it spent 219 ms across 35 calls, about 6 ms each, and the per-slice pass no longer shows up on the main thread at all.A debug build hides most of this (216 ms to 192 ms with the memos), so measure in release.
swift test -c releasedoes not compile for me because of an unrelated key-path error inTestsLinux/PlatformGatingTests.swift; I benchmarked through a small external package that linksCodexBarCore.Tests
New:
QuotaWeekProjectionScaleTestsbuilds 54,000 slices across the 2025-10-26 fall-back day in Europe/Rome, checks each weekly total against a direct sum of the slices inside the window, and checks that a warmed snapshot and a cold one agree in a second time zone.ProviderArchitectureGatekeeperTestsonly has line numbers re-anchored forUsageStore+TokenCost.swift, because the new lines shifted them. No entries were added or removed.Commands run:
swift test --filter CostUsageQuotaswift test --filter InlineCostHistoryDashboardLabelTestsmake check(0 violations)make test(all shards pass)The full suite ran before I rebased onto current main. After the rebase I re-ran the two filters above,
ProviderArchitectureGatekeeperTests, the new test, andmake check.Sample output
Both captures are
sample <pid> 60 1on the same Mac (macOS 26.7, 25G229) while I opened the menu and switched between the Codex and Claude tabs about ten times. The lines below are everyquotaWeekSummariesframe in the main-thread call graph, grouped by call-site offset. Each number is the sample count of one stack, and at a 1 ms interval a count reads as milliseconds.I produced them with:
Before, release 0.63.0 (151):
The
+ 2488site is the call intoquotaProjectionDays. One raw line from that capture, so you can see the shape:After, this branch built with
./Scripts/compile_and_run.sh(release, 0.63.1 (152)):The projection call site no longer appears on the main thread. The frames under
+ 2980symbolicate withatos(against the unstripped build product) toprojectQuotaWindowandprojectQuotaTokens. That loop still runs per call because it depends onnowand the reset boundaries.Benchmark transcript (release, external package linking
CodexBarCore, 54,000 slices, ten consecutive calls on one snapshot):ClawSweeper is right that warming is best effort. The utility task starts after publication, so a card built in that gap either computes the projection itself or waits on the memo's lock. That costs the 28 ms cold figure once per snapshot, against 142 ms on every build today.
Not in this PR
The first menu open is still heavier than I would like. The sample points at
warmMergedSwitcherSiblingContent, which builds every sibling tab in one main-thread pass about 0.12 s after the menu opens (1.16 s in my sample, nearly all SwiftUI layout). That is a different mechanism from this issue, so I left it out. I have a small change that builds one tab per timer fire and can open it separately if you want it.