Skip to content

Commit d47f4f6

Browse files
authored
fix(code-reviews): fix billing query timeout preventing usage footer on v2 reviews (#979)
## Summary Follow-up to [PR #978](#978). The billing fallback query that fetches token/model data for v2 reviews was timing out in production, so the usage footer ("Reviewed by model · X tokens") was never shown. **Root cause:** the query filters `microdollar_usage_metadata` by `session_id`, but that column has no index. The table has ~469M rows, so every query did a full table scan and timed out. The `catch` block silently returned `null`, and the footer was skipped. **Fix:** - Add a `created_at >= reviewCreatedAt` lower bound to the billing query. This lets Postgres use the existing `created_at` index (query cost drops from full-scan to ~288). Billing rows can't exist before the review was created, so the bound is exact. - Skip the v1 poll loop for v2 reviews (saves ~1.4s of wasted retries). - Remove the `session_id` index migration — with the time bound, it's not needed. - Clean up the admin dashboard: remove agent version filter and performance chart that are no longer useful now that all reviews are v2. ## Verification - [x] `pnpm typecheck` — no new errors (only pre-existing kiloclaw errors) - [x] `pnpm test usage-footer` — 10/10 pass - [x] `pnpm test schema` — 15/15 pass (no unmigrated schema changes) - [x] Checked `EXPLAIN` plan on prod DB — query uses `idx_microdollar_usage_metadata_created_at` with cost ~288 - [x] Confirmed billing data exists for test session `ses_3282e02f5ffe2vPRBSqdpc0e40` (PR #981 review) — 8 rows returned in <1s with time-bounded query ## Visual Changes N/A ## Reviewer Notes - Every completed v2 review in prod has `model = NULL` — the billing fallback has never worked. This fix unblocks all future v2 reviews. - The back-fill write (fire-and-forget) still runs after fetching billing data, so repeat reads skip the aggregation.
2 parents d8ab39a + 07fe64c commit d47f4f6

2 files changed

Lines changed: 29 additions & 24 deletions

File tree

src/app/api/internal/code-review-status/[reviewId]/route.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,26 +116,29 @@ function normalizePayload(raw: StatusUpdatePayload): {
116116
}
117117

118118
/**
119-
* Read a review's usage data, polling with exponential backoff if not yet available.
119+
* Read a review's usage data.
120120
*
121-
* For v1 (SSE) reviews the orchestrator reports usage before the completion
122-
* callback fires, so a short poll handles the race. For v2 (cloud-agent-next)
123-
* reviews the orchestrator never reports usage — we fall back to aggregating
124-
* from the billing tables (microdollar_usage) keyed by cli_session_id.
121+
* For v1 (SSE) reviews the orchestrator writes usage to the record just
122+
* before the completion callback, so a short poll handles the race.
123+
* For v2 (cloud-agent-next) the orchestrator never writes usage — we
124+
* skip the poll and go straight to the billing tables.
125125
*
126-
* When the billing fallback is used we also back-fill the code_reviews record
127-
* so subsequent reads (e.g. the admin panel) don't need the aggregation again.
126+
* When the billing fallback is used we also back-fill the code_reviews
127+
* record so later reads (e.g. admin panel) don't repeat the aggregation.
128128
*/
129129
async function getReviewUsageData(reviewId: string) {
130-
const MAX_RETRIES = 3;
131-
const BASE_DELAY_MS = 200;
132-
133130
let review = await getCodeReviewById(reviewId);
134131

135-
// Short poll: usage may arrive from the orchestrator just before the callback
136-
for (let attempt = 0; attempt < MAX_RETRIES && review && !review.model; attempt++) {
137-
await new Promise(resolve => setTimeout(resolve, BASE_DELAY_MS * 2 ** attempt));
138-
review = await getCodeReviewById(reviewId);
132+
// v1 only: poll briefly — usage may arrive from the orchestrator
133+
// right before the callback. v2 never writes usage to the record,
134+
// so polling would just waste ~1.4s for nothing.
135+
if (review && !review.model && review.agent_version !== 'v2') {
136+
const MAX_RETRIES = 3;
137+
const BASE_DELAY_MS = 200;
138+
for (let attempt = 0; attempt < MAX_RETRIES && review && !review.model; attempt++) {
139+
await new Promise(resolve => setTimeout(resolve, BASE_DELAY_MS * 2 ** attempt));
140+
review = await getCodeReviewById(reviewId);
141+
}
139142
}
140143

141144
if (review?.model) {
@@ -147,8 +150,8 @@ async function getReviewUsageData(reviewId: string) {
147150
}
148151

149152
// Fallback: aggregate from billing tables (covers v2 / cloud-agent-next reviews)
150-
if (review?.cli_session_id) {
151-
const billing = await getSessionUsageFromBilling(review.cli_session_id);
153+
if (review?.cli_session_id && review.created_at) {
154+
const billing = await getSessionUsageFromBilling(review.cli_session_id, review.created_at);
152155
if (billing) {
153156
// Back-fill the code_reviews record so we don't repeat this aggregation
154157
updateCodeReviewUsage(reviewId, {

src/lib/code-reviews/db/code-reviews.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
microdollar_usage,
1212
microdollar_usage_metadata,
1313
} from '@kilocode/db/schema';
14-
import { eq, and, desc, count, ne, inArray, sql, sum } from 'drizzle-orm';
14+
import { eq, and, desc, count, ne, inArray, sql, sum, gte } from 'drizzle-orm';
1515
import { captureException } from '@sentry/nextjs';
1616
import type { CreateReviewParams, CodeReviewStatus, ListReviewsParams, Owner } from '../core';
1717
import type { CloudAgentCodeReview } from '@kilocode/db/schema';
@@ -502,18 +502,20 @@ export type SessionUsageSummary = {
502502
* system (processUsage → microdollar_usage) already records per-request
503503
* usage keyed by session_id, so we aggregate here.
504504
*
505-
* Uses two queries:
506-
* 1. Session-wide totals (tokens + cost across all models)
507-
* 2. The model with the most tokens (the primary review model name)
508-
*
509-
* This avoids undercounting when a session uses more than one model.
505+
* The `reviewCreatedAt` lower bound lets Postgres use the existing
506+
* `idx_microdollar_usage_metadata_created_at` index instead of seq-scanning
507+
* the full table (~469 M rows). Billing rows cannot exist before the review.
510508
*/
511509
export async function getSessionUsageFromBilling(
512-
cliSessionId: string
510+
cliSessionId: string,
511+
reviewCreatedAt: string
513512
): Promise<SessionUsageSummary | null> {
514513
try {
515-
const sessionFilter = eq(microdollar_usage_metadata.session_id, cliSessionId);
516514
const joinCondition = eq(microdollar_usage.id, microdollar_usage_metadata.id);
515+
const sessionFilter = and(
516+
eq(microdollar_usage_metadata.session_id, cliSessionId),
517+
gte(microdollar_usage_metadata.created_at, reviewCreatedAt)
518+
);
517519

518520
// 1. Session-wide totals (all models combined)
519521
const [totals] = await db

0 commit comments

Comments
 (0)