@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.
- package/README.md +82 -36
- package/agent/integration.md +4 -0
- package/agent/manifest.json +9 -5
- package/agent/manifest.schema.json +7 -1
- package/dist/client/federation.d.ts +18 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +7 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +138 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +30 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +46 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +1 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/constants.d.ts +7 -2
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +24 -3
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/geo.d.ts +1 -0
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +1 -1
- package/dist/component/geo.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +14 -1
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +12 -0
- package/dist/component/identity.d.ts.map +1 -1
- package/dist/component/identity.js +30 -2
- package/dist/component/identity.js.map +1 -1
- package/dist/component/ingest.d.ts +34 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +86 -21
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +34 -4
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +47 -0
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +96 -6
- package/dist/component/reports.js.map +1 -1
- package/dist/component/rollupStore.d.ts +7 -4
- package/dist/component/rollupStore.d.ts.map +1 -1
- package/dist/component/rollupStore.js +37 -17
- package/dist/component/rollupStore.js.map +1 -1
- package/dist/component/rollups.d.ts +27 -8
- package/dist/component/rollups.d.ts.map +1 -1
- package/dist/component/rollups.js +110 -32
- package/dist/component/rollups.js.map +1 -1
- package/dist/component/schema.d.ts +6 -2
- package/dist/component/schema.js +5 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/tracker/generated.d.ts +11 -11
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -11
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +88 -43
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts.map +1 -1
- package/dist/tracker/vitals.js +22 -4
- package/dist/tracker/vitals.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -1
- package/docs/federation-setup.md +2 -0
- package/docs/federation.md +6 -0
- package/docs/upgrading.md +84 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/component/_generated/component.ts +49 -0
- package/src/component/_generated/server.ts +1 -0
- package/src/component/constants.ts +24 -3
- package/src/component/convex.config.ts +3 -0
- package/src/component/geo.ts +1 -1
- package/src/component/http.ts +15 -1
- package/src/component/identity.ts +41 -2
- package/src/component/ingest.ts +120 -36
- package/src/component/live.ts +51 -4
- package/src/component/reports.ts +120 -8
- package/src/component/rollupStore.ts +51 -20
- package/src/component/rollups.ts +141 -32
- package/src/component/schema.ts +6 -3
- package/src/tracker/generated.ts +11 -11
package/src/component/live.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/component/reports.ts
CHANGED
|
@@ -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
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
|
460
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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) {
|
package/src/component/rollups.ts
CHANGED
|
@@ -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:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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:
|
|
29
|
-
returns:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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("
|
|
46
|
-
range
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|
package/src/component/schema.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
"
|
|
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(),
|