Repository navigation
feat(llmusage): tenant spend reads the hourly rollup plus the edge hour (W06-4) - #74
Conversation
…ur (W06-4)
New leaf package internal/llmusage. Window(ctx, q, spec) sums one
rolling window of key-attributed context_llm_log rows from the hourly
rollup context_llm_usage_hourly (migration 159) - every hour from the
first full UTC hour after now - span on - plus the raw rows of the
partial hour before it, and reports the earliest relief (oldest counted
row + span) for calls and for cost. The sums read at most one hour of raw
rows whatever the span; each relief the edge cannot answer reads one
more hour.
QuotaAccountant.spend reads its three windows (external cost over 24 h
and 30 d, attributed calls over 24 h) through Window instead of a 30-day
raw scan per policy tenant and refresh. The figures are unchanged; the
filter stays on the tenant's knowledge keys, so rows of /v1 consumers
(consumer_id set, api_key_id NULL) never count against daily_calls or
the cost budget (K17, B-06-18). The three reliefs are kept per tenant in
the generation for the gateway's Retry-After (W06-14); nothing reads
them yet.
Deliberate deviations: the edge ends at the next full hour even when
since lies exactly on one (a row stamped exactly at since is outside
created_at > since but inside the bucket that starts there); each key is
probed on its own behind OFFSET 0 and the locality sits in the
aggregates' FILTER, because = ANY and a locality WHERE let custom and
generic plans pick the time-leading indexes and read every tenant's
rows; the windows end at the accountant's clock, taken once per refresh.
Gates, red then green:
(a) existing suites, base -> after: backends 80 -> 83 (+3 new), chat
-Quota 1 -> 1, llm -Quota 4 -> 4, handler -Status|Quota 52 -> 52
(b) parity vs the old spend SQL, 500 seeds: overlay without the edge
425/500 seeds differ, overlay with the design's ceilHour 90/500,
final 0/500
(c) EXPLAIN of every spend statement, custom and generic plan: base
touches 1441 raw rows (ceiling 6), created_at unbounded above, no
rollup; final 3-5 raw rows, idx_llm_log_apikey and idx_llm_usage_key
(d) asymmetry pin: spend-union overlay counts 5 consumer calls and Gate
refuses with daily_calls; final 0 and Gate passes, control with 5
key calls refuses
(e) relief: compile error against base; bucket_start overlay reports
11:00 instead of 11:05:00.000007; final exact to the microsecond
Docs: multi-tenancy.md (spend source, consumer usage never counts,
deleted rows keep counting), operations.md (migration 159 now has a
reader; dropping it needs the revert), architecture.md (the rollup's
reader). Lint: depguard rule llmusage-leaf.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
There was a problem hiding this comment.
Note
Copilot was unable to complete this review at the selected tier due to model policy or plan restrictions.
🟡 Changes recommended
The change still has an active moderate issue in go/internal/llmusage/window_integration_test.go where tenant key membership is checked via substring matching, risking false positives and making the edge coverage assertion unreliable until replaced with exact set membership.
1 open issue
What changed in this PR
This PR introduces a new rollup-backed LLM usage window reader (internal/llmusage) and refactors tenant quota spend accounting to use hourly rollups plus a bounded “edge hour” of raw context_llm_log rows, aiming to preserve prior window semantics while eliminating per-tenant 30‑day raw scans. It also updates related integration/unit tests, documentation, and depguard rules.
Changes:
- Add
internal/llmusage.Window(...)to compute rolling spend windows fromcontext_llm_usage_hourlyplus at most one partial-hour slice of raw logs, including relief timestamps. - Refactor
internal/backends.QuotaAccountantspend computation to usellmusage.Windowfor 24h calls, 24h external cost, and 30d external cost windows (tenant knowledge keys only). - Add/adjust plan-parity, bounds, and integration tests; update docs and
.golangci.ymldepguard configuration.
| File | Review Notes |
|---|---|
| go/internal/llmusage/window.go | Reviewed |
| go/internal/llmusage/window_test.go | Reviewed |
| go/internal/llmusage/window_integration_test.go | Reviewed — Moderate: edgeRows uses substring matching via strings.Contains(strings.Join(keys, \",\"), *r.key) which can produce false positives; replace with exact set-membership. |
| go/internal/backends/quota.go | Reviewed |
| go/internal/backends/quota_plan_integration_test.go | Reviewed — Moderate: walkPlan() undercounts touched rows because it multiplies only Actual Rows by Actual Loops but not Rows Removed by Filter / Rows Removed by Index Recheck (JSON EXPLAIN reports per-loop averages); can let plan regressions slip past the ceiling check. |
| go/internal/backends/quota_integration_test.go | Reviewed |
| go/internal/backends/export_test.go | Reviewed |
| docs/operations.md | Reviewed |
| docs/multi-tenancy.md | Reviewed |
| docs/architecture.md | Reviewed |
| .golangci.yml | Reviewed |
Review effort: Balanced
| func edgeRows(sc scenario, keys []string, span time.Duration) int { | ||
| since, edgeEnd := llmusage.Bounds(sc.now, span) | ||
| n := 0 | ||
| for _, r := range sc.rows { | ||
| if r.key == nil || !strings.Contains(strings.Join(keys, ","), *r.key) { | ||
| continue | ||
| } | ||
| if r.at.After(since) && r.at.Before(edgeEnd) { | ||
| n++ | ||
| } | ||
| } | ||
| return n | ||
| } |
There was a problem hiding this comment.
The keys are full 36-character UUIDs and the joined list is comma-separated, so a substring match across a boundary would need a comma inside a UUID — there is no false positive today. Agreed that an exact set check says what it means; the next wave (W06-5, status rollups on this package) carries the one-line change rather than a comment-only CI round here.
…-5) (#75) Wave W06-5 of the OpenAI-compatible API plan (RC-A, telemetry foundation; design/06 §4.7). No migration, no `/v1` surface. ## What this changes - `/api/status` `llm_24h` (server view, `queryLLM24h`) and the per-tenant `llm_24h` generation (`buildTenantRollupInto`) read the hourly rollup `context_llm_usage_hourly` from the first full UTC hour after `now − 24 h` plus the raw `context_llm_log` rows of that first, partial hour, through two new readers in `internal/llmusage` (`Groups`, `TenantGroups`), instead of scanning 24 h of raw rows per tick. The tenant reader joins both parts through `context_api_keys` → `context_tenant_scopes` exactly as the raw query did; a consumer's row (`api_key_id` NULL) counts for no tenant. - `llm_24h_complete` comes from the rollup's `unattributed_ok` (successful rows without a backend) plus the same count over the edge rows: a failed row without a backend keeps `complete` true, as before. - Output is byte-identical to the raw aggregate (golden below); the one deliberate difference is a deterministic tie order. - Docs: `docs/operations.md` (rollup readers now quota + status; rollback coupling), `docs/architecture.md`. ## Behaviour changes (intended) - Rows with the same `calls` are now ordered by `backend`, then `pipeline` (the raw query ordered by `calls` alone and left ties to the plan; live has 16/16 ties). - Backend and pipeline names longer than 100 characters show cut to 100 — the rollup's dimension width since migration 159 (the longest live name has 24 characters). Pinned by a test. - The window ends at the daemon's clock, not the database's `now()` (same host in production; tests pin the anchor on the boundaries). - On an error in the middle of the result the server view returns an empty list and `complete=false` instead of the rows read so far plus `false`. ## Consumers of the changed contracts | Changed symbol | Callers / readers | Covered by | |---|---|---| | `queryLLM24h`, `buildTenantRollupInto` (same signatures, new source) | status tick (`assemble`), `SnapshotForTenant`, SSE `/api/events` | `TestStatusLLM24hGolden` (byte-equal to the base), `TestStatusLLM24hParity` (300 random seeds vs the raw SQL), `TestStatusLLM24hPlan`, `TestStatusPerTenantView`, `TestStatusGoldenKeys`, `TestStatusEventCarriesEveryDualPathField`, 58 handler top-level tests | | `llmusage.Groups`, `TenantGroups` (new) | the two readers above | `internal/llmusage` suite (9/9), the handler suites | | `promptguard` call-site census | `internal/handler:g.Pipeline` passthrough (a copied string, no prompt) | `promptguard_callsite_test.go` | ## Overlap search Open PRs #55/#57/#63/#67 on 2026-11-03: no shared files. Based on #74 (`14b99639`); root unchanged since. ## Cost Per status tick: the edge is at most one hour of raw rows bounded on both sides (23 rows in the plan test vs 1 079 for the raw 24 h scan), the rest through `idx_llm_usage_bucket`; pinned under the custom and the forced-generic plan. Tenant view: the same two parts joined to the key and scope tables. ## Verification Gates, each red on `14b99639` first: (a) existing suites green before and after (handler 52 → 58, llmusage 9/9); (b) golden: a 16 027-byte JSON of both views generated on the base tree from the base SQL (only `now()` → `$1` and the tie-break added; the base functions match it up to tie order), new code byte-equal (same sha256, empty diff); red on base by compile error, red against overlays: no edge hour 253/300 seeds, truncated mean 262/300, no tie-break 298/300; (c) `complete` probe: red when the rollup read omits `unattributed_ok` and when the edge does not count it; an error row without a backend keeps `complete` true; (d) `EXPLAIN` custom + generic: 23 raw rows (exactly the edge) vs 1 079, rollup via `idx_llm_usage_bucket`; the base SQL fails the same check; (e) consumer rows absent from the tenant view — pinned, red against an overlay that credits them to a tenant. Suites on the head: `go build ./...`, `go vet -tags=integration ./...`, `golangci-lint run` with and without `--build-tags=integration` (0), `go test -short -race ./...`, `deadcode.sh` + `deadcode.sh testonly`, integration `-p 1` for `internal/llmusage` and `-run 'Status|Events|LLMLog|Quota'` for `internal/handler` + `internal/backends`. Independent review by a fresh agent during the build: 1 minor (the 100-character cut — documented and pinned), 4 nits (3 fixed, the error-path change documented above). ## Not verified - Clock skew between daemon and database shifts the window by that skew (same host in production). - The 100-character cut is only observable with names ctx does not produce today. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Updates** * Server-wide and tenant-level 24-hour LLM usage summaries now use hourly usage data alongside records from the current partial hour. * Summaries indicate when usage data is incomplete because successful calls could not be attributed. * Backend and pipeline names are limited to 100 characters in summaries; names that match after truncation are grouped together. * **Documentation** * Updated architecture and operations guidance for usage summaries, attribution, retries, and the effects of removing or disabling rollup data collection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
The status page's llm_24h rows - the server view (queryLLM24h) and the
per-tenant generation (buildTenantRollupInto) - no longer scan 24 hours of
context_llm_log on every tick. They read through two new readers of
internal/llmusage, Groups and TenantGroups: the hourly rollup
context_llm_usage_hourly from the first full UTC hour after now - 24 h,
plus the raw rows of the partial hour before it (llmusage.Bounds), each
side aggregated by backend and pipeline (the tenant view by scope as
well) and added in NUMERIC. The edge applies the rollup trigger's
normalisation, so edge and rollup groups meet in one row.
llm_24h_complete is false exactly when unattributed_ok > 0. The tenant
view keeps the join through context_api_keys and context_tenant_scopes,
so a consumer row (api_key_id NULL) counts for no tenant. The error
behaviour is the same, except that a mid-stream server error now returns
no rows instead of the rows read so far.
What changes on the wire (hence feat, docs updated):
- Rows with as many calls are now ordered by backend, then pipeline. The
raw query ordered by calls alone and left ties to the plan; the base
code returned the golden seed's ties in another order than the
tie-broken reference.
- A backend or pipeline name longer than 100 characters shows cut to
100, the rollup's dimension width. The longest live name has 24.
- The window anchor is the Go clock instead of the database's now().
Both run on the same host.
For names of up to 100 characters the figures are byte-equal.
Gates (red first, then green):
- (a) Existing suites before and after:
- -run 'Status|Events|LLMLog' ./internal/handler/: 52 -> 58 top-level
passes, 0 fail.
- ./internal/llmusage/: 9 passes, 0 fail.
- (b) Golden: the raw-row SQL of 14b9963 (now() -> $1, plus the
tie-break) over a mixed seed at 3 anchors wrote
testdata/llm24h_golden.json (16027 bytes) on the base tree. The base
functions match it apart from tie order. The rollup readers are
byte-equal to it (sha256 6ed75ed3, empty diff).
- Parity property: 300 random seeds, 0 differ.
- Red against the base: compile error (llm24hNow, llm24hSpan,
llmusage.Groups undefined).
- Red against overlays: no edge (golden + 253/300 seeds), truncating
avg_ms (262/300), no tie-break (298/300).
- (c) complete probe: red against a rollup read without unattributed_ok
(oc1, rollup-hour case) and an edge without it (oc2, edge case). A
failed row without backend_name keeps complete true.
- (d) EXPLAIN, custom and generic plan: the raw edge is bounded on both
sides and touches 23 rows (edge 23, 24 h 1079); the rollup is read via
idx_llm_usage_bucket. The base SQL fails the same check (1079 rows,
lower bound only).
- (e) Tenant view without consumer rows: pinned. Red against a
consumer-union overlay.
Suites: build, vet -tags=integration, go test -short -race ./... (all
ok), golangci-lint in both forms (0 issues), deadcode.sh and deadcode.sh
testonly (ok).
Also:
- The promptguard census lists llm24hRowOf's Pipeline: g.Pipeline as a
passthrough.
- llmusage edgeRows uses an exact set (Copilot nit on GottZ#74).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Wave W06-4 of the OpenAI-compatible API plan (RC-A, telemetry foundation; design/06 §4.7 / §4.5, masterplan K17). No migration, no
/v1surface.What this changes
internal/llmusage(imports only thepgxdbhandle atoms; depguard rulellmusage-leafpins that):Window(ctx, q, Spec{Now, Span, APIKeyIDs, Locality})reads one rolling window as hourly rollup from the first full UTC hour afternow − spanplus the rawcontext_llm_logrows of that first, partial hour — the exact semantics of the rawcreated_at > now() - intervalqueries it replaces, with at most one hour of raw rows per window. It also returns the earliest relief of each figure (oldest counted row + span), microsecond-exact.backends.QuotaAccountant.spendreads its three figures (24 h calls, 24 h external cost, 30-day rolling external cost) throughllmusage.Windowinstead of a 30-day scan over the hypertable. The filter stays the tenant's knowledge keys (api_key_id): a/v1consumer row never counts here (K17, B-06-18) — consumer traffic cannot exhaustdaily_callsor the cost budget and lock a tenant's people out of/api/queryand the web chat. Relief timestamps are stored on the tenant state for W06-14 (no reader yet).docs/multi-tenancy.md(spend source, asymmetry),docs/operations.md(Migration 159 rollback now coupled to the reader),docs/architecture.md.Behaviour changes (intended)
now()(same host, same clock in production).Deviations from design/06 §4.7, with reasons
sincelies exactly on one: the bucket starting atsinceholds a row stamped exactly atsince, which the strict lower bound excludes; the design formula counted such a row twice (90 parity deviations with it, 0 with the fix).LATERAL … OFFSET 0) instead ofapi_key_id = ANY($1): with= ANYthe planner preferred the time-leading indexes (uq_llm_usage_hourly, the log'screated_atindex) and read every tenant's rows in the range — under the generic plan the whole instance's edge hour. The locality condition sits in the aggregates'FILTERso both renderings share one access path. Pinned byTestQuotaSpendPlanunder the custom and the forced-generic plan.bucket_start: the bucket variant reports relief too early (11:00 for a row at 11:05:00.000007); a safe conservative variant would have beenbucket_start + 1 h.Consumers of the changed contracts
QuotaAccountant.spend(signature, now fillstenantStateincl. relief)refresh(quota.go)TestQuotaAccountant_*(backends 80 → 83),TestChatQuota,TestSynthesizeQuota, handler status/quota suitesNewQuotaAccountant(q quotaDB, …)(needsQuerier+Rower)cmd/ctxd, tests*pgxpool.Poolcontext_llm_usage_hourly(first reader)llmusage.WindowTestWindowParity(500 seeds vs the old raw SQL),TestWindowBounds(unit, hour-exact edges),TestQuotaSpendPlan.golangci.ymldepguardgolangci-lint run0Overlap search
Open PRs #55/#57/#63/#67 on 2026-11-03: no shared files. Based on #73 (
43041709); root unchanged since.Cost
Per policy tenant every 30 s: three statements, each ≤ 1 h of raw rows through
idx_llm_log_apikeyplus the rollup throughidx_llm_usage_key; measured inTestQuotaSpendPlan: raw rows touched 3–5 (base: 1 441, with no upper bound oncreated_atand no rollup). Planner edge: on a rollup of a few dozen tenants the custom plan may still pick the time-leadinguq_llm_usage_hourlyfor the 30-day window (≈ 1.5× the probe's cost); from ~100 tenants on it moves toidx_llm_usage_key. Candidate for later:INCLUDEcolumns onidx_llm_usage_key, one batched statement over all tenants.Verification
Gates, each red on
43041709first: (a) existing quota suites green before and after (backends 80/83, chat 1, llm 4, handler 52); (b) parity propertyllmusage.Windowvs the oldspendSQL over 500 random seeds with rows around both window edges — 425 deviations without the edge rows, 90 with the design'sceilHour, 0 final; (c)EXPLAINunder custom and generic plan: raw rows 1 441 → 3–5, rollup viaidx_llm_usage_key; (d) asymmetry pin: a spend-union variant (OR consumer_id = ANY) counts 5 consumer calls and the gate refuses ondaily_calls, the shipped code counts 0 and lets the tenant through, the control with 5 knowledge-key calls refuses; (e) relief with a fixed clock: compile error on base,bucket_startvariant 11:00 vs exact 11:05:00.000007.Suites on the head:
go build ./...,go vet -tags=integration ./...,golangci-lint runwith and without--build-tags=integration(0),go test -short -race ./...(65 ok),deadcode.sh+deadcode.sh testonly(ok; the new package is reached fromspend), integration-p 1 -raceforinternal/llmusage+internal/backends, and-run 'Quota|Status|LLMLog'forinternal/chat,internal/llm,internal/handler. Independent review by a fresh agent during the build: 4 minor + 5 nit, all fixed or documented in the wave report.Not verified
🤖 Generated with Claude Code
Summary by CodeRabbit
Quota Improvements
Documentation