@agent-native/core 0.146.7 → 0.146.8
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/corpus/README.md +1 -1
- package/corpus/templates/analytics/DEVELOPING.md +59 -0
- package/corpus/templates/analytics/package.json +1 -0
- package/corpus/templates/analytics/scripts/first-party-analytics-bigquery-backfill.ts +839 -0
- package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +70 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +117 -12
- package/corpus/templates/factory/actions/run-factory-automation.ts +1 -0
- package/corpus/templates/factory/netlify.toml +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +3 -3
package/corpus/README.md
CHANGED
|
@@ -183,6 +183,65 @@ Conventions:
|
|
|
183
183
|
- Use `fatal()` for required arg validation
|
|
184
184
|
- `helpers.ts` loads `dotenv/config` so env vars are available
|
|
185
185
|
|
|
186
|
+
### Dedicated first-party Analytics backfill
|
|
187
|
+
|
|
188
|
+
`backfill:first-party-bigquery` is a resumable, bounded Neon-to-BigQuery worker
|
|
189
|
+
for an explicitly selected organization. It is dry-run by default. It uses the
|
|
190
|
+
scoped credential resolver, fixed high-water marks, separate organization and
|
|
191
|
+
legacy-owner cursors, atomic local checkpoints, a local lock, and a durable
|
|
192
|
+
database lease shared with the shipped worker. The tuple-paging indexes are
|
|
193
|
+
installed by the additive analytics migration. It does not change the Analytics
|
|
194
|
+
sink or perform cutover.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
pnpm backfill:first-party-bigquery \
|
|
198
|
+
--owner-email=<owner-email> \
|
|
199
|
+
--org-id=<org-id>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Production execution requires both `--execute` and the explicit
|
|
203
|
+
`AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1` environment gate. Start with a bounded
|
|
204
|
+
`--max-batches` budget and increase concurrency only after checking database
|
|
205
|
+
health between runs. The dedicated worker claims the durable migration-job
|
|
206
|
+
lease, so the shipped worker and another operator using a different checkpoint
|
|
207
|
+
path cannot copy the same job concurrently. A crashed dedicated worker leaves
|
|
208
|
+
that lease to expire before the shipped worker can resume it.
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 pnpm backfill:first-party-bigquery \
|
|
212
|
+
--execute \
|
|
213
|
+
--owner-email=<owner-email> \
|
|
214
|
+
--org-id=<org-id> \
|
|
215
|
+
--batch-size=1000 \
|
|
216
|
+
--concurrency=2 \
|
|
217
|
+
--max-batches=100
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The checkpoint file contains cursors and counts, not event payloads or
|
|
221
|
+
credentials. A short page triggers a fresh high-water check so rows written
|
|
222
|
+
while the worker is running are included. A partial BigQuery acknowledgement
|
|
223
|
+
does not advance the cursor; retry it promptly, or run an insertId-deduplication
|
|
224
|
+
or `MERGE` pass on the target table before finalizing.
|
|
225
|
+
|
|
226
|
+
Before finalization, quiesce the source event writes and verify the final
|
|
227
|
+
high-water marks. The `--owner-email` and `--org-id` values are trusted operator
|
|
228
|
+
inputs, so execution is limited to the approved production operator and the
|
|
229
|
+
two explicit environment gates. Then use the separate `--finalize` command
|
|
230
|
+
with both gates below. The command rechecks the source high-water marks before
|
|
231
|
+
marking the durable job complete; the normal migration action's approval gate
|
|
232
|
+
still controls cutover:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_FINALIZE_ALLOW=1 \
|
|
236
|
+
AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_QUIESCENT=1 \
|
|
237
|
+
pnpm backfill:first-party-bigquery \
|
|
238
|
+
--finalize \
|
|
239
|
+
--owner-email=<owner-email> \
|
|
240
|
+
--org-id=<org-id>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Do not delete a lock file until the owning process has been verified stopped.
|
|
244
|
+
|
|
186
245
|
## TypeScript Everywhere
|
|
187
246
|
|
|
188
247
|
All code in this project must be TypeScript (`.ts`). Never create `.js`, `.cjs`, or `.mjs` files. Node 22+ runs `.ts` files natively, so no compilation step is needed for scripts. Use ESM imports (`import`), not CommonJS (`require`).
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"format.fix": "oxfmt --write .",
|
|
12
12
|
"typecheck": "agent-native typecheck",
|
|
13
13
|
"migrate:production": "tsx scripts/migrate-production.ts",
|
|
14
|
+
"backfill:first-party-bigquery": "tsx scripts/first-party-analytics-bigquery-backfill.ts",
|
|
14
15
|
"action": "agent-native action",
|
|
15
16
|
"script": "agent-native script"
|
|
16
17
|
},
|
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
import {
|
|
2
|
+
open,
|
|
3
|
+
mkdir,
|
|
4
|
+
readFile,
|
|
5
|
+
rename,
|
|
6
|
+
unlink,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
closeDbExec,
|
|
14
|
+
getDbExec,
|
|
15
|
+
withMigrationRuntime,
|
|
16
|
+
} from "@agent-native/core/db";
|
|
17
|
+
import { runWithRequestContext } from "@agent-native/core/server";
|
|
18
|
+
|
|
19
|
+
import { output, parseArgs } from "../actions/helpers.js";
|
|
20
|
+
import {
|
|
21
|
+
acquireDedicatedFirstPartyAnalyticsBackfillLease,
|
|
22
|
+
getFirstPartyAnalyticsBigQueryBackfillJob,
|
|
23
|
+
} from "../server/jobs/analytics-bigquery-backfill.js";
|
|
24
|
+
import {
|
|
25
|
+
createFirstPartyAnalyticsInserter,
|
|
26
|
+
FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS,
|
|
27
|
+
getFirstPartyAnalyticsBackend,
|
|
28
|
+
saveFirstPartyAnalyticsBackend,
|
|
29
|
+
type FirstPartyAnalyticsScope,
|
|
30
|
+
} from "../server/lib/first-party-analytics-backend.js";
|
|
31
|
+
|
|
32
|
+
const DEFAULT_BATCH_SIZE = 1_000;
|
|
33
|
+
const MAX_BATCH_SIZE = 2_500;
|
|
34
|
+
const DEFAULT_CONCURRENCY = 2;
|
|
35
|
+
const MAX_CONCURRENCY = 4;
|
|
36
|
+
const DEFAULT_MAX_BATCHES = 1_000;
|
|
37
|
+
const MAX_MAX_BATCHES = 10_000;
|
|
38
|
+
const DEFAULT_CHECKPOINT_PATH = ".analytics-first-party-bigquery-backfill.json";
|
|
39
|
+
const QUERY_TIMEOUT_MS = 30_000;
|
|
40
|
+
const JOB_TABLE = "analytics_bigquery_backfill_jobs";
|
|
41
|
+
const FINALIZE_ALLOW_ENV =
|
|
42
|
+
"AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_FINALIZE_ALLOW";
|
|
43
|
+
const FINALIZE_QUIESCENT_ENV =
|
|
44
|
+
"AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_QUIESCENT";
|
|
45
|
+
|
|
46
|
+
type BranchName = "org" | "legacy";
|
|
47
|
+
|
|
48
|
+
export interface BackfillCursor {
|
|
49
|
+
receivedAt: string;
|
|
50
|
+
id: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BranchCheckpoint {
|
|
54
|
+
cutoff: BackfillCursor | null;
|
|
55
|
+
cursor: BackfillCursor | null;
|
|
56
|
+
copied: number;
|
|
57
|
+
complete: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DedicatedBackfillCheckpoint {
|
|
61
|
+
version: 1;
|
|
62
|
+
scope: {
|
|
63
|
+
userEmail: string;
|
|
64
|
+
orgId: string;
|
|
65
|
+
};
|
|
66
|
+
table: string | null;
|
|
67
|
+
branches: Record<BranchName, BranchCheckpoint>;
|
|
68
|
+
updatedAt: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DedicatedBackfillOptions {
|
|
72
|
+
scope: FirstPartyAnalyticsScope & { orgId: string };
|
|
73
|
+
table: string | null;
|
|
74
|
+
batchSize: number;
|
|
75
|
+
concurrency: number;
|
|
76
|
+
maxBatches: number;
|
|
77
|
+
checkpointPath: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface FinalizeResult {
|
|
81
|
+
mode: "finalize";
|
|
82
|
+
copied: number;
|
|
83
|
+
cursor: BackfillCursor | null;
|
|
84
|
+
jobId: string;
|
|
85
|
+
status: "completed";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface DbQuery {
|
|
89
|
+
sql: string;
|
|
90
|
+
args: unknown[];
|
|
91
|
+
timeoutMs: number;
|
|
92
|
+
maxAttempts: 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface DbExecutor {
|
|
96
|
+
execute(query: DbQuery): Promise<{ rows: unknown[] }>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CheckpointStore {
|
|
100
|
+
load(): Promise<DedicatedBackfillCheckpoint | null>;
|
|
101
|
+
save(checkpoint: DedicatedBackfillCheckpoint): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DedicatedBackfillDependencies {
|
|
105
|
+
db: DbExecutor;
|
|
106
|
+
upload: (rows: Array<Record<string, unknown>>) => Promise<number>;
|
|
107
|
+
checkpointStore: CheckpointStore;
|
|
108
|
+
now?: () => string;
|
|
109
|
+
onProgress?: (progress: {
|
|
110
|
+
branch: BranchName;
|
|
111
|
+
batches: number;
|
|
112
|
+
copiedThisRun: number;
|
|
113
|
+
copiedTotal: number;
|
|
114
|
+
}) => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface DedicatedBackfillResult {
|
|
118
|
+
batches: number;
|
|
119
|
+
copiedThisRun: number;
|
|
120
|
+
copiedTotal: number;
|
|
121
|
+
complete: boolean;
|
|
122
|
+
checkpoint: DedicatedBackfillCheckpoint;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface BranchSpec {
|
|
126
|
+
name: BranchName;
|
|
127
|
+
predicate: string;
|
|
128
|
+
predicateArgs: string[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const PROJECTED_COLUMNS = FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS.join(", ");
|
|
132
|
+
|
|
133
|
+
function positiveInteger(
|
|
134
|
+
raw: string | undefined,
|
|
135
|
+
name: string,
|
|
136
|
+
fallback: number,
|
|
137
|
+
maximum: number,
|
|
138
|
+
): number {
|
|
139
|
+
if (raw === undefined) return fallback;
|
|
140
|
+
const value = Number(raw);
|
|
141
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
142
|
+
throw new Error(`${name} must be a positive integer`);
|
|
143
|
+
}
|
|
144
|
+
return Math.min(value, maximum);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function requiredArg(args: Record<string, string>, name: string): string {
|
|
148
|
+
const value = args[name]?.trim();
|
|
149
|
+
if (!value) throw new Error(`--${name} is required`);
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseDedicatedBackfillOptions(
|
|
154
|
+
args = parseArgs(),
|
|
155
|
+
): DedicatedBackfillOptions & { execute: boolean; finalize: boolean } {
|
|
156
|
+
const userEmail = requiredArg(args, "owner-email");
|
|
157
|
+
const orgId = requiredArg(args, "org-id");
|
|
158
|
+
return {
|
|
159
|
+
scope: { userEmail, orgId },
|
|
160
|
+
table: args.table?.trim() || null,
|
|
161
|
+
batchSize: positiveInteger(
|
|
162
|
+
args["batch-size"],
|
|
163
|
+
"--batch-size",
|
|
164
|
+
DEFAULT_BATCH_SIZE,
|
|
165
|
+
MAX_BATCH_SIZE,
|
|
166
|
+
),
|
|
167
|
+
concurrency: positiveInteger(
|
|
168
|
+
args.concurrency,
|
|
169
|
+
"--concurrency",
|
|
170
|
+
DEFAULT_CONCURRENCY,
|
|
171
|
+
MAX_CONCURRENCY,
|
|
172
|
+
),
|
|
173
|
+
maxBatches: positiveInteger(
|
|
174
|
+
args["max-batches"],
|
|
175
|
+
"--max-batches",
|
|
176
|
+
DEFAULT_MAX_BATCHES,
|
|
177
|
+
MAX_MAX_BATCHES,
|
|
178
|
+
),
|
|
179
|
+
checkpointPath: resolve(args.checkpoint || DEFAULT_CHECKPOINT_PATH),
|
|
180
|
+
execute: args.execute === "true",
|
|
181
|
+
finalize: args.finalize === "true",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function branchSpecs(
|
|
186
|
+
scope: FirstPartyAnalyticsScope & { orgId: string },
|
|
187
|
+
): BranchSpec[] {
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
name: "org",
|
|
191
|
+
predicate: "org_id = ?",
|
|
192
|
+
predicateArgs: [scope.orgId],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "legacy",
|
|
196
|
+
predicate: "org_id IS NULL AND owner_email = ?",
|
|
197
|
+
predicateArgs: [scope.userEmail],
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function buildHighWaterMarkQuery(spec: BranchSpec): DbQuery {
|
|
203
|
+
return {
|
|
204
|
+
sql: `SELECT received_at, id
|
|
205
|
+
FROM analytics_events
|
|
206
|
+
WHERE ${spec.predicate}
|
|
207
|
+
ORDER BY received_at DESC, id DESC
|
|
208
|
+
LIMIT 1`,
|
|
209
|
+
args: spec.predicateArgs,
|
|
210
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
211
|
+
maxAttempts: 1,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function buildPageQuery(
|
|
216
|
+
spec: BranchSpec,
|
|
217
|
+
state: BranchCheckpoint,
|
|
218
|
+
batchSize: number,
|
|
219
|
+
): DbQuery {
|
|
220
|
+
if (!state.cutoff) {
|
|
221
|
+
throw new Error(`Cannot page ${spec.name} without a high-water mark`);
|
|
222
|
+
}
|
|
223
|
+
const args: unknown[] = [
|
|
224
|
+
...spec.predicateArgs,
|
|
225
|
+
state.cutoff.receivedAt,
|
|
226
|
+
state.cutoff.receivedAt,
|
|
227
|
+
state.cutoff.id,
|
|
228
|
+
];
|
|
229
|
+
let cursorClause = "";
|
|
230
|
+
if (state.cursor) {
|
|
231
|
+
cursorClause = " AND (received_at > ? OR (received_at = ? AND id > ?))";
|
|
232
|
+
args.push(
|
|
233
|
+
state.cursor.receivedAt,
|
|
234
|
+
state.cursor.receivedAt,
|
|
235
|
+
state.cursor.id,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
args.push(batchSize);
|
|
239
|
+
return {
|
|
240
|
+
sql: `SELECT ${PROJECTED_COLUMNS}
|
|
241
|
+
FROM analytics_events
|
|
242
|
+
WHERE ${spec.predicate}
|
|
243
|
+
AND (received_at < ? OR (received_at = ? AND id <= ?))${cursorClause}
|
|
244
|
+
ORDER BY received_at ASC, id ASC
|
|
245
|
+
LIMIT ?`,
|
|
246
|
+
args,
|
|
247
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
248
|
+
maxAttempts: 1,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cursorFromRow(row: unknown, description: string): BackfillCursor {
|
|
253
|
+
if (!row || typeof row !== "object") {
|
|
254
|
+
throw new Error(`${description} is not an object`);
|
|
255
|
+
}
|
|
256
|
+
const record = row as Record<string, unknown>;
|
|
257
|
+
const receivedAt = record.received_at;
|
|
258
|
+
const id = record.id;
|
|
259
|
+
if (typeof receivedAt !== "string" || !receivedAt) {
|
|
260
|
+
throw new Error(`${description} is missing received_at`);
|
|
261
|
+
}
|
|
262
|
+
if (typeof id !== "string" || !id) {
|
|
263
|
+
throw new Error(`${description} is missing id`);
|
|
264
|
+
}
|
|
265
|
+
return { receivedAt, id };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function assertCheckpointCursor(
|
|
269
|
+
cursor: unknown,
|
|
270
|
+
description: string,
|
|
271
|
+
): asserts cursor is BackfillCursor {
|
|
272
|
+
if (!cursor || typeof cursor !== "object") {
|
|
273
|
+
throw new Error(`Backfill checkpoint ${description} is not an object`);
|
|
274
|
+
}
|
|
275
|
+
const record = cursor as Record<string, unknown>;
|
|
276
|
+
if (typeof record.receivedAt !== "string" || !record.receivedAt) {
|
|
277
|
+
throw new Error(`Backfill checkpoint ${description} is missing receivedAt`);
|
|
278
|
+
}
|
|
279
|
+
if (typeof record.id !== "string" || !record.id) {
|
|
280
|
+
throw new Error(`Backfill checkpoint ${description} is missing id`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function emptyBranchCheckpoint(): BranchCheckpoint {
|
|
285
|
+
return { cutoff: null, cursor: null, copied: 0, complete: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function newCheckpoint(
|
|
289
|
+
options: DedicatedBackfillOptions,
|
|
290
|
+
now: string,
|
|
291
|
+
): DedicatedBackfillCheckpoint {
|
|
292
|
+
return {
|
|
293
|
+
version: 1,
|
|
294
|
+
scope: {
|
|
295
|
+
userEmail: options.scope.userEmail,
|
|
296
|
+
orgId: options.scope.orgId,
|
|
297
|
+
},
|
|
298
|
+
table: options.table,
|
|
299
|
+
branches: {
|
|
300
|
+
org: emptyBranchCheckpoint(),
|
|
301
|
+
legacy: emptyBranchCheckpoint(),
|
|
302
|
+
},
|
|
303
|
+
updatedAt: now,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function assertCheckpointMatches(
|
|
308
|
+
checkpoint: DedicatedBackfillCheckpoint,
|
|
309
|
+
options: DedicatedBackfillOptions,
|
|
310
|
+
): void {
|
|
311
|
+
if (checkpoint.version !== 1) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`Unsupported backfill checkpoint version: ${checkpoint.version}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
checkpoint.scope.userEmail !== options.scope.userEmail ||
|
|
318
|
+
checkpoint.scope.orgId !== options.scope.orgId
|
|
319
|
+
) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
"Backfill checkpoint scope does not match --owner-email/--org-id",
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (checkpoint.table !== options.table) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
"Backfill checkpoint table does not match the current BigQuery table",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
for (const branch of ["org", "legacy"] as const) {
|
|
330
|
+
const state = checkpoint.branches[branch];
|
|
331
|
+
if (!state)
|
|
332
|
+
throw new Error(`Backfill checkpoint is missing ${branch} state`);
|
|
333
|
+
if (!Number.isInteger(state.copied) || state.copied < 0) {
|
|
334
|
+
throw new Error(`Backfill checkpoint has an invalid ${branch} count`);
|
|
335
|
+
}
|
|
336
|
+
if (state.cutoff) {
|
|
337
|
+
assertCheckpointCursor(state.cutoff, `${branch} cutoff`);
|
|
338
|
+
}
|
|
339
|
+
if (state.cursor) {
|
|
340
|
+
assertCheckpointCursor(state.cursor, `${branch} cursor`);
|
|
341
|
+
}
|
|
342
|
+
if (state.cursor && !state.cutoff) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`Backfill checkpoint has a ${branch} cursor without a cutoff`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function latestCursor(
|
|
351
|
+
checkpoint: DedicatedBackfillCheckpoint,
|
|
352
|
+
): BackfillCursor | null {
|
|
353
|
+
const cursors = [
|
|
354
|
+
checkpoint.branches.org.cursor,
|
|
355
|
+
checkpoint.branches.legacy.cursor,
|
|
356
|
+
]
|
|
357
|
+
.filter((cursor): cursor is BackfillCursor => cursor !== null)
|
|
358
|
+
.sort(
|
|
359
|
+
(left, right) =>
|
|
360
|
+
left.receivedAt.localeCompare(right.receivedAt) ||
|
|
361
|
+
left.id.localeCompare(right.id),
|
|
362
|
+
);
|
|
363
|
+
return cursors[cursors.length - 1] ?? null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function compareCursors(left: BackfillCursor, right: BackfillCursor): number {
|
|
367
|
+
return (
|
|
368
|
+
left.receivedAt.localeCompare(right.receivedAt) ||
|
|
369
|
+
left.id.localeCompare(right.id)
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function assertSourceAtCheckpoint(
|
|
374
|
+
scope: DedicatedBackfillOptions["scope"],
|
|
375
|
+
checkpoint: DedicatedBackfillCheckpoint,
|
|
376
|
+
db: DbExecutor = getDbExec(),
|
|
377
|
+
): Promise<void> {
|
|
378
|
+
for (const spec of branchSpecs(scope)) {
|
|
379
|
+
const result = await db.execute(buildHighWaterMarkQuery(spec));
|
|
380
|
+
const latestRow = result.rows[0];
|
|
381
|
+
if (!latestRow) continue;
|
|
382
|
+
const latest = cursorFromRow(
|
|
383
|
+
latestRow,
|
|
384
|
+
`${spec.name} final high-water mark`,
|
|
385
|
+
);
|
|
386
|
+
const cutoff = checkpoint.branches[spec.name].cutoff;
|
|
387
|
+
if (!cutoff || compareCursors(latest, cutoff) > 0) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`Refusing to finalize: ${spec.name} branch has events after the checkpoint cutoff; quiesce source writes and retry the finalization`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export class JsonCheckpointStore implements CheckpointStore {
|
|
396
|
+
constructor(private readonly path: string) {}
|
|
397
|
+
|
|
398
|
+
async load(): Promise<DedicatedBackfillCheckpoint | null> {
|
|
399
|
+
try {
|
|
400
|
+
const raw = await readFile(this.path, "utf8");
|
|
401
|
+
const parsed: unknown = JSON.parse(raw);
|
|
402
|
+
if (!parsed || typeof parsed !== "object") {
|
|
403
|
+
throw new Error("checkpoint is not an object");
|
|
404
|
+
}
|
|
405
|
+
return parsed as DedicatedBackfillCheckpoint;
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Unable to read backfill checkpoint ${this.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async save(checkpoint: DedicatedBackfillCheckpoint): Promise<void> {
|
|
415
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
416
|
+
const temporaryPath = `${this.path}.${process.pid}.tmp`;
|
|
417
|
+
await writeFile(temporaryPath, `${JSON.stringify(checkpoint, null, 2)}\n`, {
|
|
418
|
+
encoding: "utf8",
|
|
419
|
+
mode: 0o600,
|
|
420
|
+
});
|
|
421
|
+
await rename(temporaryPath, this.path);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function refreshBranchCutoff(
|
|
426
|
+
spec: BranchSpec,
|
|
427
|
+
state: BranchCheckpoint,
|
|
428
|
+
checkpoint: DedicatedBackfillCheckpoint,
|
|
429
|
+
dependencies: DedicatedBackfillDependencies,
|
|
430
|
+
now: () => string,
|
|
431
|
+
): Promise<void> {
|
|
432
|
+
const result = await dependencies.db.execute(buildHighWaterMarkQuery(spec));
|
|
433
|
+
const row = result.rows[0];
|
|
434
|
+
const latest = row
|
|
435
|
+
? cursorFromRow(row, `${spec.name} high-water mark`)
|
|
436
|
+
: null;
|
|
437
|
+
if (latest && (!state.cutoff || compareCursors(latest, state.cutoff) > 0)) {
|
|
438
|
+
state.cutoff = latest;
|
|
439
|
+
state.complete = false;
|
|
440
|
+
} else {
|
|
441
|
+
state.complete = true;
|
|
442
|
+
}
|
|
443
|
+
checkpoint.updatedAt = now();
|
|
444
|
+
await dependencies.checkpointStore.save(checkpoint);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export async function acquireCheckpointLock(
|
|
448
|
+
checkpointPath: string,
|
|
449
|
+
): Promise<() => Promise<void>> {
|
|
450
|
+
const lockPath = `${checkpointPath}.lock`;
|
|
451
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
452
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
453
|
+
try {
|
|
454
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Another backfill worker owns ${lockPath}; inspect it before removing the lock`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
await handle.writeFile(
|
|
465
|
+
`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`,
|
|
466
|
+
"utf8",
|
|
467
|
+
);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
await handle.close();
|
|
470
|
+
await unlink(lockPath).catch(() => undefined);
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
await handle.close();
|
|
474
|
+
return async () => {
|
|
475
|
+
await unlink(lockPath).catch((error: unknown) => {
|
|
476
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
477
|
+
});
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export async function runDedicatedBackfill(
|
|
482
|
+
options: DedicatedBackfillOptions,
|
|
483
|
+
dependencies: DedicatedBackfillDependencies,
|
|
484
|
+
): Promise<DedicatedBackfillResult> {
|
|
485
|
+
const now = dependencies.now ?? (() => new Date().toISOString());
|
|
486
|
+
const checkpoint =
|
|
487
|
+
(await dependencies.checkpointStore.load()) ??
|
|
488
|
+
newCheckpoint(options, now());
|
|
489
|
+
assertCheckpointMatches(checkpoint, options);
|
|
490
|
+
await dependencies.checkpointStore.save(checkpoint);
|
|
491
|
+
|
|
492
|
+
let batches = 0;
|
|
493
|
+
let copiedThisRun = 0;
|
|
494
|
+
let stop = false;
|
|
495
|
+
for (const spec of branchSpecs(options.scope)) {
|
|
496
|
+
const state = checkpoint.branches[spec.name];
|
|
497
|
+
if (state.complete) continue;
|
|
498
|
+
|
|
499
|
+
if (!state.cutoff) {
|
|
500
|
+
await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
|
|
501
|
+
if (state.complete) continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
while (!state.complete) {
|
|
505
|
+
if (batches >= options.maxBatches) {
|
|
506
|
+
stop = true;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
const result = await dependencies.db.execute(
|
|
510
|
+
buildPageQuery(spec, state, options.batchSize),
|
|
511
|
+
);
|
|
512
|
+
const rows = result.rows as Array<Record<string, unknown>>;
|
|
513
|
+
if (!rows.length) {
|
|
514
|
+
await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
|
|
515
|
+
if (state.complete) break;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const copied = await dependencies.upload(rows);
|
|
520
|
+
if (copied !== rows.length) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`BigQuery acknowledged ${copied} of ${rows.length} rows; checkpoint was not advanced`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
state.cursor = cursorFromRow(rows[rows.length - 1], `${spec.name} page`);
|
|
527
|
+
state.copied += copied;
|
|
528
|
+
copiedThisRun += copied;
|
|
529
|
+
batches += 1;
|
|
530
|
+
state.complete = false;
|
|
531
|
+
checkpoint.updatedAt = now();
|
|
532
|
+
await dependencies.checkpointStore.save(checkpoint);
|
|
533
|
+
dependencies.onProgress?.({
|
|
534
|
+
branch: spec.name,
|
|
535
|
+
batches,
|
|
536
|
+
copiedThisRun,
|
|
537
|
+
copiedTotal: state.copied,
|
|
538
|
+
});
|
|
539
|
+
if (rows.length < options.batchSize) {
|
|
540
|
+
await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (stop) break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
batches,
|
|
548
|
+
copiedThisRun,
|
|
549
|
+
copiedTotal:
|
|
550
|
+
checkpoint.branches.org.copied + checkpoint.branches.legacy.copied,
|
|
551
|
+
complete:
|
|
552
|
+
checkpoint.branches.org.complete && checkpoint.branches.legacy.complete,
|
|
553
|
+
checkpoint,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const USAGE = `Dedicated first-party Analytics Neon -> BigQuery backfill
|
|
558
|
+
|
|
559
|
+
Dry-run (default):
|
|
560
|
+
pnpm backfill:first-party-bigquery --owner-email=<email> --org-id=<org>
|
|
561
|
+
|
|
562
|
+
Execute only after an explicit production approval:
|
|
563
|
+
AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 pnpm backfill:first-party-bigquery \
|
|
564
|
+
--execute --owner-email=<email> --org-id=<org>
|
|
565
|
+
|
|
566
|
+
Options:
|
|
567
|
+
--table=<dataset.table> Optional override, must match the approved table
|
|
568
|
+
--batch-size=<n> Source page size, default 1000, max 2500
|
|
569
|
+
--concurrency=<n> BigQuery requests in flight, default 2, max 4
|
|
570
|
+
--max-batches=<n> Work budget per invocation, default 1000
|
|
571
|
+
--checkpoint=<path> Atomic local checkpoint path
|
|
572
|
+
|
|
573
|
+
After the checkpoint is complete, reconcile it into the shipped durable job
|
|
574
|
+
after quiescing source event writes and checking the final high-water marks:
|
|
575
|
+
AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_FINALIZE_ALLOW=1 \
|
|
576
|
+
AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_QUIESCENT=1 \
|
|
577
|
+
pnpm backfill:first-party-bigquery \
|
|
578
|
+
--finalize --owner-email=<email> --org-id=<org>
|
|
579
|
+
`;
|
|
580
|
+
|
|
581
|
+
async function runApprovedWorker(
|
|
582
|
+
options: DedicatedBackfillOptions,
|
|
583
|
+
): Promise<DedicatedBackfillResult> {
|
|
584
|
+
let releaseLock: (() => Promise<void>) | null = null;
|
|
585
|
+
let releaseLease: (() => Promise<void>) | null = null;
|
|
586
|
+
try {
|
|
587
|
+
releaseLock = await acquireCheckpointLock(options.checkpointPath);
|
|
588
|
+
return await withMigrationRuntime(async () =>
|
|
589
|
+
runWithRequestContext(options.scope, async () => {
|
|
590
|
+
const backend = await getFirstPartyAnalyticsBackend(options.scope);
|
|
591
|
+
if (backend.sink !== "dual") {
|
|
592
|
+
throw new Error(
|
|
593
|
+
`Refusing to backfill while the source sink is ${backend.sink}; prepare must establish dual-write first`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (options.table && backend.table && options.table !== backend.table) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
"--table does not match the scoped first-party Analytics backend setting",
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
const configuredTable = options.table ?? backend.table;
|
|
602
|
+
if (!configuredTable) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
"Refusing to start the dedicated backfill without a configured BigQuery table",
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const workerOptions = { ...options, table: configuredTable };
|
|
608
|
+
releaseLease = await acquireDedicatedFirstPartyAnalyticsBackfillLease(
|
|
609
|
+
options.scope,
|
|
610
|
+
configuredTable,
|
|
611
|
+
);
|
|
612
|
+
const insertRows = await createFirstPartyAnalyticsInserter(
|
|
613
|
+
configuredTable,
|
|
614
|
+
{
|
|
615
|
+
maxRowsPerRequest: 500,
|
|
616
|
+
maxConcurrentRequests: options.concurrency,
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
const result = await runDedicatedBackfill(workerOptions, {
|
|
620
|
+
db: getDbExec(),
|
|
621
|
+
upload: (rows) => insertRows(rows),
|
|
622
|
+
checkpointStore: new JsonCheckpointStore(options.checkpointPath),
|
|
623
|
+
onProgress: (progress) => {
|
|
624
|
+
console.error(JSON.stringify({ type: "progress", ...progress }));
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
return result;
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
} finally {
|
|
631
|
+
const leaseToRelease = releaseLease as (() => Promise<void>) | null;
|
|
632
|
+
const lockToRelease = releaseLock as (() => Promise<void>) | null;
|
|
633
|
+
if (leaseToRelease) {
|
|
634
|
+
try {
|
|
635
|
+
await leaseToRelease();
|
|
636
|
+
} finally {
|
|
637
|
+
if (lockToRelease) {
|
|
638
|
+
try {
|
|
639
|
+
await closeDbExec();
|
|
640
|
+
} finally {
|
|
641
|
+
await lockToRelease();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
} else if (lockToRelease) {
|
|
646
|
+
try {
|
|
647
|
+
await closeDbExec();
|
|
648
|
+
} finally {
|
|
649
|
+
await lockToRelease();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function runApprovedFinalize(
|
|
656
|
+
options: DedicatedBackfillOptions,
|
|
657
|
+
): Promise<FinalizeResult> {
|
|
658
|
+
let releaseLock: (() => Promise<void>) | null = null;
|
|
659
|
+
try {
|
|
660
|
+
releaseLock = await acquireCheckpointLock(options.checkpointPath);
|
|
661
|
+
return await withMigrationRuntime(async () =>
|
|
662
|
+
runWithRequestContext(options.scope, async () => {
|
|
663
|
+
const backend = await getFirstPartyAnalyticsBackend(options.scope);
|
|
664
|
+
if (backend.sink !== "dual") {
|
|
665
|
+
throw new Error(
|
|
666
|
+
`Refusing to finalize while the source sink is ${backend.sink}; prepare must establish dual-write first`,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
const configuredTable = options.table ?? backend.table;
|
|
670
|
+
const finalizeOptions = { ...options, table: configuredTable };
|
|
671
|
+
const checkpoint = await new JsonCheckpointStore(
|
|
672
|
+
options.checkpointPath,
|
|
673
|
+
).load();
|
|
674
|
+
if (!checkpoint) {
|
|
675
|
+
throw new Error(
|
|
676
|
+
`Backfill checkpoint ${options.checkpointPath} does not exist`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
assertCheckpointMatches(checkpoint, finalizeOptions);
|
|
680
|
+
if (
|
|
681
|
+
!checkpoint.branches.org.complete ||
|
|
682
|
+
!checkpoint.branches.legacy.complete
|
|
683
|
+
) {
|
|
684
|
+
throw new Error(
|
|
685
|
+
"Refusing to finalize before both organization and legacy-owner branches are complete",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const job = await getFirstPartyAnalyticsBigQueryBackfillJob(
|
|
690
|
+
options.scope,
|
|
691
|
+
);
|
|
692
|
+
if (!job) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
"Prepare the organization before reconciling the dedicated backfill checkpoint",
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
if (job.table !== checkpoint.table) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`Durable backfill targets ${job.table}, checkpoint targets ${checkpoint.table}`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (job.status === "completed") {
|
|
703
|
+
return {
|
|
704
|
+
mode: "finalize",
|
|
705
|
+
copied:
|
|
706
|
+
checkpoint.branches.org.copied +
|
|
707
|
+
checkpoint.branches.legacy.copied,
|
|
708
|
+
cursor: latestCursor(checkpoint),
|
|
709
|
+
jobId: job.id,
|
|
710
|
+
status: "completed",
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (
|
|
714
|
+
job.status === "running" &&
|
|
715
|
+
(!job.leaseExpiresAt || Date.parse(job.leaseExpiresAt) > Date.now())
|
|
716
|
+
) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
"The shipped backfill worker currently owns the durable job lease; wait for it to stop before finalizing",
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
await assertSourceAtCheckpoint(options.scope, checkpoint);
|
|
723
|
+
|
|
724
|
+
const cursor = latestCursor(checkpoint);
|
|
725
|
+
const now = new Date().toISOString();
|
|
726
|
+
const update = await getDbExec().execute({
|
|
727
|
+
sql: `UPDATE ${JOB_TABLE}
|
|
728
|
+
SET status = 'completed', backfill_cursor = ?,
|
|
729
|
+
copied_count = ?, lease_token = NULL,
|
|
730
|
+
lease_expires_at = NULL, next_run_at = ?,
|
|
731
|
+
last_error = NULL, completed_at = ?, updated_at = ?
|
|
732
|
+
WHERE id = ? AND table_ref = ?
|
|
733
|
+
AND (status = 'pending'
|
|
734
|
+
OR (status = 'running' AND lease_expires_at IS NOT NULL
|
|
735
|
+
AND lease_expires_at <= ?))`,
|
|
736
|
+
args: [
|
|
737
|
+
cursor ? JSON.stringify(cursor) : null,
|
|
738
|
+
Math.max(
|
|
739
|
+
job.copied,
|
|
740
|
+
checkpoint.branches.org.copied +
|
|
741
|
+
checkpoint.branches.legacy.copied,
|
|
742
|
+
),
|
|
743
|
+
now,
|
|
744
|
+
now,
|
|
745
|
+
now,
|
|
746
|
+
job.id,
|
|
747
|
+
checkpoint.table,
|
|
748
|
+
now,
|
|
749
|
+
],
|
|
750
|
+
timeoutMs: 5_000,
|
|
751
|
+
maxAttempts: 1,
|
|
752
|
+
});
|
|
753
|
+
if (update.rowsAffected !== 1) {
|
|
754
|
+
throw new Error(
|
|
755
|
+
"The durable backfill job changed while it was being finalized; inspect status before retrying",
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
await saveFirstPartyAnalyticsBackend(options.scope, {
|
|
759
|
+
...backend,
|
|
760
|
+
table: checkpoint.table,
|
|
761
|
+
backfillCursor: cursor ? JSON.stringify(cursor) : null,
|
|
762
|
+
backfillCompleted: true,
|
|
763
|
+
});
|
|
764
|
+
return {
|
|
765
|
+
mode: "finalize",
|
|
766
|
+
copied:
|
|
767
|
+
checkpoint.branches.org.copied + checkpoint.branches.legacy.copied,
|
|
768
|
+
cursor,
|
|
769
|
+
jobId: job.id,
|
|
770
|
+
status: "completed",
|
|
771
|
+
};
|
|
772
|
+
}),
|
|
773
|
+
);
|
|
774
|
+
} finally {
|
|
775
|
+
if (releaseLock) {
|
|
776
|
+
try {
|
|
777
|
+
await closeDbExec();
|
|
778
|
+
} finally {
|
|
779
|
+
await releaseLock();
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function main(): Promise<void> {
|
|
786
|
+
const args = parseArgs();
|
|
787
|
+
if (args.help) {
|
|
788
|
+
console.log(USAGE);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const options = parseDedicatedBackfillOptions(args);
|
|
792
|
+
if (options.execute && options.finalize) {
|
|
793
|
+
throw new Error("Choose either --execute or --finalize, not both");
|
|
794
|
+
}
|
|
795
|
+
if (!options.execute && !options.finalize) {
|
|
796
|
+
output({
|
|
797
|
+
mode: "dry-run",
|
|
798
|
+
batchSize: options.batchSize,
|
|
799
|
+
concurrency: options.concurrency,
|
|
800
|
+
maxBatches: options.maxBatches,
|
|
801
|
+
checkpointPath: options.checkpointPath,
|
|
802
|
+
message:
|
|
803
|
+
"No data was copied. Pass --execute and AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 after production approval.",
|
|
804
|
+
});
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (options.finalize) {
|
|
808
|
+
if (process.env[FINALIZE_ALLOW_ENV] !== "1") {
|
|
809
|
+
throw new Error(
|
|
810
|
+
`Checkpoint finalization is disabled. Set ${FINALIZE_ALLOW_ENV}=1 only after explicit production approval.`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (process.env[FINALIZE_QUIESCENT_ENV] !== "1") {
|
|
814
|
+
throw new Error(
|
|
815
|
+
`Checkpoint finalization requires source event writes to be quiesced. Set ${FINALIZE_QUIESCENT_ENV}=1 only after the final high-water check and explicit production approval.`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
output(await runApprovedFinalize(options));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (process.env.AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW !== "1") {
|
|
822
|
+
throw new Error(
|
|
823
|
+
"Production execution is disabled. Set AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 only after explicit approval.",
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
output(await runApprovedWorker(options));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (
|
|
830
|
+
process.argv[1] &&
|
|
831
|
+
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
832
|
+
) {
|
|
833
|
+
try {
|
|
834
|
+
await main();
|
|
835
|
+
} catch (error) {
|
|
836
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
837
|
+
process.exitCode = 1;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
|
|
11
11
|
const JOB_TABLE = "analytics_bigquery_backfill_jobs";
|
|
12
12
|
const LEASE_MS = 5 * 60 * 1000;
|
|
13
|
+
const DEDICATED_LEASE_MS = 24 * 60 * 60 * 1000;
|
|
13
14
|
const ERROR_RETRY_MS = 5 * 60 * 1000;
|
|
14
15
|
const DEFAULT_BATCH_SIZE = 250;
|
|
15
16
|
const MAX_BATCH_SIZE = 750;
|
|
@@ -191,6 +192,75 @@ export async function getFirstPartyAnalyticsBigQueryBackfillJob(
|
|
|
191
192
|
return row ? rowToJob(row) : null;
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
export async function acquireDedicatedFirstPartyAnalyticsBackfillLease(
|
|
196
|
+
scope: FirstPartyAnalyticsScope,
|
|
197
|
+
table: string,
|
|
198
|
+
): Promise<() => Promise<void>> {
|
|
199
|
+
if (!scope.orgId) {
|
|
200
|
+
throw new Error("Dedicated BigQuery backfill requires an organization");
|
|
201
|
+
}
|
|
202
|
+
const id = jobId(scope.orgId);
|
|
203
|
+
const token = randomUUID();
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
const leaseExpiresAt = new Date(
|
|
206
|
+
Date.now() + DEDICATED_LEASE_MS,
|
|
207
|
+
).toISOString();
|
|
208
|
+
const claimed = await executor().execute({
|
|
209
|
+
sql: `UPDATE ${JOB_TABLE}
|
|
210
|
+
SET status = 'running', lease_token = ?, lease_expires_at = ?,
|
|
211
|
+
updated_at = ?
|
|
212
|
+
WHERE id = ? AND table_ref = ?
|
|
213
|
+
AND (status = 'pending'
|
|
214
|
+
OR (status = 'running' AND lease_expires_at IS NOT NULL
|
|
215
|
+
AND lease_expires_at <= ?))`,
|
|
216
|
+
args: [token, leaseExpiresAt, now, id, table, now],
|
|
217
|
+
timeoutMs: 5_000,
|
|
218
|
+
maxAttempts: 1,
|
|
219
|
+
});
|
|
220
|
+
if (claimed.rowsAffected !== 1) {
|
|
221
|
+
const job = await getFirstPartyAnalyticsBigQueryBackfillJob(scope);
|
|
222
|
+
if (!job) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
"Prepare the organization before starting the dedicated BigQuery backfill",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (job.table !== table) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`BigQuery backfill already targets ${job.table}; the dedicated worker requested ${table}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (job.status === "completed") {
|
|
233
|
+
throw new Error(
|
|
234
|
+
"The durable BigQuery backfill is already completed; do not run the dedicated worker again",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
throw new Error(
|
|
238
|
+
"Another BigQuery backfill worker owns the durable lease; wait for it to stop before retrying",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let released = false;
|
|
243
|
+
return async () => {
|
|
244
|
+
if (released) return;
|
|
245
|
+
const releasedAt = new Date().toISOString();
|
|
246
|
+
const result = await executor().execute({
|
|
247
|
+
sql: `UPDATE ${JOB_TABLE}
|
|
248
|
+
SET status = 'pending', lease_token = NULL,
|
|
249
|
+
lease_expires_at = NULL, next_run_at = ?, updated_at = ?
|
|
250
|
+
WHERE id = ? AND lease_token = ?`,
|
|
251
|
+
args: [leaseExpiresAt, releasedAt, id, token],
|
|
252
|
+
timeoutMs: 5_000,
|
|
253
|
+
maxAttempts: 1,
|
|
254
|
+
});
|
|
255
|
+
if (result.rowsAffected !== 1) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
"The dedicated BigQuery backfill lost its durable lease before release",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
released = true;
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
194
264
|
export async function queueFirstPartyAnalyticsBigQueryBackfill(
|
|
195
265
|
scope: FirstPartyAnalyticsScope,
|
|
196
266
|
table: string,
|
|
@@ -75,6 +75,16 @@ const FIRST_PARTY_QUERY_TABLES = [
|
|
|
75
75
|
const BACKEND_CONFIG_CACHE_TTL_MS = 30_000;
|
|
76
76
|
const MAX_BACKFILL_BATCH_SIZE = 750;
|
|
77
77
|
const MAX_INSERT_BATCH_SIZE = 200;
|
|
78
|
+
const MAX_DEDICATED_INSERT_BATCH_SIZE = 500;
|
|
79
|
+
const MAX_DEDICATED_INSERT_CONCURRENCY = 4;
|
|
80
|
+
const MAX_INSERT_REQUEST_BYTES = 8 * 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
export interface FirstPartyAnalyticsInsertOptions {
|
|
83
|
+
/** Maximum rows in one BigQuery insertAll request. */
|
|
84
|
+
maxRowsPerRequest?: number;
|
|
85
|
+
/** Maximum insertAll requests in flight for a dedicated backfill worker. */
|
|
86
|
+
maxConcurrentRequests?: number;
|
|
87
|
+
}
|
|
78
88
|
|
|
79
89
|
const backendConfigCache = new Map<
|
|
80
90
|
string,
|
|
@@ -224,7 +234,7 @@ const FIRST_PARTY_ANALYTICS_RAW_SCHEMA = [
|
|
|
224
234
|
["org_id", "STRING"],
|
|
225
235
|
] as const;
|
|
226
236
|
|
|
227
|
-
const FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS = [
|
|
237
|
+
export const FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS = [
|
|
228
238
|
"id",
|
|
229
239
|
"public_key_id",
|
|
230
240
|
"event_name",
|
|
@@ -352,9 +362,114 @@ async function insertBatch(
|
|
|
352
362
|
}
|
|
353
363
|
}
|
|
354
364
|
|
|
365
|
+
function boundedInsertOption(
|
|
366
|
+
value: number | undefined,
|
|
367
|
+
fallback: number,
|
|
368
|
+
maximum: number,
|
|
369
|
+
name: string,
|
|
370
|
+
): number {
|
|
371
|
+
if (value === undefined) return fallback;
|
|
372
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
373
|
+
throw new Error(`${name} must be a positive integer`);
|
|
374
|
+
}
|
|
375
|
+
return Math.min(value, maximum);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function insertPayloadRows(
|
|
379
|
+
table: BigQueryTableRef,
|
|
380
|
+
token: string,
|
|
381
|
+
payloadRows: Record<string, unknown>[],
|
|
382
|
+
options: FirstPartyAnalyticsInsertOptions = {},
|
|
383
|
+
): Promise<void> {
|
|
384
|
+
const maxRowsPerRequest = boundedInsertOption(
|
|
385
|
+
options.maxRowsPerRequest,
|
|
386
|
+
MAX_INSERT_BATCH_SIZE,
|
|
387
|
+
MAX_DEDICATED_INSERT_BATCH_SIZE,
|
|
388
|
+
"maxRowsPerRequest",
|
|
389
|
+
);
|
|
390
|
+
const maxConcurrentRequests = boundedInsertOption(
|
|
391
|
+
options.maxConcurrentRequests,
|
|
392
|
+
1,
|
|
393
|
+
MAX_DEDICATED_INSERT_CONCURRENCY,
|
|
394
|
+
"maxConcurrentRequests",
|
|
395
|
+
);
|
|
396
|
+
const batches: Record<string, unknown>[][] = [];
|
|
397
|
+
const encoder = new TextEncoder();
|
|
398
|
+
let currentBatch: Record<string, unknown>[] = [];
|
|
399
|
+
let currentBytes = 2;
|
|
400
|
+
for (const row of payloadRows) {
|
|
401
|
+
const rowBytes = encoder.encode(
|
|
402
|
+
JSON.stringify({
|
|
403
|
+
insertId: typeof row.id === "string" ? row.id : undefined,
|
|
404
|
+
json: row,
|
|
405
|
+
}),
|
|
406
|
+
).byteLength;
|
|
407
|
+
if (rowBytes + 2 > MAX_INSERT_REQUEST_BYTES) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
"A first-party Analytics row exceeds the BigQuery request limit",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (
|
|
413
|
+
currentBatch.length > 0 &&
|
|
414
|
+
(currentBatch.length >= maxRowsPerRequest ||
|
|
415
|
+
currentBytes + rowBytes + 1 > MAX_INSERT_REQUEST_BYTES)
|
|
416
|
+
) {
|
|
417
|
+
batches.push(currentBatch);
|
|
418
|
+
currentBatch = [];
|
|
419
|
+
currentBytes = 2;
|
|
420
|
+
}
|
|
421
|
+
currentBatch.push(row);
|
|
422
|
+
currentBytes += rowBytes + 1;
|
|
423
|
+
}
|
|
424
|
+
if (currentBatch.length > 0) batches.push(currentBatch);
|
|
425
|
+
|
|
426
|
+
let nextBatch = 0;
|
|
427
|
+
const worker = async (): Promise<void> => {
|
|
428
|
+
while (true) {
|
|
429
|
+
const batchIndex = nextBatch;
|
|
430
|
+
nextBatch += 1;
|
|
431
|
+
const batch = batches[batchIndex];
|
|
432
|
+
if (!batch) return;
|
|
433
|
+
await insertBatch(table, token, batch);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
await Promise.all(
|
|
438
|
+
Array.from(
|
|
439
|
+
{ length: Math.min(maxConcurrentRequests, batches.length) },
|
|
440
|
+
() => worker(),
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Create a long-lived inserter for a dedicated backfill process. The table is
|
|
447
|
+
* resolved once, while the token is refreshed through the existing scoped
|
|
448
|
+
* resolver for every page so a multi-hour run does not use an expired token.
|
|
449
|
+
*/
|
|
450
|
+
export async function createFirstPartyAnalyticsInserter(
|
|
451
|
+
configuredTable?: string | null,
|
|
452
|
+
options: FirstPartyAnalyticsInsertOptions = {},
|
|
453
|
+
): Promise<
|
|
454
|
+
(
|
|
455
|
+
rows: Array<FirstPartyAnalyticsEventRow | Record<string, unknown>>,
|
|
456
|
+
) => Promise<number>
|
|
457
|
+
> {
|
|
458
|
+
requireRequestCredentialContext("GOOGLE_APPLICATION_CREDENTIALS_JSON");
|
|
459
|
+
const table = await getFirstPartyAnalyticsTable(configuredTable);
|
|
460
|
+
return async (rows) => {
|
|
461
|
+
if (!rows.length) return 0;
|
|
462
|
+
const token = await getAccessToken();
|
|
463
|
+
const payloadRows = rows.map(firstPartyEventRowToBigQuery);
|
|
464
|
+
await insertPayloadRows(table, token, payloadRows, options);
|
|
465
|
+
return payloadRows.length;
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
355
469
|
export async function insertFirstPartyAnalyticsRows(
|
|
356
470
|
rows: Array<FirstPartyAnalyticsEventRow | Record<string, unknown>>,
|
|
357
471
|
configuredTable?: string | null,
|
|
472
|
+
options: FirstPartyAnalyticsInsertOptions = {},
|
|
358
473
|
): Promise<number> {
|
|
359
474
|
if (!rows.length) return 0;
|
|
360
475
|
requireRequestCredentialContext("GOOGLE_APPLICATION_CREDENTIALS_JSON");
|
|
@@ -363,17 +478,7 @@ export async function insertFirstPartyAnalyticsRows(
|
|
|
363
478
|
getAccessToken(),
|
|
364
479
|
]);
|
|
365
480
|
const payloadRows = rows.map(firstPartyEventRowToBigQuery);
|
|
366
|
-
|
|
367
|
-
let offset = 0;
|
|
368
|
-
offset < payloadRows.length;
|
|
369
|
-
offset += MAX_INSERT_BATCH_SIZE
|
|
370
|
-
) {
|
|
371
|
-
await insertBatch(
|
|
372
|
-
table,
|
|
373
|
-
token,
|
|
374
|
-
payloadRows.slice(offset, offset + MAX_INSERT_BATCH_SIZE),
|
|
375
|
-
);
|
|
376
|
-
}
|
|
481
|
+
await insertPayloadRows(table, token, payloadRows, options);
|
|
377
482
|
return payloadRows.length;
|
|
378
483
|
}
|
|
379
484
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build]
|
|
2
|
-
command = "NITRO_PRESET=netlify pnpm --filter factory build && if [ \"${CONTEXT:-}\" = \"production\" ]; then pnpm --filter factory migrate:production; fi"
|
|
2
|
+
command = "export DATABASE_URL=\"${NETLIFY_DATABASE_URL:-$DATABASE_URL}\" && pnpm install && DATABASE_URL=\"${NETLIFY_DATABASE_URL_UNPOOLED:-$DATABASE_URL}\" NITRO_PRESET=netlify pnpm --filter factory build && if [ \"${CONTEXT:-}\" = \"production\" ]; then DATABASE_URL=\"${NETLIFY_DATABASE_URL_UNPOOLED:-$DATABASE_URL}\" pnpm --filter factory migrate:production; fi"
|
|
3
3
|
publish = "dist"
|
|
4
4
|
functions = ".netlify/functions-internal"
|
|
5
5
|
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
ok?: undefined;
|
|
17
16
|
error: string;
|
|
17
|
+
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
|
@@ -41,27 +41,27 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
|
|
|
41
41
|
thumbsUpRate: number;
|
|
42
42
|
avgEvalScore: number;
|
|
43
43
|
} | {
|
|
44
|
-
error?: undefined;
|
|
45
|
-
ok?: undefined;
|
|
46
44
|
summary: import("./types.js").TraceSummary;
|
|
47
45
|
spans: import("./types.js").TraceSpan[];
|
|
48
46
|
id?: undefined;
|
|
49
|
-
} | {
|
|
50
47
|
error?: undefined;
|
|
51
48
|
ok?: undefined;
|
|
49
|
+
} | {
|
|
52
50
|
summary?: undefined;
|
|
53
51
|
spans?: undefined;
|
|
54
52
|
id: string;
|
|
55
|
-
|
|
53
|
+
error?: undefined;
|
|
56
54
|
ok?: undefined;
|
|
55
|
+
} | {
|
|
57
56
|
summary?: undefined;
|
|
58
57
|
spans?: undefined;
|
|
59
58
|
id?: undefined;
|
|
60
59
|
error: any;
|
|
60
|
+
ok?: undefined;
|
|
61
61
|
} | {
|
|
62
|
-
error?: undefined;
|
|
63
62
|
summary?: undefined;
|
|
64
63
|
spans?: undefined;
|
|
65
64
|
id?: undefined;
|
|
66
65
|
ok: boolean;
|
|
66
|
+
error?: undefined;
|
|
67
67
|
}>>;
|
|
@@ -48,8 +48,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
|
|
|
48
48
|
}>;
|
|
49
49
|
/** DELETE /_agent-native/resources/:id — delete a resource */
|
|
50
50
|
export declare function handleDeleteResource(event: any): Promise<{
|
|
51
|
-
error: string;
|
|
52
51
|
ok?: undefined;
|
|
52
|
+
error: string;
|
|
53
53
|
} | {
|
|
54
54
|
error?: undefined;
|
|
55
55
|
ok: boolean;
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -34,16 +34,16 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
|
|
|
34
34
|
/** POST /_agent-native/secrets/:key — write a secret. */
|
|
35
35
|
export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
36
36
|
error: string;
|
|
37
|
-
status?: undefined;
|
|
38
37
|
ok?: undefined;
|
|
38
|
+
status?: undefined;
|
|
39
39
|
} | {
|
|
40
40
|
ok: boolean;
|
|
41
41
|
status: string;
|
|
42
42
|
error?: undefined;
|
|
43
43
|
} | {
|
|
44
|
+
ok?: undefined;
|
|
44
45
|
error: string;
|
|
45
46
|
removed?: undefined;
|
|
46
|
-
ok?: undefined;
|
|
47
47
|
} | {
|
|
48
48
|
ok: boolean;
|
|
49
49
|
removed: boolean;
|
|
@@ -54,9 +54,9 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
|
|
|
54
54
|
* or the current stored value without changing anything.
|
|
55
55
|
*/
|
|
56
56
|
export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
57
|
+
ok?: undefined;
|
|
57
58
|
error: string;
|
|
58
59
|
note?: undefined;
|
|
59
|
-
ok?: undefined;
|
|
60
60
|
} | {
|
|
61
61
|
ok: boolean;
|
|
62
62
|
note?: undefined;
|
|
@@ -27,10 +27,10 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
|
|
|
27
27
|
export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
28
28
|
error: any;
|
|
29
29
|
} | {
|
|
30
|
+
error?: undefined;
|
|
30
31
|
ok: boolean;
|
|
31
32
|
key: string;
|
|
32
33
|
baseUrlKey?: string;
|
|
33
34
|
scope: AgentEngineApiKeyScope;
|
|
34
|
-
error?: undefined;
|
|
35
35
|
}>>;
|
|
36
36
|
export {};
|
|
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
|
|
|
26
26
|
expiresAt?: undefined;
|
|
27
27
|
ttlSeconds?: undefined;
|
|
28
28
|
} | {
|
|
29
|
-
error?: undefined;
|
|
30
29
|
token: string;
|
|
31
30
|
expiresAt: string;
|
|
32
31
|
ttlSeconds: number;
|
|
32
|
+
error?: undefined;
|
|
33
33
|
}>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.146.
|
|
3
|
+
"version": "0.146.8",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -412,8 +412,8 @@
|
|
|
412
412
|
"y-protocols": "^1.0.7",
|
|
413
413
|
"yjs": "^13.6.31",
|
|
414
414
|
"zod": "^4.3.6",
|
|
415
|
-
"@agent-native/
|
|
416
|
-
"@agent-native/
|
|
415
|
+
"@agent-native/recap-cli": "0.5.3",
|
|
416
|
+
"@agent-native/toolkit": "^0.13.5"
|
|
417
417
|
},
|
|
418
418
|
"devDependencies": {
|
|
419
419
|
"@ai-sdk/anthropic": "^3.0.71",
|