Skip to content

Move credit expiration to scheduled cron - #6192

Closed
chrarnoldus wants to merge 1 commit into
mainfrom
kilo/spectral-beacon-gin
Closed

chrarnoldus wants to merge 1 commit into
mainfrom
kilo/spectral-beacon-gin

Conversation

@chrarnoldus

Copy link
Copy Markdown
Contributor

Summary

  • move user and organization credit expiration out of balance reads and the AI gateway request path
  • process due expirations in bounded batches from a five-minute Vercel cron
  • add partial concurrent indexes for efficient due-credit scans

Verification

  • formatted changed files
  • git diff --check
  • tests, lint, and typecheck intentionally left to CI per request

@chrarnoldus chrarnoldus self-assigned this Sep 16, 2026

const userLimit = pLimit(USER_CONCURRENCY);
const userResults = await Promise.allSettled(
dueUsers.map(user => userLimit(() => processLocalExpirations(user, now)))

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.

CRITICAL: processLocalExpirations requires updated_at, but the query above does not select it

UserForLocalExpiration (apps/web/src/lib/creditExpiration.ts:29-36) is Pick<User, 'id' | 'microdollars_used' | 'next_credit_expiration_at' | 'updated_at' | 'total_microdollars_acquired'>, and kilocode_users.updated_at is notNull() (packages/db/src/schema.ts:415-418). The dueUsers select only projects id, microdollars_used, next_credit_expiration_at, and total_microdollars_acquired, so this argument is missing the required updated_at property and will fail TypeScript assignability, breaking pnpm typecheck in CI. Add updated_at: kilocode_users.updated_at to the select, or drop updated_at from UserForLocalExpiration (it is not read inside the function body).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

state.next_credit_expiration_at !== organization.next_credit_expiration_at
);
}).length;
} catch {

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.

WARNING: Organization failures are swallowed without logging, and every due org is reported as failed

The bare catch discards the error and marks all due organizations as failed. processOrganizationExpirationsBatch commits its set-based updates in a transaction and retries on conflicts, so a throw after a partial commit (or a transient DB error) can mean most orgs were actually processed, yet the summary reports all of them failed and the route returns 500. Conversely, orgs that lost the optimistic race return normally and are counted as neither processed nor failed. Capture the error and log it (e.g. sentryLogger) and record only the ids that genuinely failed. The user path has the same observability gap: Promise.allSettled above keeps only ids and drops every rejection reason, so a systematic per-user failure is invisible apart from a count.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

})
.from(kilocode_users)
.where(lte(kilocode_users.next_credit_expiration_at, expirationBoundary))
.orderBy(asc(kilocode_users.next_credit_expiration_at), asc(kilocode_users.id))

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.

SUGGESTION: No dead-lettering for persistently failing rows

The batch always selects the batchSize oldest due rows ordered by next_credit_expiration_at, and any row that failed processing is re-selected on the next run. A user or organization whose processing throws deterministically stays at the head of the queue and consumes a batch slot every tick; once batchSize such rows accumulate for a table, that table stops making progress entirely. Consider tracking attempt counts or skipping recently-failed ids so one poison row cannot starve the rest of the queue.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return NextResponse.json({ error: 'User not found: ' + userId }, { status: 404 });
}
await getBalanceForUser(user, { forceRefresh: true });
await getBalanceForUser(user);

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.

WARNING: Admin "Force expiration check" silently becomes a no-op

{ forceRefresh: true } was what previously made this endpoint run expiration for the user. getBalanceForUser(user) now only computes (total_microdollars_acquired - microdollars_used) / 1_000_000 in memory and schedules after(() => maybePerformAutoTopUp(user)); it writes nothing and recomputes no expirations. The route still returns { success: true }, so the admin UI button "Force expiration check" (apps/web/src/app/admin/components/UserAdmin/UserAdminUsageBilling.tsx:233-243) now reports success while doing nothing. Either remove/rename the endpoint and button, or have it invoke expiration for that user directly.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

throw new Error('CRON_SECRET is not configured in environment variables');
}

export const maxDuration = 300;

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.

SUGGESTION: maxDuration = 300 equals the 5-minute cron interval

The route allows a 5-minute execution while apps/web/vercel.json schedules it every 5 minutes, so a slow run can still be in flight when the next invocation starts, re-selecting the same due rows and doubling database load. The sibling 5-minute cron api/cron/sync-providers deliberately caps at maxDuration = 240 with a comment tying the value to the schedule. Consider a timeout below the interval or an explicit overlap guard.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 2
SUGGESTION 2

The highest-risk issue is in apps/web/src/lib/credit-expiration-cron.ts: the new due-user query omits updated_at, which processLocalExpirations requires, so the PR will not pass typecheck.

Issue Details (click to expand)

CRITICAL

File Line Issue
apps/web/src/lib/credit-expiration-cron.ts 58 dueUsers select omits updated_at, which UserForLocalExpiration requires — TypeScript assignability error / typecheck failure

WARNING

File Line Issue
apps/web/src/lib/credit-expiration-cron.ts 80 Bare catch swallows the org batch error, marks every due org failed, and user rejection reasons are dropped
apps/web/src/app/admin/api/users/[id]/kill-balance-cache/route.ts 24 Admin "Force expiration check" endpoint is now a silent no-op that still reports success

SUGGESTION

File Line Issue
apps/web/src/lib/credit-expiration-cron.ts 41 No dead-lettering; persistently failing rows occupy batch slots and can starve a table
apps/web/src/app/api/cron/process-credit-expirations/route.ts 12 maxDuration = 300 equals the 5-minute schedule, so runs can overlap
Files Reviewed (10 files)
  • apps/web/src/app/admin/api/users/[id]/kill-balance-cache/route.ts - 1 issue
  • apps/web/src/app/api/cron/process-credit-expirations/route.ts - 1 issue
  • apps/web/src/lib/credit-expiration-cron.ts - 3 issues
  • apps/web/src/lib/organizations/organization-usage.ts - no issues
  • apps/web/src/lib/user/balance.ts - no issues
  • apps/web/vercel.json - no issues
  • packages/db/src/schema.ts - no issues (partial CONCURRENTLY indexes follow repo convention)
  • packages/db/src/migrations/0243_flawless_colleen_wing.sql - no issues (COMMIT/BEGIN wrapper is the documented pattern)
  • packages/db/src/migrations/meta/0243_snapshot.json - generated, skipped
  • packages/db/src/migrations/meta/_journal.json - generated, skipped

Notes

  • Intended behavior change: balances can be stale for up to one cron interval now that expiration is no longer computed on balance reads. This is the stated goal of the PR; org-side lazy expiration still exists in several routers and races the cron, but no org permanently fails to expire.
  • Typecheck was not executed in this environment (read-only, dependencies not installed); the CRITICAL finding is based on static type analysis.
  • The packages/db snapshot/journal and the concurrent-index migration were verified against packages/db/AGENTS.md and prior migrations.

Fix these issues in Kilo Cloud


Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

@chrarnoldus
chrarnoldus deleted the kilo/spectral-beacon-gin branch September 17, 2026 04:38
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