@convex-dev/ai-budget 0.0.2-alpha.17 → 0.0.2-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -584,19 +584,29 @@ import { AIBudget } from "@convex-dev/ai-budget";
584
584
  const ai = new AIBudget(components.aiBudget);
585
585
  const http = httpRouter();
586
586
 
587
- ai.registerRoutes(http, {
588
- // Gate it — the endpoint is public. Recommended: check the caller is an admin.
589
- authorize: async (ctx) => (await ctx.auth.getUserIdentity())?.role === "admin",
590
- });
587
+ // Recommended: token mode. Set AI_BUDGET_DASHBOARD_TOKEN in the deployment env;
588
+ // open the dashboard with ?token=<it> once and the page keeps using it.
589
+ ai.registerRoutes(http);
591
590
 
592
591
  export default http;
593
592
  ```
594
593
 
595
594
  It lives at `https://<deployment>.convex.site/aibudget` (override with `path`).
596
- **It is a public internet endpoint, so you must gate it**: pass `authorize`
597
- (return `true` to allow) or set `AI_BUDGET_DASHBOARD_TOKEN` (sent as
598
- `Authorization: Bearer …`). With neither, every route returns 401. Everything the
599
- page shows is backed by the component's own functions nothing else to wire up.
595
+ **It is a public internet endpoint, so you must gate it**, one of two ways:
596
+
597
+ - **Token (recommended, works end-to-end):** set the `AI_BUDGET_DASHBOARD_TOKEN`
598
+ env var. Open `…/aibudget?token=<token>` once; the page strips it from the URL
599
+ and sends it as a bearer on every API call. `?token=` is accepted only on the
600
+ page navigation, and the compare is constant-time.
601
+ - **`authorize(ctx, request)` callback:** must authenticate from something the
602
+ **browser sends on a top-level navigation** — a cookie or a header *you*
603
+ control — because a page load carries no `Authorization` bearer. `ctx.auth`
604
+ (deployment JWT) is `null` for the HTML page, so `authorize: (ctx) => (await
605
+ ctx.auth.getUserIdentity())?.role === "admin"` will 401 the page. Use it only
606
+ when you have your own session cookie to check.
607
+
608
+ With neither configured, every route returns 401. Everything the page shows is
609
+ backed by the component's own functions — nothing else to wire up.
600
610
 
601
611
  ---
602
612
 
@@ -641,6 +651,19 @@ more tokens or costs more than estimated, settlement records the real amount and
641
651
  the final total can exceed a hard cap by that request's estimation delta. The next
642
652
  admission sees the settled total and blocks until there is headroom again.
643
653
 
654
+ **Throughput characteristics (know these before you turn on a global cap).**
655
+ Reconciliation runs as small, independently-rescheduling phases, so folding,
656
+ expiry, and retention can't stall each other and each drains its own backlog.
657
+ Two shared-write hot spots remain, by design:
658
+ - The **global cap** reads a sharded counter *inside* admission, which contends
659
+ with every fold that writes it — so a configured global cap adds contention on
660
+ the hot path. It's a deployment-wide killswitch, not a high-throughput per-call
661
+ limit; prefer per-bucket caps for the common case.
662
+ - **Settlement writes every attributed bucket**, including the per-request
663
+ `action` bucket that every call shares. That single row is written by every
664
+ settle, so an extremely high single-action settle rate can lag totals. Spread
665
+ load across naturally-sharded dimensions (per user/customer) where you can.
666
+
644
667
  The `error.md` file documents the adversarial audits this design survived, with
645
668
  live repros.
646
669
 
@@ -39,10 +39,17 @@ function toTokenCount(x) {
39
39
  }
40
40
  function extractUsage(usage) {
41
41
  return {
42
- // Cover AI SDK camelCase (v5/v7) AND raw OpenAI-compatible snake_case, so a
43
- // `meter` caller passing a raw provider `usage` object still gets counts.
44
- promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens ?? usage?.prompt_tokens),
45
- completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens ?? usage?.completion_tokens),
42
+ // Cover AI SDK camelCase (v5/v7), raw OpenAI-compatible snake_case, AND raw
43
+ // Anthropic (`input_tokens`/`output_tokens`), so a `meter` caller passing any
44
+ // provider's raw `usage` object still gets counts.
45
+ promptTokens: toTokenCount(usage?.inputTokens ??
46
+ usage?.promptTokens ??
47
+ usage?.prompt_tokens ??
48
+ usage?.input_tokens),
49
+ completionTokens: toTokenCount(usage?.outputTokens ??
50
+ usage?.completionTokens ??
51
+ usage?.completion_tokens ??
52
+ usage?.output_tokens),
46
53
  // cached prompt tokens. The Convex gateway reports these at
47
54
  // `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
48
55
  // (`cachedInputTokens`) and raw OpenAI-compatible shapes.
@@ -50,7 +57,8 @@ function extractUsage(usage) {
50
57
  usage?.cachedInputTokens ??
51
58
  usage?.promptTokensDetails?.cachedTokens ??
52
59
  usage?.prompt_tokens_details?.cached_tokens ??
53
- usage?.cached_tokens),
60
+ usage?.cached_tokens ??
61
+ usage?.cache_read_input_tokens),
54
62
  };
55
63
  }
56
64
  // The AI Gateway reports the authoritative dollar cost of each request.
@@ -72,6 +80,10 @@ function extractGatewayCostNanos(result) {
72
80
  return undefined;
73
81
  }
74
82
  const NANOS_PER_DOLLAR = 1e9;
83
+ // Cap stored prompt/response content so one row can't approach Convex's 1 MiB
84
+ // document limit (which would fail the call) or bloat the reconciler's scans.
85
+ const MAX_STORED_CONTENT = 32 * 1024;
86
+ const capContent = (s) => s.length > MAX_STORED_CONTENT ? s.slice(0, MAX_STORED_CONTENT) + "…[truncated]" : s;
75
87
  // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
76
88
  function simplifyPrompt(prompt) {
77
89
  if (!Array.isArray(prompt))
@@ -83,13 +95,22 @@ function simplifyPrompt(prompt) {
83
95
  }
84
96
  else if (Array.isArray(m.content)) {
85
97
  content = m.content
86
- .map((part) => part?.type === "text" ? part.text : JSON.stringify(part))
98
+ .map((part) => {
99
+ if (part?.type === "text")
100
+ return part.text ?? "";
101
+ // NEVER inline a base64 image/file part — a data URL or Uint8Array here
102
+ // becomes megabytes, pushing the stored row past the 1 MiB doc limit
103
+ // (failing the call) and turning a 200 KB image into a ~50k-token
104
+ // estimate. Store a compact placeholder.
105
+ const bytes = part?.data?.length ?? part?.image?.length ?? part?.data?.byteLength;
106
+ return `[${part?.type ?? "part"}${typeof bytes === "number" ? ` ${bytes}b` : ""}]`;
107
+ })
87
108
  .join("");
88
109
  }
89
110
  else {
90
111
  content = JSON.stringify(m.content);
91
112
  }
92
- return { role: String(m.role), content };
113
+ return { role: String(m.role), content: capContent(content) };
93
114
  });
94
115
  }
95
116
  function extractText(result) {
@@ -491,6 +512,21 @@ export class AIBudget {
491
512
  async flush() {
492
513
  await settle();
493
514
  },
515
+ // A cancelled/aborted stream (client disconnect, AbortSignal, or
516
+ // breaking out of `for await`) does NOT run `flush`. Without this
517
+ // the request would sit pending until the 30-min reservation
518
+ // expiry and be recorded as $0 though the provider charged for
519
+ // what streamed. Settle here with the text so far, estimating the
520
+ // completion tokens when the provider gave no usage.
521
+ // `cancel` is a newer Streams-spec transformer hook not yet in
522
+ // the TS DOM lib types; cast the literal so it compiles (runtimes
523
+ // without it simply won't call it, and the reconciler backstops).
524
+ async cancel(reason) {
525
+ if (usage === undefined && text) {
526
+ usage = { outputTokens: Math.ceil(text.length / 4) };
527
+ }
528
+ await settle(`cancelled: ${String(reason)}`);
529
+ },
494
530
  }));
495
531
  return { ...result, stream: tapped };
496
532
  }
@@ -117,9 +117,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
117
117
  warnAtPct?: number;
118
118
  }, null, Name>;
119
119
  setGlobalLimits: FunctionReference<"mutation", "internal", {
120
- dailySpendLimitNanos?: number;
121
- enforcement?: "hard" | "soft";
122
- lifetimeSpendLimitNanos?: number;
120
+ dailySpendLimitNanos?: number | null;
121
+ enforcement?: "hard" | "soft" | null;
122
+ lifetimeSpendLimitNanos?: number | null;
123
123
  }, null, Name>;
124
124
  setModelPolicy: FunctionReference<"mutation", "internal", {
125
125
  mode: "open" | "allowlist" | "denylist";
@@ -39,9 +39,14 @@ export declare const finishRequest: import("convex/server").RegisteredMutation<"
39
39
  export declare const foldTotals: import("convex/server").RegisteredMutation<"internal", {
40
40
  requestId: import("convex/values").GenericId<"requests">;
41
41
  }, Promise<null>>;
42
- export declare const reconcile: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
42
+ export declare const reconcile: import("convex/server").RegisteredMutation<"internal", {}, Promise<null>>;
43
+ export declare const foldPhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
43
44
  folded: number;
45
+ }>>;
46
+ export declare const expirePhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
44
47
  expired: number;
48
+ }>>;
49
+ export declare const retentionPhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
45
50
  purged: number;
46
51
  }>>;
47
52
  export declare const setRetention: import("convex/server").RegisteredMutation<"public", {
@@ -36,6 +36,10 @@ function assertFraction(n, name) {
36
36
  // Coerce a caller/provider-supplied count to a finite nonnegative integer,
37
37
  // mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
38
38
  const safeCount = (n) => Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
39
+ // Treat a non-finite stored accounting field as 0, so a bucket that was poisoned
40
+ // before validation existed self-heals on its next reserve/release instead of
41
+ // staying NaN forever.
42
+ const fin = (n) => (Number.isFinite(n) ? n : 0);
39
43
  // Built-in attribution dimensions. `user` and `action` are always populated
40
44
  // from a request's userId/actionName; apps can add any other dimensions
41
45
  // (team, project, customer, env, …) as tags. These two names are reserved —
@@ -87,6 +91,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
87
91
  // audit table — and the sensitive content in it — from growing without bound.
88
92
  // Override per-deployment via setRetention.
89
93
  const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
94
+ // Reconciliation runs as small, independently-rescheduling phases. Keeping the
95
+ // batch small bounds the bytes read per transaction (each request row can carry
96
+ // prompts/responses) so a burst can't push one phase past Convex's 8 MiB / 16k-
97
+ // doc read limit and stall the whole reconciler. A phase that fills its batch
98
+ // reschedules itself immediately, so throughput still scales with backlog.
99
+ const RECONCILE_BATCH = 50;
100
+ // Keep an expired billing tombstone (content already purged) this long so a very
101
+ // late provider charge can still land against it, then delete it. A finish after
102
+ // deletion is a graceful no-op.
103
+ const LATE_SETTLE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
90
104
  const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
91
105
  const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
92
106
  // Cached (prompt-cache-read) input tokens are billed far below the normal input
@@ -99,10 +113,15 @@ const CACHE_DISCOUNT = 0.1;
99
113
  // bills real money. Charging the conservative max instead keeps the caps honest
100
114
  // (over-counting is the safe direction); admins can pin an exact price via
101
115
  // setPrice, which also clears the `unpricedModel` flag on future requests.
116
+ // Seed with a true frontier ceiling ($20/$100 per Mtok), not just the max of the
117
+ // small built-in table — otherwise premium models (Opus-class $15/$75, etc.) not
118
+ // in the table would be under-counted several-fold whenever the gateway's
119
+ // authoritative cost isn't available. Over-counting an unpriced model is the safe
120
+ // direction; admins pin the exact rate with setPrice.
102
121
  const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce((m, p) => ({
103
122
  input: Math.max(m.input, p.input),
104
123
  output: Math.max(m.output, p.output),
105
- }), { input: 0, output: 0 });
124
+ }), { input: 20_000_000_000, output: 100_000_000_000 });
106
125
  async function getPrice(ctx, model) {
107
126
  const override = await ctx.db
108
127
  .query("prices")
@@ -379,6 +398,11 @@ export const startRequest = mutation({
379
398
  if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
380
399
  throw new Error("reserveTtlMs must be finite and nonnegative");
381
400
  }
401
+ // Infinity/NaN here is catastrophic: it's reserved onto the bucket, and a
402
+ // later release computes `Infinity - Infinity = NaN`, leaving the reserved
403
+ // fields NaN forever — after which every `used > cap` check is `NaN > cap`
404
+ // (false) and the bucket admits unlimited spend. Reject it up front.
405
+ assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
382
406
  const extraTags = sanitizeExtraTags(args.tags);
383
407
  // Record the blocked attempt and return a rejection (throwing would roll
384
408
  // back the record). `persist` is false for the high-frequency-by-design
@@ -668,8 +692,15 @@ export const finishRequest = mutation({
668
692
  }
669
693
  else {
670
694
  const settings = await getSettings(ctx);
671
- costNanos =
672
- settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
695
+ const priced = settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
696
+ // Fail closed: if the settle carried NO usable cost signal — no tokens and
697
+ // no priced server-tool fee (an unpriced tool like `video_seconds`, or a
698
+ // bare settle()) — fall back to the reserved estimate rather than recording
699
+ // $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
700
+ // reserve time precisely so this floor is their real cost. A caller that
701
+ // truly wants $0 passes an explicit authoritative costNanos: 0 above.
702
+ const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
703
+ costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
673
704
  }
674
705
  // Durable write to the request's OWN row only — uncontended, so it always
675
706
  // lands. `settled: false` hands it to the fold step; the row is never left
@@ -711,13 +742,13 @@ async function releaseReservation(ctx, req) {
711
742
  if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b)))
712
743
  continue;
713
744
  await ctx.db.patch(b._id, {
714
- reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
715
- reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
716
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
717
- reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
718
- reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
719
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
720
- pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
745
+ reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
746
+ reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
747
+ reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
748
+ reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
749
+ reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
750
+ reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
751
+ pendingCount: Math.max(0, fin(b.pendingCount) - 1),
721
752
  });
722
753
  }
723
754
  await ctx.db.patch(req._id, { reservationReleased: true });
@@ -773,27 +804,49 @@ export const foldTotals = internalMutation({
773
804
  // Backstop for both failure modes: folds finished requests whose scheduled fold
774
805
  // lost the retry race, and releases reservations for requests that never
775
806
  // settled (their action crashed). Runs on a cron.
807
+ // Cron entry: kick off each reconciliation phase as its OWN transaction so a
808
+ // failure in one (e.g. an oversized retention scan) can't stall the others, and
809
+ // so the hot fold path doesn't share a transaction with retention. Each phase
810
+ // self-reschedules while it has a full batch of backlog.
776
811
  export const reconcile = internalMutation({
777
812
  args: {},
778
- returns: v.object({
779
- folded: v.number(),
780
- expired: v.number(),
781
- purged: v.number(),
782
- }),
813
+ returns: v.null(),
814
+ handler: async (ctx) => {
815
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
816
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
817
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
818
+ return null;
819
+ },
820
+ });
821
+ // Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
822
+ export const foldPhase = internalMutation({
823
+ args: {},
824
+ returns: v.object({ folded: v.number() }),
783
825
  handler: async (ctx) => {
784
826
  const toFold = await ctx.db
785
827
  .query("requests")
786
828
  .withIndex("settled", (q) => q.eq("settled", false))
787
- .take(200);
829
+ .take(RECONCILE_BATCH);
788
830
  for (const req of toFold)
789
831
  await foldOne(ctx, req);
832
+ if (toFold.length === RECONCILE_BATCH)
833
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
834
+ return { folded: toFold.length };
835
+ },
836
+ });
837
+ // Release reservations for requests that never settled (their action crashed),
838
+ // and lazily backfill deadlines on legacy pending rows.
839
+ export const expirePhase = internalMutation({
840
+ args: {},
841
+ returns: v.object({ expired: v.number() }),
842
+ handler: async (ctx) => {
790
843
  // Lazily migrate old pending rows in bounded batches. Once indexed, long
791
844
  // TTL jobs cannot hide expired jobs behind them in creation-time order.
792
- const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
845
+ const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
793
846
  for (const req of legacy) {
794
847
  await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
795
848
  }
796
- const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
849
+ const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(RECONCILE_BATCH);
797
850
  for (const req of candidates) {
798
851
  await releaseReservation(ctx, req);
799
852
  await ctx.db.patch(req._id, {
@@ -801,33 +854,57 @@ export const reconcile = internalMutation({
801
854
  reservationExpired: true, expiresAt: undefined, settled: true,
802
855
  });
803
856
  }
804
- const expired = candidates.length;
805
- // Retention: delete terminal, fully-accounted request rows past the window.
857
+ if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
858
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
859
+ return { expired: candidates.length };
860
+ },
861
+ });
862
+ // Delete terminal, fully-accounted request rows past the retention window; purge
863
+ // content from expired tombstones; and finally delete tombstones past the
864
+ // late-settle horizon so they can't accumulate forever.
865
+ export const retentionPhase = internalMutation({
866
+ args: {},
867
+ returns: v.object({ purged: v.number() }),
868
+ handler: async (ctx) => {
806
869
  const settings = await getSettings(ctx);
807
870
  const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
871
+ if (retentionMs <= 0)
872
+ return { purged: 0 };
873
+ const retentionCutoff = Date.now() - retentionMs;
808
874
  let purged = 0;
809
- if (retentionMs > 0) {
810
- const retentionCutoff = Date.now() - retentionMs;
811
- // Query eligible states directly. Long-lived pending jobs and expired
812
- // billing tombstones must not repeatedly occupy the front of a scan.
813
- const old = [];
814
- for (const expiredFlag of [undefined, false]) {
815
- old.push(...await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
816
- .lt("_creationTime", retentionCutoff)).take(200));
817
- }
818
- old.push(...await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
819
- for (const req of old) {
875
+ let more = false;
876
+ const sweep = async (rows) => {
877
+ for (const req of rows) {
820
878
  await deleteRequestTags(ctx, req._id);
821
879
  await ctx.db.delete(req._id);
822
880
  purged++;
823
881
  }
824
- const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
825
- .lt("_creationTime", retentionCutoff)).take(200);
826
- for (const req of expiredContent) {
827
- await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
828
- }
882
+ if (rows.length === RECONCILE_BATCH)
883
+ more = true;
884
+ };
885
+ // Settled, non-expired terminal rows past the window.
886
+ for (const expiredFlag of [undefined, false]) {
887
+ await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
888
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
889
+ }
890
+ // Blocked attempts past the window.
891
+ await sweep(await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
892
+ // Expired billing tombstones past the late-settle horizon (content already
893
+ // gone). Without this they'd live forever (one per crashed request).
894
+ const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
895
+ await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", true).eq("settled", true)
896
+ .lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
897
+ // Strip PII from expired tombstones still inside the horizon.
898
+ const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
899
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
900
+ for (const req of expiredContent) {
901
+ await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
829
902
  }
830
- return { folded: toFold.length, expired, purged };
903
+ if (expiredContent.length === RECONCILE_BATCH)
904
+ more = true;
905
+ if (more)
906
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
907
+ return { purged };
831
908
  },
832
909
  });
833
910
  export const setRetention = mutation({
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.17",
10
+ "version": "0.0.2-alpha.18",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -172,13 +172,20 @@ function extractUsage(usage: any): {
172
172
  cachedTokens: number;
173
173
  } {
174
174
  return {
175
- // Cover AI SDK camelCase (v5/v7) AND raw OpenAI-compatible snake_case, so a
176
- // `meter` caller passing a raw provider `usage` object still gets counts.
175
+ // Cover AI SDK camelCase (v5/v7), raw OpenAI-compatible snake_case, AND raw
176
+ // Anthropic (`input_tokens`/`output_tokens`), so a `meter` caller passing any
177
+ // provider's raw `usage` object still gets counts.
177
178
  promptTokens: toTokenCount(
178
- usage?.inputTokens ?? usage?.promptTokens ?? usage?.prompt_tokens
179
+ usage?.inputTokens ??
180
+ usage?.promptTokens ??
181
+ usage?.prompt_tokens ??
182
+ usage?.input_tokens
179
183
  ),
180
184
  completionTokens: toTokenCount(
181
- usage?.outputTokens ?? usage?.completionTokens ?? usage?.completion_tokens
185
+ usage?.outputTokens ??
186
+ usage?.completionTokens ??
187
+ usage?.completion_tokens ??
188
+ usage?.output_tokens
182
189
  ),
183
190
  // cached prompt tokens. The Convex gateway reports these at
184
191
  // `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
@@ -188,7 +195,8 @@ function extractUsage(usage: any): {
188
195
  usage?.cachedInputTokens ??
189
196
  usage?.promptTokensDetails?.cachedTokens ??
190
197
  usage?.prompt_tokens_details?.cached_tokens ??
191
- usage?.cached_tokens
198
+ usage?.cached_tokens ??
199
+ usage?.cache_read_input_tokens
192
200
  ),
193
201
  };
194
202
  }
@@ -214,6 +222,12 @@ function extractGatewayCostNanos(result: any): number | undefined {
214
222
 
215
223
  const NANOS_PER_DOLLAR = 1e9;
216
224
 
225
+ // Cap stored prompt/response content so one row can't approach Convex's 1 MiB
226
+ // document limit (which would fail the call) or bloat the reconciler's scans.
227
+ const MAX_STORED_CONTENT = 32 * 1024;
228
+ const capContent = (s: string) =>
229
+ s.length > MAX_STORED_CONTENT ? s.slice(0, MAX_STORED_CONTENT) + "…[truncated]" : s;
230
+
217
231
  // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
218
232
  function simplifyPrompt(prompt: any): Message[] {
219
233
  if (!Array.isArray(prompt)) return [];
@@ -223,14 +237,20 @@ function simplifyPrompt(prompt: any): Message[] {
223
237
  content = m.content;
224
238
  } else if (Array.isArray(m.content)) {
225
239
  content = m.content
226
- .map((part: any) =>
227
- part?.type === "text" ? part.text : JSON.stringify(part)
228
- )
240
+ .map((part: any) => {
241
+ if (part?.type === "text") return part.text ?? "";
242
+ // NEVER inline a base64 image/file part — a data URL or Uint8Array here
243
+ // becomes megabytes, pushing the stored row past the 1 MiB doc limit
244
+ // (failing the call) and turning a 200 KB image into a ~50k-token
245
+ // estimate. Store a compact placeholder.
246
+ const bytes = part?.data?.length ?? part?.image?.length ?? part?.data?.byteLength;
247
+ return `[${part?.type ?? "part"}${typeof bytes === "number" ? ` ${bytes}b` : ""}]`;
248
+ })
229
249
  .join("");
230
250
  } else {
231
251
  content = JSON.stringify(m.content);
232
252
  }
233
- return { role: String(m.role), content };
253
+ return { role: String(m.role), content: capContent(content) };
234
254
  });
235
255
  }
236
256
 
@@ -781,7 +801,7 @@ export class AIBudget {
781
801
  }));
782
802
  const tapped = result.stream.pipeThrough(
783
803
  new TransformStream({
784
- async transform(chunk: any, controller) {
804
+ async transform(chunk: any, controller: any) {
785
805
  if (chunk?.type === "text-delta") {
786
806
  text += chunk.delta ?? chunk.textDelta ?? "";
787
807
  }
@@ -797,7 +817,22 @@ export class AIBudget {
797
817
  async flush() {
798
818
  await settle();
799
819
  },
800
- })
820
+ // A cancelled/aborted stream (client disconnect, AbortSignal, or
821
+ // breaking out of `for await`) does NOT run `flush`. Without this
822
+ // the request would sit pending until the 30-min reservation
823
+ // expiry and be recorded as $0 though the provider charged for
824
+ // what streamed. Settle here with the text so far, estimating the
825
+ // completion tokens when the provider gave no usage.
826
+ // `cancel` is a newer Streams-spec transformer hook not yet in
827
+ // the TS DOM lib types; cast the literal so it compiles (runtimes
828
+ // without it simply won't call it, and the reconciler backstops).
829
+ async cancel(reason: any) {
830
+ if (usage === undefined && text) {
831
+ usage = { outputTokens: Math.ceil(text.length / 4) };
832
+ }
833
+ await settle(`cancelled: ${String(reason)}`);
834
+ },
835
+ } as any)
801
836
  );
802
837
  return { ...result, stream: tapped };
803
838
  } catch (e) {
@@ -185,9 +185,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
185
185
  "mutation",
186
186
  "internal",
187
187
  {
188
- dailySpendLimitNanos?: number;
189
- enforcement?: "hard" | "soft";
190
- lifetimeSpendLimitNanos?: number;
188
+ dailySpendLimitNanos?: number | null;
189
+ enforcement?: "hard" | "soft" | null;
190
+ lifetimeSpendLimitNanos?: number | null;
191
191
  },
192
192
  null,
193
193
  Name
@@ -554,8 +554,9 @@ describe("F-04 fail-closed pricing", () => {
554
554
  expect(r.allowed).toBe(true);
555
555
  await settle(t, r.requestId, 1_000_000, 1_000_000);
556
556
  const u = await userOf(t, "u");
557
- // conservative = max over table = {$3 in, $15 out}/Mtok => $18 = 18e9 nano.
558
- expect(u.totalSpendNanos).toBe(18_000_000_000);
557
+ // Conservative fallback is a frontier ceiling of {$20 in, $100 out}/Mtok, so
558
+ // 1M in + 1M out => $120 = 120e9 nano (over-count is the safe direction).
559
+ expect(u.totalSpendNanos).toBe(120_000_000_000);
559
560
  const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
560
561
  expect(req.unpricedModel).toBe(true);
561
562
  });
@@ -598,7 +599,7 @@ describe("accounting lifecycle regressions", () => {
598
599
  await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
599
600
  const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
600
601
  vi.advanceTimersByTime(2 * 60 * 60_000);
601
- await t.mutation(internal.lib.reconcile, {});
602
+ await t.mutation(internal.lib.expirePhase, {});
602
603
  expect((await userOf(t, "u")).reservedTotalNanos).toBe(0);
603
604
  await start(t, { userId: "u", estimatedCostNanos: 100 });
604
605
  await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 75 });
@@ -625,7 +626,7 @@ describe("accounting lifecycle regressions", () => {
625
626
  });
626
627
  const short = await start(t, { userId: "short" });
627
628
  vi.advanceTimersByTime(31 * 60_000);
628
- const result = await t.mutation(internal.lib.reconcile, {});
629
+ const result = await t.mutation(internal.lib.expirePhase, {});
629
630
  expect(result.expired).toBe(1);
630
631
  expect((await t.run(ctx => ctx.db.get(short.requestId))).reservationExpired).toBe(true);
631
632
  } finally { vi.useRealTimers(); }
@@ -664,9 +665,11 @@ test("legacy pending rows acquire deadlines without starving newer expired work"
664
665
  });
665
666
  const job = await start(t, { userId: "new" });
666
667
  vi.advanceTimersByTime(31 * 60_000);
667
- expect((await t.mutation(internal.lib.reconcile, {})).expired).toBe(1);
668
+ expect((await t.mutation(internal.lib.expirePhase, {})).expired).toBe(1);
668
669
  expect((await t.run(ctx => ctx.db.get(job.requestId))).reservationExpired).toBe(true);
669
- await t.mutation(internal.lib.reconcile, {});
670
+ // The phase self-reschedules to backfill the remaining legacy rows in
671
+ // batches; drain those scheduled continuations.
672
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
670
673
  expect(await t.run(ctx => ctx.db.query("requests").withIndex("status_expires", q =>
671
674
  q.eq("status", "pending").eq("expiresAt", undefined)).take(1))).toHaveLength(0);
672
675
  } finally { vi.useRealTimers(); }
@@ -685,7 +688,7 @@ test("retention progresses past unresolved jobs", async () => {
685
688
  await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 0 });
686
689
  await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
687
690
  vi.advanceTimersByTime(2 * 60 * 60_000);
688
- expect((await t.mutation(internal.lib.reconcile, {})).purged).toBe(1);
691
+ expect((await t.mutation(internal.lib.retentionPhase, {})).purged).toBe(1);
689
692
  expect(await t.run(ctx => ctx.db.get(job.requestId))).toBeNull();
690
693
  } finally { vi.useRealTimers(); }
691
694
  });
@@ -791,3 +794,53 @@ describe("v1 hardening", () => {
791
794
  ).rejects.toThrow(/dailySpendLimitNanos/);
792
795
  });
793
796
  });
797
+
798
+ describe("v1 hardening (round 2)", () => {
799
+ test("a non-finite estimatedCostNanos is rejected before it can poison a bucket", async () => {
800
+ const t = initTest();
801
+ for (const estimatedCostNanos of [Infinity, NaN, -1]) {
802
+ await expect(
803
+ start(t, { userId: "u", estimatedCostNanos })
804
+ ).rejects.toThrow(/estimatedCostNanos/);
805
+ }
806
+ });
807
+
808
+ test("a settle with no cost signal falls back to the reserved estimate, not $0", async () => {
809
+ const t = initTest();
810
+ // $2 reserved up front (e.g. a video job).
811
+ const r = await start(t, { userId: "u", estimatedCostNanos: 2_000_000_000 });
812
+ // Settle with an unpriced server tool and no tokens/authoritative cost.
813
+ const out = await t.mutation(api.lib.finishRequest, {
814
+ requestId: r.requestId,
815
+ serverToolUses: { video_seconds: 8 }, // no configured price
816
+ });
817
+ expect(out.costNanos).toBe(2_000_000_000); // NOT 0
818
+ vi.useFakeTimers();
819
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
820
+ vi.useRealTimers();
821
+ const u = await userOf(t, "u");
822
+ expect(u.totalSpendNanos).toBe(2_000_000_000);
823
+ });
824
+ });
825
+
826
+ describe("v1 hardening (round 3): reconcile phases", () => {
827
+ test("expired tombstones are deleted after the late-settle horizon", async () => {
828
+ vi.useFakeTimers();
829
+ try {
830
+ const t = initTest();
831
+ const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
832
+ vi.advanceTimersByTime(31 * 60_000);
833
+ // Expire it into a billing tombstone (reservationExpired + settled).
834
+ await t.mutation(internal.lib.expirePhase, {});
835
+ // Within the 7-day late-settle horizon: retention keeps the tombstone.
836
+ await t.mutation(internal.lib.retentionPhase, {});
837
+ expect(await t.run((ctx) => ctx.db.get(job.requestId))).not.toBeNull();
838
+ // Past the horizon: the tombstone is deleted so they can't accumulate.
839
+ vi.advanceTimersByTime(8 * 24 * 60 * 60_000);
840
+ await t.mutation(internal.lib.retentionPhase, {});
841
+ expect(await t.run((ctx) => ctx.db.get(job.requestId))).toBeNull();
842
+ } finally {
843
+ vi.useRealTimers();
844
+ }
845
+ });
846
+ });
@@ -46,6 +46,10 @@ function assertFraction(n: number | undefined, name: string) {
46
46
  // mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
47
47
  const safeCount = (n: number | undefined) =>
48
48
  Number.isFinite(n) ? Math.max(0, Math.floor(n as number)) : 0;
49
+ // Treat a non-finite stored accounting field as 0, so a bucket that was poisoned
50
+ // before validation existed self-heals on its next reserve/release instead of
51
+ // staying NaN forever.
52
+ const fin = (n: number | undefined) => (Number.isFinite(n) ? (n as number) : 0);
49
53
 
50
54
  // Built-in attribution dimensions. `user` and `action` are always populated
51
55
  // from a request's userId/actionName; apps can add any other dimensions
@@ -103,6 +107,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
103
107
  // audit table — and the sensitive content in it — from growing without bound.
104
108
  // Override per-deployment via setRetention.
105
109
  const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
110
+ // Reconciliation runs as small, independently-rescheduling phases. Keeping the
111
+ // batch small bounds the bytes read per transaction (each request row can carry
112
+ // prompts/responses) so a burst can't push one phase past Convex's 8 MiB / 16k-
113
+ // doc read limit and stall the whole reconciler. A phase that fills its batch
114
+ // reschedules itself immediately, so throughput still scales with backlog.
115
+ const RECONCILE_BATCH = 50;
116
+ // Keep an expired billing tombstone (content already purged) this long so a very
117
+ // late provider charge can still land against it, then delete it. A finish after
118
+ // deletion is a graceful no-op.
119
+ const LATE_SETTLE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
106
120
 
107
121
  const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
108
122
  const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
@@ -118,12 +132,17 @@ const CACHE_DISCOUNT = 0.1;
118
132
  // bills real money. Charging the conservative max instead keeps the caps honest
119
133
  // (over-counting is the safe direction); admins can pin an exact price via
120
134
  // setPrice, which also clears the `unpricedModel` flag on future requests.
135
+ // Seed with a true frontier ceiling ($20/$100 per Mtok), not just the max of the
136
+ // small built-in table — otherwise premium models (Opus-class $15/$75, etc.) not
137
+ // in the table would be under-counted several-fold whenever the gateway's
138
+ // authoritative cost isn't available. Over-counting an unpriced model is the safe
139
+ // direction; admins pin the exact rate with setPrice.
121
140
  const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce(
122
141
  (m, p) => ({
123
142
  input: Math.max(m.input, p.input),
124
143
  output: Math.max(m.output, p.output),
125
144
  }),
126
- { input: 0, output: 0 }
145
+ { input: 20_000_000_000, output: 100_000_000_000 }
127
146
  );
128
147
 
129
148
  async function getPrice(ctx: MutationCtx, model: string) {
@@ -500,6 +519,11 @@ export const startRequest = mutation({
500
519
  if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
501
520
  throw new Error("reserveTtlMs must be finite and nonnegative");
502
521
  }
522
+ // Infinity/NaN here is catastrophic: it's reserved onto the bucket, and a
523
+ // later release computes `Infinity - Infinity = NaN`, leaving the reserved
524
+ // fields NaN forever — after which every `used > cap` check is `NaN > cap`
525
+ // (false) and the bucket admits unlimited spend. Reject it up front.
526
+ assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
503
527
  const extraTags = sanitizeExtraTags(args.tags);
504
528
  // Record the blocked attempt and return a rejection (throwing would roll
505
529
  // back the record). `persist` is false for the high-frequency-by-design
@@ -830,13 +854,21 @@ export const finishRequest = mutation({
830
854
  costNanos = Math.round(args.costNanos);
831
855
  } else {
832
856
  const settings = await getSettings(ctx);
833
- costNanos =
857
+ const priced =
834
858
  settleCost(
835
859
  promptTokens,
836
860
  cachedTokens,
837
861
  completionTokens,
838
862
  await getPrice(ctx, request.model)
839
863
  ) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
864
+ // Fail closed: if the settle carried NO usable cost signal — no tokens and
865
+ // no priced server-tool fee (an unpriced tool like `video_seconds`, or a
866
+ // bare settle()) — fall back to the reserved estimate rather than recording
867
+ // $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
868
+ // reserve time precisely so this floor is their real cost. A caller that
869
+ // truly wants $0 passes an explicit authoritative costNanos: 0 above.
870
+ const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
871
+ costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
840
872
  }
841
873
 
842
874
  // Durable write to the request's OWN row only — uncontended, so it always
@@ -879,13 +911,13 @@ async function releaseReservation(ctx: MutationCtx, req: Doc<"requests">) {
879
911
  const b = await getBucketDoc(ctx, t.dimension, t.value);
880
912
  if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b))) continue;
881
913
  await ctx.db.patch(b._id, {
882
- reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
883
- reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
884
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
885
- reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
886
- reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
887
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
888
- pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
914
+ reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
915
+ reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
916
+ reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
917
+ reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
918
+ reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
919
+ reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
920
+ pendingCount: Math.max(0, fin(b.pendingCount) - 1),
889
921
  });
890
922
  }
891
923
  await ctx.db.patch(req._id, { reservationReleased: true });
@@ -943,29 +975,52 @@ export const foldTotals = internalMutation({
943
975
  // Backstop for both failure modes: folds finished requests whose scheduled fold
944
976
  // lost the retry race, and releases reservations for requests that never
945
977
  // settled (their action crashed). Runs on a cron.
978
+ // Cron entry: kick off each reconciliation phase as its OWN transaction so a
979
+ // failure in one (e.g. an oversized retention scan) can't stall the others, and
980
+ // so the hot fold path doesn't share a transaction with retention. Each phase
981
+ // self-reschedules while it has a full batch of backlog.
946
982
  export const reconcile = internalMutation({
947
983
  args: {},
948
- returns: v.object({
949
- folded: v.number(),
950
- expired: v.number(),
951
- purged: v.number(),
952
- }),
984
+ returns: v.null(),
985
+ handler: async (ctx) => {
986
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
987
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
988
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
989
+ return null;
990
+ },
991
+ });
992
+
993
+ // Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
994
+ export const foldPhase = internalMutation({
995
+ args: {},
996
+ returns: v.object({ folded: v.number() }),
953
997
  handler: async (ctx) => {
954
998
  const toFold = await ctx.db
955
999
  .query("requests")
956
1000
  .withIndex("settled", (q) => q.eq("settled", false))
957
- .take(200);
1001
+ .take(RECONCILE_BATCH);
958
1002
  for (const req of toFold) await foldOne(ctx, req);
1003
+ if (toFold.length === RECONCILE_BATCH)
1004
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
1005
+ return { folded: toFold.length };
1006
+ },
1007
+ });
959
1008
 
1009
+ // Release reservations for requests that never settled (their action crashed),
1010
+ // and lazily backfill deadlines on legacy pending rows.
1011
+ export const expirePhase = internalMutation({
1012
+ args: {},
1013
+ returns: v.object({ expired: v.number() }),
1014
+ handler: async (ctx) => {
960
1015
  // Lazily migrate old pending rows in bounded batches. Once indexed, long
961
1016
  // TTL jobs cannot hide expired jobs behind them in creation-time order.
962
1017
  const legacy = await ctx.db.query("requests").withIndex("status_expires", q =>
963
- q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
1018
+ q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
964
1019
  for (const req of legacy) {
965
1020
  await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
966
1021
  }
967
1022
  const candidates = await ctx.db.query("requests").withIndex("status_expires", q =>
968
- q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
1023
+ q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(RECONCILE_BATCH);
969
1024
  for (const req of candidates) {
970
1025
  await releaseReservation(ctx, req);
971
1026
  await ctx.db.patch(req._id, {
@@ -973,37 +1028,58 @@ export const reconcile = internalMutation({
973
1028
  reservationExpired: true, expiresAt: undefined, settled: true,
974
1029
  });
975
1030
  }
976
- const expired = candidates.length;
1031
+ if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
1032
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
1033
+ return { expired: candidates.length };
1034
+ },
1035
+ });
977
1036
 
978
- // Retention: delete terminal, fully-accounted request rows past the window.
1037
+ // Delete terminal, fully-accounted request rows past the retention window; purge
1038
+ // content from expired tombstones; and finally delete tombstones past the
1039
+ // late-settle horizon so they can't accumulate forever.
1040
+ export const retentionPhase = internalMutation({
1041
+ args: {},
1042
+ returns: v.object({ purged: v.number() }),
1043
+ handler: async (ctx) => {
979
1044
  const settings = await getSettings(ctx);
980
1045
  const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
1046
+ if (retentionMs <= 0) return { purged: 0 };
1047
+ const retentionCutoff = Date.now() - retentionMs;
981
1048
  let purged = 0;
982
- if (retentionMs > 0) {
983
- const retentionCutoff = Date.now() - retentionMs;
984
- // Query eligible states directly. Long-lived pending jobs and expired
985
- // billing tombstones must not repeatedly occupy the front of a scan.
986
- const old = [];
987
- for (const expiredFlag of [undefined, false]) {
988
- old.push(...await ctx.db.query("requests").withIndex("retention", q =>
989
- q.eq("reservationExpired", expiredFlag).eq("settled", true)
990
- .lt("_creationTime", retentionCutoff)).take(200));
991
- }
992
- old.push(...await ctx.db.query("requests").withIndex("status", q =>
993
- q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
994
- for (const req of old) {
1049
+ let more = false;
1050
+ const sweep = async (rows: Doc<"requests">[]) => {
1051
+ for (const req of rows) {
995
1052
  await deleteRequestTags(ctx, req._id);
996
1053
  await ctx.db.delete(req._id);
997
1054
  purged++;
998
1055
  }
999
- const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
1000
- q.eq("reservationExpired", true).eq("contentPurged", undefined)
1001
- .lt("_creationTime", retentionCutoff)).take(200);
1002
- for (const req of expiredContent) {
1003
- await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
1004
- }
1056
+ if (rows.length === RECONCILE_BATCH) more = true;
1057
+ };
1058
+ // Settled, non-expired terminal rows past the window.
1059
+ for (const expiredFlag of [undefined, false] as const) {
1060
+ await sweep(await ctx.db.query("requests").withIndex("retention", q =>
1061
+ q.eq("reservationExpired", expiredFlag).eq("settled", true)
1062
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
1063
+ }
1064
+ // Blocked attempts past the window.
1065
+ await sweep(await ctx.db.query("requests").withIndex("status", q =>
1066
+ q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
1067
+ // Expired billing tombstones past the late-settle horizon (content already
1068
+ // gone). Without this they'd live forever (one per crashed request).
1069
+ const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
1070
+ await sweep(await ctx.db.query("requests").withIndex("retention", q =>
1071
+ q.eq("reservationExpired", true).eq("settled", true)
1072
+ .lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
1073
+ // Strip PII from expired tombstones still inside the horizon.
1074
+ const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
1075
+ q.eq("reservationExpired", true).eq("contentPurged", undefined)
1076
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
1077
+ for (const req of expiredContent) {
1078
+ await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
1005
1079
  }
1006
- return { folded: toFold.length, expired, purged };
1080
+ if (expiredContent.length === RECONCILE_BATCH) more = true;
1081
+ if (more) await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
1082
+ return { purged };
1007
1083
  },
1008
1084
  });
1009
1085