@iann29/rastro 0.2.0 → 0.4.0

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.
Files changed (87) hide show
  1. package/README.md +82 -36
  2. package/agent/integration.md +4 -0
  3. package/agent/manifest.json +9 -5
  4. package/agent/manifest.schema.json +7 -1
  5. package/dist/client/federation.d.ts +18 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +7 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +138 -1
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +30 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +46 -0
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/_generated/server.d.ts +1 -0
  16. package/dist/component/_generated/server.d.ts.map +1 -1
  17. package/dist/component/_generated/server.js.map +1 -1
  18. package/dist/component/constants.d.ts +7 -2
  19. package/dist/component/constants.d.ts.map +1 -1
  20. package/dist/component/constants.js +24 -3
  21. package/dist/component/constants.js.map +1 -1
  22. package/dist/component/convex.config.d.ts +1 -0
  23. package/dist/component/convex.config.js +3 -0
  24. package/dist/component/convex.config.js.map +1 -1
  25. package/dist/component/geo.d.ts +1 -0
  26. package/dist/component/geo.d.ts.map +1 -1
  27. package/dist/component/geo.js +1 -1
  28. package/dist/component/geo.js.map +1 -1
  29. package/dist/component/http.d.ts.map +1 -1
  30. package/dist/component/http.js +14 -1
  31. package/dist/component/http.js.map +1 -1
  32. package/dist/component/identity.d.ts +12 -0
  33. package/dist/component/identity.d.ts.map +1 -1
  34. package/dist/component/identity.js +30 -2
  35. package/dist/component/identity.js.map +1 -1
  36. package/dist/component/ingest.d.ts +34 -0
  37. package/dist/component/ingest.d.ts.map +1 -1
  38. package/dist/component/ingest.js +86 -21
  39. package/dist/component/ingest.js.map +1 -1
  40. package/dist/component/live.d.ts.map +1 -1
  41. package/dist/component/live.js +34 -4
  42. package/dist/component/live.js.map +1 -1
  43. package/dist/component/reports.d.ts +47 -0
  44. package/dist/component/reports.d.ts.map +1 -1
  45. package/dist/component/reports.js +96 -6
  46. package/dist/component/reports.js.map +1 -1
  47. package/dist/component/rollupStore.d.ts +7 -4
  48. package/dist/component/rollupStore.d.ts.map +1 -1
  49. package/dist/component/rollupStore.js +37 -17
  50. package/dist/component/rollupStore.js.map +1 -1
  51. package/dist/component/rollups.d.ts +27 -8
  52. package/dist/component/rollups.d.ts.map +1 -1
  53. package/dist/component/rollups.js +110 -32
  54. package/dist/component/rollups.js.map +1 -1
  55. package/dist/component/schema.d.ts +6 -2
  56. package/dist/component/schema.js +5 -2
  57. package/dist/component/schema.js.map +1 -1
  58. package/dist/tracker/generated.d.ts +11 -11
  59. package/dist/tracker/generated.d.ts.map +1 -1
  60. package/dist/tracker/generated.js +11 -11
  61. package/dist/tracker/generated.js.map +1 -1
  62. package/dist/tracker/tracker.js +88 -43
  63. package/dist/tracker/tracker.js.map +1 -1
  64. package/dist/tracker/vitals.d.ts.map +1 -1
  65. package/dist/tracker/vitals.js +22 -4
  66. package/dist/tracker/vitals.js.map +1 -1
  67. package/dist/tracker.min.js +1 -1
  68. package/dist/vitals.min.js +1 -1
  69. package/docs/federation-setup.md +2 -0
  70. package/docs/federation.md +6 -0
  71. package/docs/upgrading.md +84 -0
  72. package/llms.txt +3 -3
  73. package/package.json +1 -1
  74. package/src/component/_generated/component.ts +49 -0
  75. package/src/component/_generated/server.ts +1 -0
  76. package/src/component/constants.ts +24 -3
  77. package/src/component/convex.config.ts +3 -0
  78. package/src/component/geo.ts +1 -1
  79. package/src/component/http.ts +15 -1
  80. package/src/component/identity.ts +41 -2
  81. package/src/component/ingest.ts +120 -36
  82. package/src/component/live.ts +51 -4
  83. package/src/component/reports.ts +120 -8
  84. package/src/component/rollupStore.ts +51 -20
  85. package/src/component/rollups.ts +141 -32
  86. package/src/component/schema.ts +6 -3
  87. package/src/tracker/generated.ts +11 -11
@@ -1,7 +1,15 @@
1
1
  import { v } from "convex/values";
2
2
  import { internal } from "./_generated/api.js";
3
+ import type { Doc, Id } from "./_generated/dataModel.js";
4
+ import type { MutationCtx } from "./_generated/server.js";
3
5
  import { internalMutation } from "./_generated/server.js";
4
6
  import { LIVE_SWEEP_INTERVAL_MS } from "./constants.js";
7
+ import {
8
+ flushAggregates,
9
+ foldSessionDuration,
10
+ type AggregateDeltas,
11
+ } from "./ingest.js";
12
+ import { localDayTimezone } from "./localTime.js";
5
13
 
6
14
  const SWEEP_BATCH_SIZE = 500;
7
15
 
@@ -58,7 +66,7 @@ export const expire = internalMutation({
58
66
  }
59
67
  return { expired: false, rescheduled: false, stale: false };
60
68
  }
61
- await ctx.db.delete("liveSessions", liveSession._id);
69
+ await retireLiveSessions(ctx, liveSession.siteId, [liveSession]);
62
70
  return { expired: true, rescheduled: false, stale: false };
63
71
  },
64
72
  });
@@ -89,9 +97,7 @@ export const sweep = internalMutation({
89
97
  range.eq("siteId", args.siteId).lte("expiresAt", now),
90
98
  )
91
99
  .take(SWEEP_BATCH_SIZE);
92
- for (const session of expired) {
93
- await ctx.db.delete("liveSessions", session._id);
94
- }
100
+ await retireLiveSessions(ctx, args.siteId, expired);
95
101
 
96
102
  const next = await ctx.db
97
103
  .query("liveSessions")
@@ -115,3 +121,44 @@ export const sweep = internalMutation({
115
121
  return { expired: expired.length, rescheduled: true, stale: false };
116
122
  },
117
123
  });
124
+
125
+ /**
126
+ * Deletes departed live rows and counts the time their heartbeats proved. A
127
+ * heartbeat only moves its session's `lastSeenAt`, and `durationMs` is what
128
+ * the duration counters already hold, so the difference reaches the buckets
129
+ * here, once, in the hour the visitor was last seen. It lands in one shard
130
+ * per site, so a sweep rewrites a few buckets however many visitors it
131
+ * retires.
132
+ */
133
+ async function retireLiveSessions(
134
+ ctx: MutationCtx,
135
+ siteId: Id<"sites">,
136
+ rows: Doc<"liveSessions">[],
137
+ ) {
138
+ if (rows.length === 0) return;
139
+ const site = await ctx.db.get("sites", siteId);
140
+ const timezone = localDayTimezone(site?.timezone);
141
+ const aggregateDeltas: AggregateDeltas = new Map();
142
+ for (const row of rows) {
143
+ await ctx.db.delete("liveSessions", row._id);
144
+ const session = await ctx.db
145
+ .query("sessions")
146
+ .withIndex("by_siteId_and_sessionId", (range) =>
147
+ range.eq("siteId", siteId).eq("sessionId", row.sessionId),
148
+ )
149
+ .unique();
150
+ if (!session) continue;
151
+ const durationMs = session.lastSeenAt - session.startedAt;
152
+ if (durationMs <= session.durationMs) continue;
153
+ await ctx.db.patch("sessions", session._id, { durationMs });
154
+ foldSessionDuration(
155
+ aggregateDeltas,
156
+ siteId,
157
+ siteId,
158
+ session.lastSeenAt,
159
+ durationMs - session.durationMs,
160
+ timezone,
161
+ );
162
+ }
163
+ await flushAggregates(ctx, aggregateDeltas);
164
+ }
@@ -346,14 +346,19 @@ export const overview = query({
346
346
  const bucketCount = localDays
347
347
  ? bucketStarts.length
348
348
  : Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
349
- const perSiteLimit =
350
- bucketCount *
351
- (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
349
+ const shards =
350
+ interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS;
351
+ const perSiteLimit = bucketCount * shards;
352
352
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
353
+ // The row budget, not MAX_REPORT_RANGE_DAYS, bounds the overview: 101
354
+ // days for one site and 10 for ten; 24 hours for two, 5 for ten.
355
+ const maximumBuckets = Math.floor(
356
+ MAX_OVERVIEW_AGGREGATE_ROWS / (shards * siteIds.length),
357
+ );
353
358
  fail(
354
359
  "LIMIT_EXCEEDED",
355
- "overview range is too wide for the requested number of sites",
356
- { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS },
360
+ `overview range is too wide: ${siteIds.length === 1 ? "one site" : `${siteIds.length} sites`} can read at most ${maximumBuckets} ${interval}s`,
361
+ { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS, maximumBuckets },
357
362
  );
358
363
  }
359
364
  const totals = emptyTotals();
@@ -382,7 +387,13 @@ export const overview = query({
382
387
  await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
383
388
 
384
389
  for (const siteId of siteIds) {
385
- const buckets = await ctx.db
390
+ // Iterated rather than taken whole: within the row budget a busy range
391
+ // can still cross the bytes a query may read. Values are length-capped,
392
+ // so sixteen buckets stay far below the reserve, and running out fails
393
+ // with REPORT_INCOMPLETE, as the feature reports do, instead of a raw
394
+ // limit error.
395
+ const buckets: Doc<"aggregateBuckets">[] = [];
396
+ for await (const bucket of ctx.db
386
397
  .query("aggregateBuckets")
387
398
  .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
388
399
  range
@@ -390,8 +401,13 @@ export const overview = query({
390
401
  .eq("granularity", granularity)
391
402
  .gte("bucketStart", firstBucket)
392
403
  .lte("bucketStart", lastBucket),
393
- )
394
- .take(perSiteLimit + 1);
404
+ )) {
405
+ buckets.push(bucket);
406
+ if (buckets.length > perSiteLimit) break;
407
+ if (buckets.length % 16 === 0) {
408
+ await requireFeatureReadHeadroom(ctx, "overview aggregate buckets");
409
+ }
410
+ }
395
411
  failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
396
412
  for (const bucket of buckets) {
397
413
  if (
@@ -932,6 +948,102 @@ async function readRouteRollups(
932
948
  return { rows, completeFrom: cutAt + DAY_MS };
933
949
  }
934
950
 
951
+ /** Metadata for a selected session, independent of list pagination. */
952
+ export const getSession = query({
953
+ args: { siteId: v.id("sites"), sessionId: v.string() },
954
+ returns: v.union(sessionDocumentValidator, v.null()),
955
+ handler: async (ctx, args) => {
956
+ const session = await ctx.db
957
+ .query("sessions")
958
+ .withIndex("by_siteId_and_sessionId", (q) =>
959
+ q.eq("siteId", args.siteId).eq("sessionId", args.sessionId),
960
+ )
961
+ .unique();
962
+ if (!session) return null;
963
+ const {
964
+ geoLookupAttemptedAt: _geo,
965
+ lastPageviewAt: _pageview,
966
+ ...result
967
+ } = session;
968
+ return result;
969
+ },
970
+ });
971
+
972
+ /** Exact commissions alongside overview's period revenue/conversion totals. */
973
+ export const revenueSummary = query({
974
+ args: { siteIds: v.array(v.id("sites")), from: v.number(), to: v.number() },
975
+ returns: v.object({ commissionCents: v.number() }),
976
+ handler: async (ctx, args) => {
977
+ const siteIds = validateSiteIds(args.siteIds);
978
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
979
+ const fullFrom = Math.ceil(args.from / DAY_MS) * DAY_MS;
980
+ const fullTo = Math.floor((args.to + 1) / DAY_MS) * DAY_MS;
981
+ let commissionCents = 0;
982
+ let rowsRead = 0;
983
+ const currencies = new Set<string>();
984
+ for (const siteId of siteIds) {
985
+ const site = await ctx.db.get("sites", siteId);
986
+ if (!site) fail("NOT_FOUND", "site not found");
987
+ currencies.add(site.currency ?? "USD");
988
+ if (currencies.size > 1)
989
+ fail("CURRENCY_MISMATCH", "revenue sites must use the same currency");
990
+ if (fullFrom < fullTo) {
991
+ await assertSourceAvailable(
992
+ ctx,
993
+ [siteId],
994
+ ["affiliateStats"],
995
+ fullFrom,
996
+ );
997
+ const stats = await ctx.db
998
+ .query("affiliateDailyStats")
999
+ .withIndex("by_siteId_and_bucketStart", (q) =>
1000
+ q
1001
+ .eq("siteId", siteId)
1002
+ .gte("bucketStart", fullFrom)
1003
+ .lt("bucketStart", fullTo),
1004
+ )
1005
+ .take(MAX_REPORT_BUCKETS - rowsRead + 1);
1006
+ rowsRead += stats.length;
1007
+ failIfIncomplete(
1008
+ stats,
1009
+ MAX_REPORT_BUCKETS - (rowsRead - stats.length),
1010
+ "commission buckets",
1011
+ );
1012
+ for (const row of stats) commissionCents += row.commissionCents;
1013
+ }
1014
+ // ponytail: boundary scans cap at MAX_REPORT_BUCKETS; add hourly commission
1015
+ // rollups if busy partial days exceed this explicit report limit.
1016
+ // Daily stats cannot answer partial days (24h and local-day windows).
1017
+ // Read only those edges from the trusted ledger, never round the period.
1018
+ const edges: Array<[number, number]> =
1019
+ fullFrom < fullTo
1020
+ ? [
1021
+ [args.from, fullFrom - 1],
1022
+ [fullTo, args.to],
1023
+ ]
1024
+ : [[args.from, args.to]];
1025
+ for (const [from, to] of edges) {
1026
+ if (from > to) continue;
1027
+ await assertSourceAvailable(ctx, [siteId], ["conversions"], from);
1028
+ const rows = await ctx.db
1029
+ .query("conversions")
1030
+ .withIndex("by_siteId_and_timestamp", (q) =>
1031
+ q.eq("siteId", siteId).gte("timestamp", from).lte("timestamp", to),
1032
+ )
1033
+ .take(MAX_REPORT_BUCKETS - rowsRead + 1);
1034
+ rowsRead += rows.length;
1035
+ failIfIncomplete(
1036
+ rows,
1037
+ MAX_REPORT_BUCKETS - (rowsRead - rows.length),
1038
+ "commission boundary conversions",
1039
+ );
1040
+ for (const row of rows) commissionCents += row.commissionCents;
1041
+ }
1042
+ }
1043
+ return { commissionCents };
1044
+ },
1045
+ });
1046
+
935
1047
  export const listSessions = query({
936
1048
  args: {
937
1049
  siteId: v.id("sites"),
@@ -13,6 +13,7 @@ import {
13
13
  MAX_FUNNEL_STEPS,
14
14
  MAX_SITE_MAP_ROUTES_PER_DAY,
15
15
  REPORT_ROLLUP_SHARDS,
16
+ ROLLUP_FOLD_DEBOUNCE_MS,
16
17
  ROLLUP_FOLD_LEASE_MS,
17
18
  ROUTE_TRANSITION_SLOTS,
18
19
  SITE_MAP_OTHER_ROUTE,
@@ -36,8 +37,8 @@ import {
36
37
  * whole batches are lost (#52). Ingestion therefore never touches a rollup
37
38
  * row. It appends the batch's deltas to `reportRollupQueue` — an insert
38
39
  * conflicts with nothing — and one leased fold job per site merges the queue
39
- * into the rows a few hundred milliseconds later, so every rollup row has a
40
- * single writer.
40
+ * into the rows about a second later (see rollups.ts), so every rollup row
41
+ * has a single writer.
41
42
  */
42
43
 
43
44
  export type GoalRollupDelta = {
@@ -221,6 +222,10 @@ const queuedVitalValidator = v.object({
221
222
  export const reportRollupQueueFields = {
222
223
  siteId: v.id("sites"),
223
224
  createdAt: v.number(),
225
+ // Failed runs in a row with this batch at the head of the queue, and when
226
+ // it was set aside after too many (see rollups.ts).
227
+ foldFailures: v.optional(v.number()),
228
+ deadLetteredAt: v.optional(v.number()),
224
229
  goals: v.array(queuedGoalValidator),
225
230
  funnels: v.array(queuedFunnelValidator),
226
231
  funnelSteps: v.array(queuedFunnelStepValidator),
@@ -456,8 +461,9 @@ export function mergeQueuedRollups(
456
461
  /**
457
462
  * Appends a batch's rollup deltas to the site's queue and makes sure a fold
458
463
  * job will pick them up. The only document this reads is the site's fold
459
- * state, which the fold job rewrites about twice a minute under load, so
460
- * concurrent ingests almost never retry because of it.
464
+ * state, which no batch rewrites while a fold is pending and the fold job
465
+ * rewrites about twice a minute under load, so concurrent ingests almost
466
+ * never retry because of it.
461
467
  */
462
468
  export async function enqueueReportRollups(
463
469
  ctx: MutationCtx,
@@ -482,6 +488,7 @@ export async function ensureRollupFold(
482
488
  .query("rollupFoldStates")
483
489
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
484
490
  .unique();
491
+ // A held lease means a chain is running and will fold this batch.
485
492
  if (state && state.leaseUntil > now) return;
486
493
  const token = crypto.randomUUID();
487
494
  const leaseUntil = now + ROLLUP_FOLD_LEASE_MS;
@@ -490,7 +497,11 @@ export async function ensureRollupFold(
490
497
  } else {
491
498
  await ctx.db.insert("rollupFoldStates", { siteId, leaseUntil, token });
492
499
  }
493
- await ctx.scheduler.runAfter(0, internal.rollups.fold, { siteId, token });
500
+ // Debounced, so every batch of the next second joins this run.
501
+ await ctx.scheduler.runAfter(ROLLUP_FOLD_DEBOUNCE_MS, internal.rollups.fold, {
502
+ siteId,
503
+ token,
504
+ });
494
505
  }
495
506
 
496
507
  // ---------------------------------------------------------------------------
@@ -632,8 +643,9 @@ export async function flushReportRollups(
632
643
  for (const delta of deltas.routes.values()) {
633
644
  await upsertRouteRollup(ctx, delta, routeDayRowCounts);
634
645
  }
646
+ const vitalDayPageRows = new Map<string, number>();
635
647
  for (const delta of deltas.vitals.values()) {
636
- await upsertVitalRollup(ctx, delta);
648
+ await upsertVitalRollup(ctx, delta, vitalDayPageRows);
637
649
  }
638
650
  }
639
651
 
@@ -731,7 +743,11 @@ async function upsertRouteRollup(
731
743
  }
732
744
  }
733
745
 
734
- async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
746
+ async function upsertVitalRollup(
747
+ ctx: MutationCtx,
748
+ delta: VitalRollupDelta,
749
+ dayPageRows: Map<string, number>,
750
+ ) {
735
751
  const existing = await ctx.db
736
752
  .query("vitalRollups")
737
753
  .withIndex("by_key", (range) =>
@@ -748,21 +764,36 @@ async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
748
764
  delta.device === VITAL_ALL &&
749
765
  delta.page !== VITAL_OTHER_PAGES
750
766
  ) {
751
- // A new page row spends one bounded read to respect the daily page slots;
752
- // past the cap, the day's remaining pages fold into "(other)".
753
- const pageRows = await ctx.db
754
- .query("vitalRollups")
755
- .withIndex("by_key", (range) =>
756
- range
757
- .eq("siteId", delta.siteId)
758
- .eq("bucketStart", delta.bucketStart)
759
- .eq("device", VITAL_ALL),
760
- )
761
- .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
762
- if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
763
- await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
767
+ // New page rows spend one bounded read per day and fold run to respect
768
+ // the daily page slots, as routes do: rereading it for every new page let
769
+ // a fold of many distinct URLs pass the read limit and fail every retry.
770
+ // Past the cap, the day's remaining pages fold into "(other)".
771
+ const capRows = VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS;
772
+ const dayKey = `${delta.siteId}:${delta.bucketStart}`;
773
+ let pageRows = dayPageRows.get(dayKey);
774
+ if (pageRows === undefined) {
775
+ pageRows = (
776
+ await ctx.db
777
+ .query("vitalRollups")
778
+ .withIndex("by_key", (range) =>
779
+ range
780
+ .eq("siteId", delta.siteId)
781
+ .eq("bucketStart", delta.bucketStart)
782
+ .eq("device", VITAL_ALL),
783
+ )
784
+ .take(capRows + 1)
785
+ ).length;
786
+ dayPageRows.set(dayKey, pageRows);
787
+ }
788
+ if (pageRows > capRows) {
789
+ await upsertVitalRollup(
790
+ ctx,
791
+ { ...delta, page: VITAL_OTHER_PAGES },
792
+ dayPageRows,
793
+ );
764
794
  return;
765
795
  }
796
+ dayPageRows.set(dayKey, pageRows + 1);
766
797
  }
767
798
  const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
768
799
  for (const [metric, addition] of delta.metrics) {
@@ -1,9 +1,14 @@
1
- import { v } from "convex/values";
1
+ import { v, type Infer } from "convex/values";
2
2
  import { internal } from "./_generated/api.js";
3
+ import type { Id } from "./_generated/dataModel.js";
4
+ import type { MutationCtx } from "./_generated/server.js";
3
5
  import { internalMutation } from "./_generated/server.js";
4
6
  import {
7
+ ROLLUP_DEAD_LETTER_RETENTION_MS,
5
8
  ROLLUP_FOLD_BATCH_DOCS,
9
+ ROLLUP_FOLD_DEBOUNCE_MS,
6
10
  ROLLUP_FOLD_LEASE_MS,
11
+ ROLLUP_FOLD_MAX_FAILURES,
7
12
  ROLLUP_FOLD_MAX_ROWS,
8
13
  } from "./constants.js";
9
14
  import {
@@ -13,24 +18,65 @@ import {
13
18
  mergeQueuedRollups,
14
19
  } from "./rollupStore.js";
15
20
 
21
+ const foldArgs = { siteId: v.id("sites"), token: v.string() };
22
+ const foldResult = v.object({
23
+ folded: v.number(),
24
+ remaining: v.boolean(),
25
+ stale: v.boolean(),
26
+ });
27
+ type FoldResult = Infer<typeof foldResult>;
28
+
29
+ // A failed run's reads still count against the transaction that catches it,
30
+ // so a run stops short of the platform's read limits (16 MiB, 32,000
31
+ // documents, 4,096 queries) and leaves room to record the failure.
32
+ const RUN_READ_LIMITS = {
33
+ bytesRead: 15 * 1024 * 1024,
34
+ documentsRead: 31_000,
35
+ databaseQueries: 4_000,
36
+ };
37
+
16
38
  /**
17
39
  * Folds a site's queued report rollup deltas into the rollup rows. Exactly
18
- * one chain runs per site: ingestion starts one when no lease is held, every
19
- * run carries the token of the lease it belongs to, and a run whose token is
20
- * no longer current exits without touching anything. A run that finds more
21
- * queued documents than it could take continues at once; a run that drains
22
- * the queue releases the lease. A batch that lands while a run is draining
23
- * is never stranded: the run read the queue range the batch inserted into
24
- * and the batch read the lease the run released, so one of the two retries
25
- * and sees the other's work.
40
+ * one chain runs per site: a batch that finds the lease free takes it and
41
+ * schedules the first run a debounce away (see ensureRollupFold), every run
42
+ * carries the token of that lease, and a run whose token is no longer
43
+ * current exits without touching anything. Batches that land while the
44
+ * lease is held only append to the queue; they never write the lease or
45
+ * schedule anything.
46
+ *
47
+ * A run folds the batches created before it began and, if it folded any,
48
+ * schedules the next run a debounce later (at once while a backlog remains),
49
+ * so a steady stream costs one run per second and batches landing during a
50
+ * run never conflict with it. Only a run that finds nothing left releases
51
+ * the lease, and that run reads the whole queue range: a batch landing
52
+ * concurrently either commits first, and the run retries and folds it, or
53
+ * reads the released lease and starts a new chain.
54
+ *
55
+ * A run executes as a nested transaction, so one that throws is rolled back
56
+ * whole and its batches stay queued; the oldest is charged the failure and
57
+ * the next run retries it alone. After ROLLUP_FOLD_MAX_FAILURES runs in a
58
+ * row it is set aside as a dead letter and the queue moves on.
26
59
  */
27
60
  export const fold = internalMutation({
28
- args: { siteId: v.id("sites"), token: v.string() },
29
- returns: v.object({
30
- folded: v.number(),
31
- remaining: v.boolean(),
32
- stale: v.boolean(),
33
- }),
61
+ args: foldArgs,
62
+ returns: foldResult,
63
+ handler: async (ctx, args) => {
64
+ try {
65
+ const result: FoldResult = await ctx.runMutation(
66
+ internal.rollups.foldRun,
67
+ args,
68
+ { transactionLimits: RUN_READ_LIMITS },
69
+ );
70
+ return result;
71
+ } catch (error) {
72
+ return await setAsideFailedRun(ctx, args, error);
73
+ }
74
+ },
75
+ });
76
+
77
+ export const foldRun = internalMutation({
78
+ args: foldArgs,
79
+ returns: foldResult,
34
80
  handler: async (ctx, args) => {
35
81
  const state = await ctx.db
36
82
  .query("rollupFoldStates")
@@ -42,20 +88,31 @@ export const fold = internalMutation({
42
88
  const now = Date.now();
43
89
  const queued = await ctx.db
44
90
  .query("reportRollupQueue")
45
- .withIndex("by_siteId_and_createdAt", (range) =>
46
- range.eq("siteId", args.siteId),
91
+ .withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
92
+ range
93
+ .eq("siteId", args.siteId)
94
+ .eq("deadLetteredAt", undefined)
95
+ .lte("createdAt", now),
47
96
  )
48
- .order("asc")
49
97
  .take(ROLLUP_FOLD_BATCH_DOCS + 1);
50
98
  if (queued.length === 0) {
51
- if (state.leaseUntil > now) {
99
+ if (await oldestQueued(ctx, args.siteId)) {
100
+ await ctx.scheduler.runAfter(
101
+ ROLLUP_FOLD_DEBOUNCE_MS,
102
+ internal.rollups.fold,
103
+ args,
104
+ );
105
+ } else if (state.leaseUntil > now) {
52
106
  await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil: now });
53
107
  }
54
108
  return { folded: 0, remaining: false, stale: false };
55
109
  }
110
+ // A batch that already failed a run is retried alone, so the next
111
+ // failure is charged to the batch that causes it.
112
+ const batchDocs = queued[0].foldFailures ? 1 : ROLLUP_FOLD_BATCH_DOCS;
56
113
  const deltas = createReportRollupDeltas();
57
114
  let taken = 0;
58
- for (const document of queued.slice(0, ROLLUP_FOLD_BATCH_DOCS)) {
115
+ for (const document of queued.slice(0, batchDocs)) {
59
116
  mergeQueuedRollups(deltas, document);
60
117
  taken += 1;
61
118
  if (countReportRollupRows(deltas) >= ROLLUP_FOLD_MAX_ROWS) break;
@@ -64,19 +121,71 @@ export const fold = internalMutation({
64
121
  for (const document of queued.slice(0, taken)) {
65
122
  await ctx.db.delete("reportRollupQueue", document._id);
66
123
  }
67
- const remaining = queued.length > taken;
68
- if (remaining) {
69
- // Renew only when the lease is halfway gone, so the state document is
70
- // rewritten about twice a minute under load rather than on every run.
71
- if (state.leaseUntil - now < ROLLUP_FOLD_LEASE_MS / 2) {
72
- await ctx.db.patch("rollupFoldStates", state._id, {
73
- leaseUntil: now + ROLLUP_FOLD_LEASE_MS,
74
- });
75
- }
76
- await ctx.scheduler.runAfter(0, internal.rollups.fold, args);
77
- } else {
78
- await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil: now });
124
+ // Renew only when the lease is halfway gone, so the state document is
125
+ // rewritten about twice a minute while batches keep arriving.
126
+ if (state.leaseUntil - now < ROLLUP_FOLD_LEASE_MS / 2) {
127
+ await ctx.db.patch("rollupFoldStates", state._id, {
128
+ leaseUntil: now + ROLLUP_FOLD_LEASE_MS,
129
+ });
79
130
  }
131
+ const remaining = queued.length > taken;
132
+ await ctx.scheduler.runAfter(
133
+ remaining ? 0 : ROLLUP_FOLD_DEBOUNCE_MS,
134
+ internal.rollups.fold,
135
+ args,
136
+ );
80
137
  return { folded: taken, remaining, stale: false };
81
138
  },
82
139
  });
140
+
141
+ /** The oldest batch still waiting to fold; dead letters are skipped. */
142
+ function oldestQueued(ctx: MutationCtx, siteId: Id<"sites">) {
143
+ return ctx.db
144
+ .query("reportRollupQueue")
145
+ .withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
146
+ range.eq("siteId", siteId).eq("deadLetteredAt", undefined),
147
+ )
148
+ .first();
149
+ }
150
+
151
+ async function setAsideFailedRun(
152
+ ctx: MutationCtx,
153
+ args: { siteId: Id<"sites">; token: string },
154
+ error: unknown,
155
+ ): Promise<FoldResult> {
156
+ const state = await ctx.db
157
+ .query("rollupFoldStates")
158
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
159
+ .unique();
160
+ const head = await oldestQueued(ctx, args.siteId);
161
+ if (!head || state?.token !== args.token) throw error;
162
+ const now = Date.now();
163
+ const foldFailures = (head.foldFailures ?? 0) + 1;
164
+ if (foldFailures < ROLLUP_FOLD_MAX_FAILURES) {
165
+ await ctx.db.patch("reportRollupQueue", head._id, { foldFailures });
166
+ } else {
167
+ console.error(
168
+ `rollup fold set aside queued batch ${head._id} of site ${args.siteId} after ${foldFailures} failed runs`,
169
+ error,
170
+ );
171
+ await ctx.db.patch("reportRollupQueue", head._id, {
172
+ foldFailures,
173
+ deadLetteredAt: now,
174
+ });
175
+ // Dead letters stay for inspection; each new one retires expired ones.
176
+ const expired = await ctx.db
177
+ .query("reportRollupQueue")
178
+ .withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
179
+ range
180
+ .eq("siteId", args.siteId)
181
+ .gte("deadLetteredAt", 0)
182
+ .lt("deadLetteredAt", now - ROLLUP_DEAD_LETTER_RETENTION_MS),
183
+ )
184
+ .take(ROLLUP_FOLD_BATCH_DOCS);
185
+ for (const document of expired) {
186
+ await ctx.db.delete("reportRollupQueue", document._id);
187
+ }
188
+ }
189
+ await ctx.scheduler.runAfter(0, internal.rollups.fold, args);
190
+ return { folded: 0, remaining: true, stale: false };
191
+ }
@@ -248,10 +248,11 @@ export default defineSchema({
248
248
  .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
249
249
 
250
250
  // Rollup deltas wait here between ingestion and the fold job; see
251
- // rollupStore.ts. Rows are deleted as they are folded.
251
+ // rollups.ts. Rows are deleted as they are folded; a batch that keeps
252
+ // failing is set aside with `deadLetteredAt` and skipped.
252
253
  reportRollupQueue: defineTable(reportRollupQueueFields).index(
253
- "by_siteId_and_createdAt",
254
- ["siteId", "createdAt"],
254
+ "by_siteId_and_deadLetteredAt_and_createdAt",
255
+ ["siteId", "deadLetteredAt", "createdAt"],
255
256
  ),
256
257
 
257
258
  rollupFoldStates: defineTable(rollupFoldStateFields).index("by_siteId", [
@@ -441,6 +442,8 @@ export default defineSchema({
441
442
 
442
443
  ingestWindows: defineTable({
443
444
  siteId: v.id("sites"),
445
+ // A session ID, or `~client:` plus a salted daily client-network key for
446
+ // the per-client budget; no sanitized session ID starts with `~`.
444
447
  sessionId: v.string(),
445
448
  windowStart: v.number(),
446
449
  eventCount: v.number(),