@adcp/sdk 15.9.0 → 15.9.1

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.
@@ -11,11 +11,16 @@ import type { PostgresReportingLedgerStore, ReportingLedgerRevisionMetadataV1, R
11
11
  * are the adopter's.
12
12
  *
13
13
  * Exactly-once loading: a batch (change-feed range, revision list, job IDs) is
14
- * planned durably in PostgreSQL before any load. A crash between the load and
15
- * the cursor commit replays the same batch under the same job IDs, which
16
- * BigQuery refuses to run twice. A failed load job is retried under a new
17
- * attempt suffix only after BigQuery reports that job finished with an error,
18
- * and each table keeps its own attempt count, so no batch lands twice.
14
+ * planned durably in PostgreSQL before any load. Before every submission the
15
+ * sink looks the deterministic job ID up in the destination project and
16
+ * location: a job that finished cleanly counts as loaded, a job still running
17
+ * is waited for (never uploaded again), and only a missing job is uploaded.
18
+ * A crash or timeout between the load and the cursor commit therefore replays
19
+ * the same batch under the same job IDs without a second upload. A failed load
20
+ * job is retried under a new attempt suffix only after BigQuery reports that
21
+ * job finished with an error, and each table keeps its own attempt count, so
22
+ * no batch lands twice. A load that outlasts the deadline never changes the job
23
+ * ID: the sink grows that table's deadline instead (persisted with the plan).
19
24
  * Concurrent runs claim one durable plan with compare-and-swap. Replays of
20
25
  * that plan share job IDs; stale runs cannot change its attempts or cursor.
21
26
  */
@@ -36,7 +41,7 @@ export interface ReportingBigQueryClientV1 {
36
41
  location?: string;
37
42
  }): {
38
43
  getMetadata(): Promise<unknown>;
39
- /** Request cancellation. Optional so wrappers written before the sink used it still type-check. */
44
+ /** @deprecated Unused: BigQuery does not stop a running load, so the sink never cancels. Kept so wrappers that declare it still type-check. */
40
45
  cancel?(): Promise<unknown>;
41
46
  };
42
47
  query(options: {
@@ -74,7 +79,11 @@ export interface CreateBigQueryReportingWarehouseSinkOptionsV1 {
74
79
  datasetId: string;
75
80
  rowsTableId: string;
76
81
  revisionsTableId: string;
77
- /** Dataset location, so jobs run in the dataset's region. */
82
+ /**
83
+ * Dataset location, so jobs run in the dataset's region. Set it for any dataset outside the US and EU
84
+ * multi-regions: BigQuery answers a job lookup without the right location with 404, and the sink then
85
+ * cannot find the jobs it created.
86
+ */
78
87
  location?: string;
79
88
  /** Restrict the sink to one account; omit for the deployment-wide feed. */
80
89
  account_id?: string;
@@ -98,18 +107,24 @@ export interface CreateBigQueryReportingWarehouseSinkOptionsV1 {
98
107
  */
99
108
  labels?: Readonly<Record<string, string>>;
100
109
  /**
101
- * Deadline for one load (and for each status check). Default 10 minutes.
102
- * When a load exceeds it, the sink asks BigQuery to cancel that job by its
103
- * deterministic ID (in `projectId` and `location`) and rejects, leaving the
104
- * plan in place. The next run finds the cancelled job finished with an
105
- * error, advances that table's attempt and rejects; the run after that
106
- * loads under the new attempt. If the cancel has not taken effect yet the
107
- * next run waits for the job. Each attempt doubles the load deadline (up to
108
- * 6 hours), so a load that is slow but healthy converges; set this above
109
- * your slowest expected load to avoid the cancelled attempts. A status
110
- * check that times out is not cancelled. This is a client-side deadline:
111
- * BigQuery's server-side `jobTimeoutMs` cannot be set on a local-file load
112
- * through the official client, and cancellation is best effort.
110
+ * Base deadline for one run's work on one load. Default 10 minutes. It bounds
111
+ * how long a run uploads a file or waits for a running job; it does not bound
112
+ * the job. BigQuery does not stop a load when the client gives up (a cancel
113
+ * is accepted and the job finishes anyway), so the sink never cancels.
114
+ * Before every submission it looks the deterministic job ID up in `projectId`
115
+ * and `location`: a job that finished cleanly is accepted, a job that is
116
+ * pending or running is waited for (polled within the deadline) instead of
117
+ * being uploaded again, and only a missing job is uploaded. When the
118
+ * deadline passes, the run rejects with the plan retained and nothing
119
+ * advanced, and the next run checks the job again. Each such timeout doubles
120
+ * that table's deadline for later runs (up to 6 hours, persisted with the
121
+ * plan, and never changing the job ID), so an upload slower than the
122
+ * deadline eventually gets enough time. Set it above your slowest expected
123
+ * load to avoid the extra runs. Only a job that finished with an error
124
+ * advances the attempt. A status check that times out is bounded by this base
125
+ * deadline and does not grow it. This is a client-side deadline: BigQuery's
126
+ * server-side `jobTimeoutMs` cannot be set on a local-file load through the
127
+ * official client.
113
128
  */
114
129
  loadTimeoutMilliseconds?: number;
115
130
  }
@@ -153,7 +168,8 @@ export interface BigQueryReportingWarehouseSinkV1 {
153
168
  * the destination tables first, or use a new sink name against new tables.
154
169
  * Each reset starts a new job ID generation, so reloaded batches submit
155
170
  * fresh BigQuery jobs instead of matching the finished jobs of the earlier
156
- * history. Nothing holds change retention between `reset()` and the first
171
+ * history. The sink never cancels a load, so stop every worker and let its
172
+ * in-flight uploads and jobs finish before emptying the destination tables. Nothing holds change retention between `reset()` and the first
157
173
  * committed batch. If `reset()` rejects after clearing the sink's state
158
174
  * (the consumer could not be unregistered), call it again before restarting
159
175
  * workers; it is idempotent. Needs a store with `removeFeedConsumer`.
@@ -170,6 +186,11 @@ export interface BigQueryReportingWarehouseSinkV1 {
170
186
  readonly currentRowsViewSql: string;
171
187
  }
172
188
  export declare function createBigQueryReportingWarehouseSinkV1(options: CreateBigQueryReportingWarehouseSinkOptionsV1): BigQueryReportingWarehouseSinkV1;
189
+ /**
190
+ * How long one run may upload a file or wait for a running job: the configured
191
+ * base doubled once per earlier timeout of that table's load, capped at 6 hours.
192
+ */
193
+ export declare function reportingWarehouseLoadDeadlineV1(baseMilliseconds: number, timeouts: number): number;
173
194
  /** Deterministic load job ID for one planned change-feed range. */
174
195
  export declare function reportingWarehouseJobIdV1(name: string, from: string | null, to: string, generation?: number): string;
175
196
  interface WarehouseTablesV1 {
@@ -11,11 +11,16 @@ import type { PostgresReportingLedgerStore, ReportingLedgerRevisionMetadataV1, R
11
11
  * are the adopter's.
12
12
  *
13
13
  * Exactly-once loading: a batch (change-feed range, revision list, job IDs) is
14
- * planned durably in PostgreSQL before any load. A crash between the load and
15
- * the cursor commit replays the same batch under the same job IDs, which
16
- * BigQuery refuses to run twice. A failed load job is retried under a new
17
- * attempt suffix only after BigQuery reports that job finished with an error,
18
- * and each table keeps its own attempt count, so no batch lands twice.
14
+ * planned durably in PostgreSQL before any load. Before every submission the
15
+ * sink looks the deterministic job ID up in the destination project and
16
+ * location: a job that finished cleanly counts as loaded, a job still running
17
+ * is waited for (never uploaded again), and only a missing job is uploaded.
18
+ * A crash or timeout between the load and the cursor commit therefore replays
19
+ * the same batch under the same job IDs without a second upload. A failed load
20
+ * job is retried under a new attempt suffix only after BigQuery reports that
21
+ * job finished with an error, and each table keeps its own attempt count, so
22
+ * no batch lands twice. A load that outlasts the deadline never changes the job
23
+ * ID: the sink grows that table's deadline instead (persisted with the plan).
19
24
  * Concurrent runs claim one durable plan with compare-and-swap. Replays of
20
25
  * that plan share job IDs; stale runs cannot change its attempts or cursor.
21
26
  */
@@ -36,7 +41,7 @@ export interface ReportingBigQueryClientV1 {
36
41
  location?: string;
37
42
  }): {
38
43
  getMetadata(): Promise<unknown>;
39
- /** Request cancellation. Optional so wrappers written before the sink used it still type-check. */
44
+ /** @deprecated Unused: BigQuery does not stop a running load, so the sink never cancels. Kept so wrappers that declare it still type-check. */
40
45
  cancel?(): Promise<unknown>;
41
46
  };
42
47
  query(options: {
@@ -74,7 +79,11 @@ export interface CreateBigQueryReportingWarehouseSinkOptionsV1 {
74
79
  datasetId: string;
75
80
  rowsTableId: string;
76
81
  revisionsTableId: string;
77
- /** Dataset location, so jobs run in the dataset's region. */
82
+ /**
83
+ * Dataset location, so jobs run in the dataset's region. Set it for any dataset outside the US and EU
84
+ * multi-regions: BigQuery answers a job lookup without the right location with 404, and the sink then
85
+ * cannot find the jobs it created.
86
+ */
78
87
  location?: string;
79
88
  /** Restrict the sink to one account; omit for the deployment-wide feed. */
80
89
  account_id?: string;
@@ -98,18 +107,24 @@ export interface CreateBigQueryReportingWarehouseSinkOptionsV1 {
98
107
  */
99
108
  labels?: Readonly<Record<string, string>>;
100
109
  /**
101
- * Deadline for one load (and for each status check). Default 10 minutes.
102
- * When a load exceeds it, the sink asks BigQuery to cancel that job by its
103
- * deterministic ID (in `projectId` and `location`) and rejects, leaving the
104
- * plan in place. The next run finds the cancelled job finished with an
105
- * error, advances that table's attempt and rejects; the run after that
106
- * loads under the new attempt. If the cancel has not taken effect yet the
107
- * next run waits for the job. Each attempt doubles the load deadline (up to
108
- * 6 hours), so a load that is slow but healthy converges; set this above
109
- * your slowest expected load to avoid the cancelled attempts. A status
110
- * check that times out is not cancelled. This is a client-side deadline:
111
- * BigQuery's server-side `jobTimeoutMs` cannot be set on a local-file load
112
- * through the official client, and cancellation is best effort.
110
+ * Base deadline for one run's work on one load. Default 10 minutes. It bounds
111
+ * how long a run uploads a file or waits for a running job; it does not bound
112
+ * the job. BigQuery does not stop a load when the client gives up (a cancel
113
+ * is accepted and the job finishes anyway), so the sink never cancels.
114
+ * Before every submission it looks the deterministic job ID up in `projectId`
115
+ * and `location`: a job that finished cleanly is accepted, a job that is
116
+ * pending or running is waited for (polled within the deadline) instead of
117
+ * being uploaded again, and only a missing job is uploaded. When the
118
+ * deadline passes, the run rejects with the plan retained and nothing
119
+ * advanced, and the next run checks the job again. Each such timeout doubles
120
+ * that table's deadline for later runs (up to 6 hours, persisted with the
121
+ * plan, and never changing the job ID), so an upload slower than the
122
+ * deadline eventually gets enough time. Set it above your slowest expected
123
+ * load to avoid the extra runs. Only a job that finished with an error
124
+ * advances the attempt. A status check that times out is bounded by this base
125
+ * deadline and does not grow it. This is a client-side deadline: BigQuery's
126
+ * server-side `jobTimeoutMs` cannot be set on a local-file load through the
127
+ * official client.
113
128
  */
114
129
  loadTimeoutMilliseconds?: number;
115
130
  }
@@ -153,7 +168,8 @@ export interface BigQueryReportingWarehouseSinkV1 {
153
168
  * the destination tables first, or use a new sink name against new tables.
154
169
  * Each reset starts a new job ID generation, so reloaded batches submit
155
170
  * fresh BigQuery jobs instead of matching the finished jobs of the earlier
156
- * history. Nothing holds change retention between `reset()` and the first
171
+ * history. The sink never cancels a load, so stop every worker and let its
172
+ * in-flight uploads and jobs finish before emptying the destination tables. Nothing holds change retention between `reset()` and the first
157
173
  * committed batch. If `reset()` rejects after clearing the sink's state
158
174
  * (the consumer could not be unregistered), call it again before restarting
159
175
  * workers; it is idempotent. Needs a store with `removeFeedConsumer`.
@@ -170,6 +186,11 @@ export interface BigQueryReportingWarehouseSinkV1 {
170
186
  readonly currentRowsViewSql: string;
171
187
  }
172
188
  export declare function createBigQueryReportingWarehouseSinkV1(options: CreateBigQueryReportingWarehouseSinkOptionsV1): BigQueryReportingWarehouseSinkV1;
189
+ /**
190
+ * How long one run may upload a file or wait for a running job: the configured
191
+ * base doubled once per earlier timeout of that table's load, capped at 6 hours.
192
+ */
193
+ export declare function reportingWarehouseLoadDeadlineV1(baseMilliseconds: number, timeouts: number): number;
173
194
  /** Deterministic load job ID for one planned change-feed range. */
174
195
  export declare function reportingWarehouseJobIdV1(name: string, from: string | null, to: string, generation?: number): string;
175
196
  interface WarehouseTablesV1 {
@@ -32,7 +32,8 @@ __export(bigquery_exports, {
32
32
  createBigQueryReportingWarehouseSinkV1: () => createBigQueryReportingWarehouseSinkV1,
33
33
  reportingWarehouseCurrentRowsViewSqlV1: () => reportingWarehouseCurrentRowsViewSqlV1,
34
34
  reportingWarehouseDefaultTablesSqlV1: () => reportingWarehouseDefaultTablesSqlV1,
35
- reportingWarehouseJobIdV1: () => reportingWarehouseJobIdV1
35
+ reportingWarehouseJobIdV1: () => reportingWarehouseJobIdV1,
36
+ reportingWarehouseLoadDeadlineV1: () => reportingWarehouseLoadDeadlineV1
36
37
  });
37
38
  module.exports = __toCommonJS(bigquery_exports);
38
39
  var import_crypto = require("crypto");
@@ -49,14 +50,21 @@ CREATE TABLE IF NOT EXISTS adcp_reporting_warehouse_sinks (
49
50
  planned_revisions JSONB,
50
51
  planned_rows_attempt INTEGER NOT NULL DEFAULT 0 CHECK (planned_rows_attempt >= 0),
51
52
  planned_revisions_attempt INTEGER NOT NULL DEFAULT 0 CHECK (planned_revisions_attempt >= 0),
53
+ planned_rows_timeouts INTEGER NOT NULL DEFAULT 0 CHECK (planned_rows_timeouts >= 0),
54
+ planned_revisions_timeouts INTEGER NOT NULL DEFAULT 0 CHECK (planned_revisions_timeouts >= 0),
52
55
  reset_generation INTEGER NOT NULL DEFAULT 0 CHECK (reset_generation >= 0),
53
56
  updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
54
57
  CHECK ((planned_to_cursor IS NULL) = (planned_revisions IS NULL))
55
58
  );
56
59
  ALTER TABLE adcp_reporting_warehouse_sinks
57
60
  ADD COLUMN IF NOT EXISTS reset_generation INTEGER NOT NULL DEFAULT 0 CHECK (reset_generation >= 0);
61
+ ALTER TABLE adcp_reporting_warehouse_sinks
62
+ ADD COLUMN IF NOT EXISTS planned_rows_timeouts INTEGER NOT NULL DEFAULT 0 CHECK (planned_rows_timeouts >= 0);
63
+ ALTER TABLE adcp_reporting_warehouse_sinks
64
+ ADD COLUMN IF NOT EXISTS planned_revisions_timeouts INTEGER NOT NULL DEFAULT 0 CHECK (planned_revisions_timeouts >= 0);
58
65
  `.trim();
59
66
  const MAX_LOAD_TIMEOUT = 6 * 36e5;
67
+ const MAX_TIMEOUT_DOUBLINGS = 16;
60
68
  let warnedLabelsIgnored = false;
61
69
  const PROJECT = /^[a-z][a-z0-9-]{4,61}[a-z0-9]$/;
62
70
  const DATASET_OR_TABLE = /^[A-Za-z_][A-Za-z0-9_]{0,1023}$/;
@@ -109,51 +117,104 @@ function createBigQueryReportingWarehouseSinkV1(options) {
109
117
  ...options.projectId ? { projectId: options.projectId } : {},
110
118
  ...options.location ? { location: options.location } : {}
111
119
  } : void 0;
112
- async function cancelLoad(jobId, signal) {
120
+ const uploads = /* @__PURE__ */ new Map();
121
+ function startUpload(tableId, jobId, lines) {
122
+ const entry = {
123
+ startedAt: Date.now(),
124
+ done: Promise.resolve()
125
+ };
126
+ const cleanup = async () => {
127
+ if (uploads.get(jobId) === entry) uploads.delete(jobId);
128
+ if (entry.directory) await import_fs.promises.rm(entry.directory, { recursive: true, force: true }).catch(() => {
129
+ });
130
+ };
131
+ entry.done = (async () => {
132
+ const directory = await import_fs.promises.mkdtemp(path.join((0, import_os.tmpdir)(), "adcp-warehouse-"));
133
+ entry.directory = directory;
134
+ const file = path.join(directory, `${jobId}.ndjson`);
135
+ await import_fs.promises.writeFile(file, lines.length ? lines.join("\n") + "\n" : "", { mode: 384 });
136
+ return dataset.table(tableId).load(file, {
137
+ jobId,
138
+ sourceFormat: "NEWLINE_DELIMITED_JSON",
139
+ writeDisposition: "WRITE_APPEND",
140
+ ...options.location ? { location: options.location } : {}
141
+ });
142
+ })();
143
+ const replaced = uploads.get(jobId);
144
+ uploads.set(jobId, entry);
145
+ void entry.done.then(cleanup, cleanup);
146
+ if (replaced?.directory) void import_fs.promises.rm(replaced.directory, { recursive: true, force: true }).catch(() => {
147
+ });
148
+ return entry.done;
149
+ }
150
+ async function jobStatus(jobId, signal, boundMilliseconds = loadTimeout, context) {
151
+ let metadata;
113
152
  try {
114
- const job = options.bigquery.job(jobId, jobOptions);
115
- if (typeof job.cancel !== "function") return;
116
- const cancel = job.cancel.bind(job);
117
- await withinDeadline(cancel, Math.min(loadTimeout, 3e4), signal);
118
- } catch (error) {
119
- const code = error?.code;
120
- if (code === 404 || signal?.aborted) return;
121
- process.emitWarning(
122
- `Cancelling BigQuery load job ${jobId} failed (${typeof code === "number" || typeof code === "string" ? code : "no status code"}); the job may still be running`,
123
- { code: "ADCP_BIGQUERY_SINK_CANCEL_FAILED" }
153
+ metadata = await withinDeadline(
154
+ () => options.bigquery.job(jobId, jobOptions).getMetadata(),
155
+ boundMilliseconds,
156
+ signal,
157
+ context
124
158
  );
159
+ } catch (error) {
160
+ if (error?.code === 404) return "missing";
161
+ throw error;
125
162
  }
163
+ const record = Array.isArray(metadata) ? metadata[0] : metadata;
164
+ if (record?.status?.state !== "DONE") return "running";
165
+ if (record.status.errorResult) {
166
+ const failure = new Error("A BigQuery load job for this batch failed; it will be retried as a new attempt");
167
+ Object.defineProperty(failure, "adcpJobFailed", { value: true });
168
+ throw failure;
169
+ }
170
+ return "done";
126
171
  }
127
- async function loadOnce(tableId, jobId, lines, attempt, signal) {
128
- const loadDeadline = Math.min(loadTimeout * 2 ** Math.min(attempt, 16), MAX_LOAD_TIMEOUT);
172
+ async function loadOnce(role, tableId, jobId, lines, timeouts, signal) {
129
173
  signal?.throwIfAborted();
130
- const directory = await import_fs.promises.mkdtemp(path.join((0, import_os.tmpdir)(), "adcp-warehouse-"));
131
- const file = path.join(directory, `${jobId}.ndjson`);
132
- try {
133
- await import_fs.promises.writeFile(file, lines.length ? lines.join("\n") + "\n" : "", { mode: 384 });
174
+ const table = `the ${role} table "${tableId}" (timeout count ${timeouts})`;
175
+ let status = await jobStatus(jobId, signal, loadTimeout, table);
176
+ if (status === "done") return;
177
+ const loadDeadline = reportingWarehouseLoadDeadlineV1(loadTimeout, timeouts);
178
+ const deadlineAt = Date.now() + loadDeadline;
179
+ const timedOut = (what) => {
180
+ const error = new Error(
181
+ `${what} exceeded this run's ${loadDeadline} ms deadline for ${table}; the next run checks the job again`
182
+ );
183
+ Object.defineProperty(error, "adcpDeadlineExceeded", { value: true });
184
+ Object.defineProperty(error, "adcpLoadTimedOut", { value: true });
185
+ return error;
186
+ };
187
+ if (status === "missing") {
134
188
  try {
135
- await withinDeadline(
136
- () => dataset.table(tableId).load(file, {
137
- jobId,
138
- sourceFormat: "NEWLINE_DELIMITED_JSON",
139
- writeDisposition: "WRITE_APPEND",
140
- ...options.location ? { location: options.location } : {}
141
- }),
142
- loadDeadline,
143
- signal
144
- );
189
+ const running = uploads.get(jobId);
190
+ const upload = running && Date.now() - running.startedAt < MAX_LOAD_TIMEOUT ? running.done : startUpload(tableId, jobId, lines);
191
+ await withinDeadline(() => upload, Math.max(deadlineAt - Date.now(), 1), signal);
192
+ return;
145
193
  } catch (error) {
146
- if (isDeadlineExceeded(error)) await cancelLoad(jobId, signal);
194
+ if (isDeadlineExceeded(error)) throw timedOut("Uploading the load");
147
195
  if (!isAlreadyExists(error)) throw error;
148
- const metadata = await withinDeadline(
149
- () => options.bigquery.job(jobId, jobOptions).getMetadata(),
150
- loadTimeout,
151
- signal
152
- );
153
- assertJobSucceeded(metadata);
196
+ status = await jobStatus(jobId, signal, loadTimeout, table);
197
+ if (status === "missing") {
198
+ throw new Error(
199
+ "BigQuery refused the load job ID as already existing, but the job cannot be found. Check that projectId and location match the dataset (location is required for datasets outside the US and EU multi-regions)",
200
+ { cause: error }
201
+ );
202
+ }
154
203
  }
155
- } finally {
156
- await import_fs.promises.rm(directory, { recursive: true, force: true });
204
+ }
205
+ const pollMilliseconds = Math.min(5e3, Math.max(250, Math.floor(loadDeadline / 10)));
206
+ while (status !== "done") {
207
+ const remaining = deadlineAt - Date.now();
208
+ if (remaining <= 0) throw timedOut("Waiting for the running load job");
209
+ await sleep(Math.min(pollMilliseconds, remaining), signal);
210
+ const bound = Math.max(Math.min(loadTimeout, deadlineAt - Date.now()), 1);
211
+ try {
212
+ status = await jobStatus(jobId, signal, bound, table);
213
+ } catch (error) {
214
+ if (isDeadlineExceeded(error) && bound < loadTimeout) throw timedOut("Waiting for the running load job");
215
+ throw error;
216
+ }
217
+ if (status === "missing") throw new Error("A BigQuery load job for this batch disappeared while it was awaited");
157
218
  }
158
219
  }
159
220
  return {
@@ -168,6 +229,7 @@ function createBigQueryReportingWarehouseSinkV1(options) {
168
229
  `UPDATE adcp_reporting_warehouse_sinks
169
230
  SET committed_cursor = NULL, planned_from_cursor = NULL, planned_to_cursor = NULL,
170
231
  planned_revisions = NULL, planned_rows_attempt = 0, planned_revisions_attempt = 0,
232
+ planned_rows_timeouts = 0, planned_revisions_timeouts = 0,
171
233
  reset_generation = reset_generation + 1, updated_at = clock_timestamp()
172
234
  WHERE sink_name = $1 AND ($2::boolean OR planned_to_cursor IS NULL)
173
235
  RETURNING sink_name`,
@@ -201,7 +263,8 @@ function createBigQueryReportingWarehouseSinkV1(options) {
201
263
  );
202
264
  const state = (await options.db.query(
203
265
  `SELECT committed_cursor, planned_from_cursor, planned_to_cursor, planned_revisions,
204
- planned_rows_attempt, planned_revisions_attempt, reset_generation
266
+ planned_rows_attempt, planned_revisions_attempt,
267
+ planned_rows_timeouts, planned_revisions_timeouts, reset_generation
205
268
  FROM adcp_reporting_warehouse_sinks WHERE sink_name = $1`,
206
269
  [options.name]
207
270
  ).catch(missingMigration)).rows[0];
@@ -220,7 +283,9 @@ function createBigQueryReportingWarehouseSinkV1(options) {
220
283
  to: state.planned_to_cursor,
221
284
  revisions: state.planned_revisions,
222
285
  rowsAttempt: state.planned_rows_attempt,
223
- revisionsAttempt: state.planned_revisions_attempt
286
+ revisionsAttempt: state.planned_revisions_attempt,
287
+ rowsTimeouts: state.planned_rows_timeouts,
288
+ revisionsTimeouts: state.planned_revisions_timeouts
224
289
  } : void 0;
225
290
  if (!plan) {
226
291
  const position = {
@@ -269,12 +334,15 @@ function createBigQueryReportingWarehouseSinkV1(options) {
269
334
  to: page.cursor,
270
335
  revisions: page.records.filter((record) => record.kind === "revision").map((record) => ({ id: record.record_id, account_id: record.account_id })),
271
336
  rowsAttempt: 0,
272
- revisionsAttempt: 0
337
+ revisionsAttempt: 0,
338
+ rowsTimeouts: 0,
339
+ revisionsTimeouts: 0
273
340
  };
274
341
  const claimed = await options.db.query(
275
342
  `UPDATE adcp_reporting_warehouse_sinks
276
343
  SET planned_from_cursor = $2, planned_to_cursor = $3, planned_revisions = $4::jsonb,
277
- planned_rows_attempt = 0, planned_revisions_attempt = 0, updated_at = clock_timestamp()
344
+ planned_rows_attempt = 0, planned_revisions_attempt = 0,
345
+ planned_rows_timeouts = 0, planned_revisions_timeouts = 0, updated_at = clock_timestamp()
278
346
  WHERE sink_name = $1 AND committed_cursor IS NOT DISTINCT FROM $2
279
347
  AND planned_to_cursor IS NULL AND planned_revisions IS NULL AND reset_generation = $5
280
348
  RETURNING sink_name`,
@@ -347,27 +415,52 @@ function createBigQueryReportingWarehouseSinkV1(options) {
347
415
  const baseJobId = reportingWarehouseJobIdV1(options.name, plan.from, plan.to, state.reset_generation);
348
416
  const suffix = (attempt) => attempt ? `_a${attempt}` : "";
349
417
  const steps = [
350
- { table: options.rowsTableId, kind: "rows", lines: rowLines, attempt: plan.rowsAttempt },
351
- { table: options.revisionsTableId, kind: "revs", lines: revisionLines, attempt: plan.revisionsAttempt }
418
+ {
419
+ table: options.rowsTableId,
420
+ role: "rows",
421
+ kind: "rows",
422
+ lines: rowLines,
423
+ attempt: plan.rowsAttempt,
424
+ timeouts: plan.rowsTimeouts,
425
+ timeoutColumn: "planned_rows_timeouts",
426
+ attemptColumn: "planned_rows_attempt"
427
+ },
428
+ {
429
+ table: options.revisionsTableId,
430
+ role: "revisions",
431
+ kind: "revs",
432
+ lines: revisionLines,
433
+ attempt: plan.revisionsAttempt,
434
+ timeouts: plan.revisionsTimeouts,
435
+ timeoutColumn: "planned_revisions_timeouts",
436
+ attemptColumn: "planned_revisions_attempt"
437
+ }
352
438
  ];
353
439
  for (const step of steps) {
354
440
  if (!step.lines.length) continue;
355
441
  try {
356
442
  await loadOnce(
443
+ step.role,
357
444
  step.table,
358
445
  `${baseJobId}_${step.kind}${suffix(step.attempt)}`,
359
446
  [...step.lines],
360
- step.attempt,
447
+ step.timeouts,
361
448
  signal
362
449
  );
363
450
  } catch (error) {
364
451
  if (isFinishedWithError(error)) {
365
- const column = step.kind === "rows" ? "planned_rows_attempt" : "planned_revisions_attempt";
366
452
  await options.db.query(
367
- `UPDATE adcp_reporting_warehouse_sinks SET ${column} = ${column} + 1,
453
+ `UPDATE adcp_reporting_warehouse_sinks SET ${step.attemptColumn} = ${step.attemptColumn} + 1,
368
454
  updated_at = clock_timestamp() ${matchesPlan}`,
369
455
  planValues
370
456
  );
457
+ } else if (isLoadTimedOut(error)) {
458
+ await options.db.query(
459
+ `UPDATE adcp_reporting_warehouse_sinks
460
+ SET ${step.timeoutColumn} = $8::integer + 1, updated_at = clock_timestamp()
461
+ ${matchesPlan} AND ${step.timeoutColumn} = $8::integer`,
462
+ [...planValues, step.timeouts]
463
+ );
371
464
  }
372
465
  throw error;
373
466
  }
@@ -376,7 +469,7 @@ function createBigQueryReportingWarehouseSinkV1(options) {
376
469
  `UPDATE adcp_reporting_warehouse_sinks
377
470
  SET committed_cursor = $3, planned_from_cursor = NULL, planned_to_cursor = NULL,
378
471
  planned_revisions = NULL, planned_rows_attempt = 0, planned_revisions_attempt = 0,
379
- updated_at = clock_timestamp()
472
+ planned_rows_timeouts = 0, planned_revisions_timeouts = 0, updated_at = clock_timestamp()
380
473
  ${matchesPlan} RETURNING sink_name`,
381
474
  planValues
382
475
  );
@@ -395,6 +488,9 @@ function missingMigration(error) {
395
488
  }
396
489
  throw error;
397
490
  }
491
+ function reportingWarehouseLoadDeadlineV1(baseMilliseconds, timeouts) {
492
+ return Math.min(baseMilliseconds * 2 ** Math.min(Math.max(timeouts, 0), MAX_TIMEOUT_DOUBLINGS), MAX_LOAD_TIMEOUT);
493
+ }
398
494
  function reportingWarehouseJobIdV1(name, from, to, generation = 0) {
399
495
  return `adcp_rr_${(0, import_crypto.createHash)("sha256").update(JSON.stringify(generation > 0 ? [name, from, to, generation] : [name, from, to])).digest("hex").slice(0, 40)}`;
400
496
  }
@@ -467,7 +563,7 @@ function sourceLocalDate(start, timeZone) {
467
563
  return instant.toISOString().slice(0, 10);
468
564
  }
469
565
  }
470
- async function withinDeadline(operation, milliseconds, signal) {
566
+ async function withinDeadline(operation, milliseconds, signal, context) {
471
567
  signal?.throwIfAborted();
472
568
  let timer;
473
569
  let onAbort;
@@ -476,7 +572,9 @@ async function withinDeadline(operation, milliseconds, signal) {
476
572
  operation(),
477
573
  new Promise((_, reject) => {
478
574
  timer = setTimeout(() => {
479
- const error = new Error("BigQuery call exceeded its deadline; the batch will be retried");
575
+ const error = new Error(
576
+ `BigQuery call exceeded its deadline${context ? ` of ${milliseconds} ms for ${context}` : ""}; the batch will be retried`
577
+ );
480
578
  Object.defineProperty(error, "adcpDeadlineExceeded", { value: true });
481
579
  reject(error);
482
580
  }, milliseconds);
@@ -498,25 +596,32 @@ function isAlreadyExists(error) {
498
596
  const record = error;
499
597
  return record?.code === 409 || !!record?.errors?.some((entry) => entry.reason === "duplicate");
500
598
  }
599
+ function isLoadTimedOut(error) {
600
+ return error?.adcpLoadTimedOut === true;
601
+ }
602
+ function sleep(milliseconds, signal) {
603
+ signal?.throwIfAborted();
604
+ return new Promise((resolve, reject) => {
605
+ const onAbort = () => {
606
+ clearTimeout(timer);
607
+ reject(signal?.reason ?? new Error("aborted"));
608
+ };
609
+ const timer = setTimeout(() => {
610
+ signal?.removeEventListener("abort", onAbort);
611
+ resolve();
612
+ }, milliseconds);
613
+ signal?.addEventListener("abort", onAbort, { once: true });
614
+ });
615
+ }
501
616
  function isFinishedWithError(error) {
502
617
  return error?.adcpJobFailed === true;
503
618
  }
504
- function assertJobSucceeded(metadata) {
505
- const record = Array.isArray(metadata) ? metadata[0] : metadata;
506
- if (record?.status?.state !== "DONE") {
507
- throw new Error("A BigQuery load job for this batch is still running; retry later");
508
- }
509
- if (record.status.errorResult) {
510
- const failure = new Error("A BigQuery load job for this batch failed; it will be retried as a new attempt");
511
- Object.defineProperty(failure, "adcpJobFailed", { value: true });
512
- throw failure;
513
- }
514
- }
515
619
  // Annotate the CommonJS export names for ESM import in node:
516
620
  0 && (module.exports = {
517
621
  REPORTING_WAREHOUSE_SINK_MIGRATION,
518
622
  createBigQueryReportingWarehouseSinkV1,
519
623
  reportingWarehouseCurrentRowsViewSqlV1,
520
624
  reportingWarehouseDefaultTablesSqlV1,
521
- reportingWarehouseJobIdV1
625
+ reportingWarehouseJobIdV1,
626
+ reportingWarehouseLoadDeadlineV1
522
627
  });
@@ -12,14 +12,21 @@ CREATE TABLE IF NOT EXISTS adcp_reporting_warehouse_sinks (
12
12
  planned_revisions JSONB,
13
13
  planned_rows_attempt INTEGER NOT NULL DEFAULT 0 CHECK (planned_rows_attempt >= 0),
14
14
  planned_revisions_attempt INTEGER NOT NULL DEFAULT 0 CHECK (planned_revisions_attempt >= 0),
15
+ planned_rows_timeouts INTEGER NOT NULL DEFAULT 0 CHECK (planned_rows_timeouts >= 0),
16
+ planned_revisions_timeouts INTEGER NOT NULL DEFAULT 0 CHECK (planned_revisions_timeouts >= 0),
15
17
  reset_generation INTEGER NOT NULL DEFAULT 0 CHECK (reset_generation >= 0),
16
18
  updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
17
19
  CHECK ((planned_to_cursor IS NULL) = (planned_revisions IS NULL))
18
20
  );
19
21
  ALTER TABLE adcp_reporting_warehouse_sinks
20
22
  ADD COLUMN IF NOT EXISTS reset_generation INTEGER NOT NULL DEFAULT 0 CHECK (reset_generation >= 0);
23
+ ALTER TABLE adcp_reporting_warehouse_sinks
24
+ ADD COLUMN IF NOT EXISTS planned_rows_timeouts INTEGER NOT NULL DEFAULT 0 CHECK (planned_rows_timeouts >= 0);
25
+ ALTER TABLE adcp_reporting_warehouse_sinks
26
+ ADD COLUMN IF NOT EXISTS planned_revisions_timeouts INTEGER NOT NULL DEFAULT 0 CHECK (planned_revisions_timeouts >= 0);
21
27
  `.trim();
22
28
  const MAX_LOAD_TIMEOUT = 6 * 36e5;
29
+ const MAX_TIMEOUT_DOUBLINGS = 16;
23
30
  let warnedLabelsIgnored = false;
24
31
  const PROJECT = /^[a-z][a-z0-9-]{4,61}[a-z0-9]$/;
25
32
  const DATASET_OR_TABLE = /^[A-Za-z_][A-Za-z0-9_]{0,1023}$/;
@@ -72,51 +79,104 @@ function createBigQueryReportingWarehouseSinkV1(options) {
72
79
  ...options.projectId ? { projectId: options.projectId } : {},
73
80
  ...options.location ? { location: options.location } : {}
74
81
  } : void 0;
75
- async function cancelLoad(jobId, signal) {
82
+ const uploads = /* @__PURE__ */ new Map();
83
+ function startUpload(tableId, jobId, lines) {
84
+ const entry = {
85
+ startedAt: Date.now(),
86
+ done: Promise.resolve()
87
+ };
88
+ const cleanup = async () => {
89
+ if (uploads.get(jobId) === entry) uploads.delete(jobId);
90
+ if (entry.directory) await fs.rm(entry.directory, { recursive: true, force: true }).catch(() => {
91
+ });
92
+ };
93
+ entry.done = (async () => {
94
+ const directory = await fs.mkdtemp(path.join(tmpdir(), "adcp-warehouse-"));
95
+ entry.directory = directory;
96
+ const file = path.join(directory, `${jobId}.ndjson`);
97
+ await fs.writeFile(file, lines.length ? lines.join("\n") + "\n" : "", { mode: 384 });
98
+ return dataset.table(tableId).load(file, {
99
+ jobId,
100
+ sourceFormat: "NEWLINE_DELIMITED_JSON",
101
+ writeDisposition: "WRITE_APPEND",
102
+ ...options.location ? { location: options.location } : {}
103
+ });
104
+ })();
105
+ const replaced = uploads.get(jobId);
106
+ uploads.set(jobId, entry);
107
+ void entry.done.then(cleanup, cleanup);
108
+ if (replaced?.directory) void fs.rm(replaced.directory, { recursive: true, force: true }).catch(() => {
109
+ });
110
+ return entry.done;
111
+ }
112
+ async function jobStatus(jobId, signal, boundMilliseconds = loadTimeout, context) {
113
+ let metadata;
76
114
  try {
77
- const job = options.bigquery.job(jobId, jobOptions);
78
- if (typeof job.cancel !== "function") return;
79
- const cancel = job.cancel.bind(job);
80
- await withinDeadline(cancel, Math.min(loadTimeout, 3e4), signal);
81
- } catch (error) {
82
- const code = error?.code;
83
- if (code === 404 || signal?.aborted) return;
84
- process.emitWarning(
85
- `Cancelling BigQuery load job ${jobId} failed (${typeof code === "number" || typeof code === "string" ? code : "no status code"}); the job may still be running`,
86
- { code: "ADCP_BIGQUERY_SINK_CANCEL_FAILED" }
115
+ metadata = await withinDeadline(
116
+ () => options.bigquery.job(jobId, jobOptions).getMetadata(),
117
+ boundMilliseconds,
118
+ signal,
119
+ context
87
120
  );
121
+ } catch (error) {
122
+ if (error?.code === 404) return "missing";
123
+ throw error;
88
124
  }
125
+ const record = Array.isArray(metadata) ? metadata[0] : metadata;
126
+ if (record?.status?.state !== "DONE") return "running";
127
+ if (record.status.errorResult) {
128
+ const failure = new Error("A BigQuery load job for this batch failed; it will be retried as a new attempt");
129
+ Object.defineProperty(failure, "adcpJobFailed", { value: true });
130
+ throw failure;
131
+ }
132
+ return "done";
89
133
  }
90
- async function loadOnce(tableId, jobId, lines, attempt, signal) {
91
- const loadDeadline = Math.min(loadTimeout * 2 ** Math.min(attempt, 16), MAX_LOAD_TIMEOUT);
134
+ async function loadOnce(role, tableId, jobId, lines, timeouts, signal) {
92
135
  signal?.throwIfAborted();
93
- const directory = await fs.mkdtemp(path.join(tmpdir(), "adcp-warehouse-"));
94
- const file = path.join(directory, `${jobId}.ndjson`);
95
- try {
96
- await fs.writeFile(file, lines.length ? lines.join("\n") + "\n" : "", { mode: 384 });
136
+ const table = `the ${role} table "${tableId}" (timeout count ${timeouts})`;
137
+ let status = await jobStatus(jobId, signal, loadTimeout, table);
138
+ if (status === "done") return;
139
+ const loadDeadline = reportingWarehouseLoadDeadlineV1(loadTimeout, timeouts);
140
+ const deadlineAt = Date.now() + loadDeadline;
141
+ const timedOut = (what) => {
142
+ const error = new Error(
143
+ `${what} exceeded this run's ${loadDeadline} ms deadline for ${table}; the next run checks the job again`
144
+ );
145
+ Object.defineProperty(error, "adcpDeadlineExceeded", { value: true });
146
+ Object.defineProperty(error, "adcpLoadTimedOut", { value: true });
147
+ return error;
148
+ };
149
+ if (status === "missing") {
97
150
  try {
98
- await withinDeadline(
99
- () => dataset.table(tableId).load(file, {
100
- jobId,
101
- sourceFormat: "NEWLINE_DELIMITED_JSON",
102
- writeDisposition: "WRITE_APPEND",
103
- ...options.location ? { location: options.location } : {}
104
- }),
105
- loadDeadline,
106
- signal
107
- );
151
+ const running = uploads.get(jobId);
152
+ const upload = running && Date.now() - running.startedAt < MAX_LOAD_TIMEOUT ? running.done : startUpload(tableId, jobId, lines);
153
+ await withinDeadline(() => upload, Math.max(deadlineAt - Date.now(), 1), signal);
154
+ return;
108
155
  } catch (error) {
109
- if (isDeadlineExceeded(error)) await cancelLoad(jobId, signal);
156
+ if (isDeadlineExceeded(error)) throw timedOut("Uploading the load");
110
157
  if (!isAlreadyExists(error)) throw error;
111
- const metadata = await withinDeadline(
112
- () => options.bigquery.job(jobId, jobOptions).getMetadata(),
113
- loadTimeout,
114
- signal
115
- );
116
- assertJobSucceeded(metadata);
158
+ status = await jobStatus(jobId, signal, loadTimeout, table);
159
+ if (status === "missing") {
160
+ throw new Error(
161
+ "BigQuery refused the load job ID as already existing, but the job cannot be found. Check that projectId and location match the dataset (location is required for datasets outside the US and EU multi-regions)",
162
+ { cause: error }
163
+ );
164
+ }
117
165
  }
118
- } finally {
119
- await fs.rm(directory, { recursive: true, force: true });
166
+ }
167
+ const pollMilliseconds = Math.min(5e3, Math.max(250, Math.floor(loadDeadline / 10)));
168
+ while (status !== "done") {
169
+ const remaining = deadlineAt - Date.now();
170
+ if (remaining <= 0) throw timedOut("Waiting for the running load job");
171
+ await sleep(Math.min(pollMilliseconds, remaining), signal);
172
+ const bound = Math.max(Math.min(loadTimeout, deadlineAt - Date.now()), 1);
173
+ try {
174
+ status = await jobStatus(jobId, signal, bound, table);
175
+ } catch (error) {
176
+ if (isDeadlineExceeded(error) && bound < loadTimeout) throw timedOut("Waiting for the running load job");
177
+ throw error;
178
+ }
179
+ if (status === "missing") throw new Error("A BigQuery load job for this batch disappeared while it was awaited");
120
180
  }
121
181
  }
122
182
  return {
@@ -131,6 +191,7 @@ function createBigQueryReportingWarehouseSinkV1(options) {
131
191
  `UPDATE adcp_reporting_warehouse_sinks
132
192
  SET committed_cursor = NULL, planned_from_cursor = NULL, planned_to_cursor = NULL,
133
193
  planned_revisions = NULL, planned_rows_attempt = 0, planned_revisions_attempt = 0,
194
+ planned_rows_timeouts = 0, planned_revisions_timeouts = 0,
134
195
  reset_generation = reset_generation + 1, updated_at = clock_timestamp()
135
196
  WHERE sink_name = $1 AND ($2::boolean OR planned_to_cursor IS NULL)
136
197
  RETURNING sink_name`,
@@ -164,7 +225,8 @@ function createBigQueryReportingWarehouseSinkV1(options) {
164
225
  );
165
226
  const state = (await options.db.query(
166
227
  `SELECT committed_cursor, planned_from_cursor, planned_to_cursor, planned_revisions,
167
- planned_rows_attempt, planned_revisions_attempt, reset_generation
228
+ planned_rows_attempt, planned_revisions_attempt,
229
+ planned_rows_timeouts, planned_revisions_timeouts, reset_generation
168
230
  FROM adcp_reporting_warehouse_sinks WHERE sink_name = $1`,
169
231
  [options.name]
170
232
  ).catch(missingMigration)).rows[0];
@@ -183,7 +245,9 @@ function createBigQueryReportingWarehouseSinkV1(options) {
183
245
  to: state.planned_to_cursor,
184
246
  revisions: state.planned_revisions,
185
247
  rowsAttempt: state.planned_rows_attempt,
186
- revisionsAttempt: state.planned_revisions_attempt
248
+ revisionsAttempt: state.planned_revisions_attempt,
249
+ rowsTimeouts: state.planned_rows_timeouts,
250
+ revisionsTimeouts: state.planned_revisions_timeouts
187
251
  } : void 0;
188
252
  if (!plan) {
189
253
  const position = {
@@ -232,12 +296,15 @@ function createBigQueryReportingWarehouseSinkV1(options) {
232
296
  to: page.cursor,
233
297
  revisions: page.records.filter((record) => record.kind === "revision").map((record) => ({ id: record.record_id, account_id: record.account_id })),
234
298
  rowsAttempt: 0,
235
- revisionsAttempt: 0
299
+ revisionsAttempt: 0,
300
+ rowsTimeouts: 0,
301
+ revisionsTimeouts: 0
236
302
  };
237
303
  const claimed = await options.db.query(
238
304
  `UPDATE adcp_reporting_warehouse_sinks
239
305
  SET planned_from_cursor = $2, planned_to_cursor = $3, planned_revisions = $4::jsonb,
240
- planned_rows_attempt = 0, planned_revisions_attempt = 0, updated_at = clock_timestamp()
306
+ planned_rows_attempt = 0, planned_revisions_attempt = 0,
307
+ planned_rows_timeouts = 0, planned_revisions_timeouts = 0, updated_at = clock_timestamp()
241
308
  WHERE sink_name = $1 AND committed_cursor IS NOT DISTINCT FROM $2
242
309
  AND planned_to_cursor IS NULL AND planned_revisions IS NULL AND reset_generation = $5
243
310
  RETURNING sink_name`,
@@ -310,27 +377,52 @@ function createBigQueryReportingWarehouseSinkV1(options) {
310
377
  const baseJobId = reportingWarehouseJobIdV1(options.name, plan.from, plan.to, state.reset_generation);
311
378
  const suffix = (attempt) => attempt ? `_a${attempt}` : "";
312
379
  const steps = [
313
- { table: options.rowsTableId, kind: "rows", lines: rowLines, attempt: plan.rowsAttempt },
314
- { table: options.revisionsTableId, kind: "revs", lines: revisionLines, attempt: plan.revisionsAttempt }
380
+ {
381
+ table: options.rowsTableId,
382
+ role: "rows",
383
+ kind: "rows",
384
+ lines: rowLines,
385
+ attempt: plan.rowsAttempt,
386
+ timeouts: plan.rowsTimeouts,
387
+ timeoutColumn: "planned_rows_timeouts",
388
+ attemptColumn: "planned_rows_attempt"
389
+ },
390
+ {
391
+ table: options.revisionsTableId,
392
+ role: "revisions",
393
+ kind: "revs",
394
+ lines: revisionLines,
395
+ attempt: plan.revisionsAttempt,
396
+ timeouts: plan.revisionsTimeouts,
397
+ timeoutColumn: "planned_revisions_timeouts",
398
+ attemptColumn: "planned_revisions_attempt"
399
+ }
315
400
  ];
316
401
  for (const step of steps) {
317
402
  if (!step.lines.length) continue;
318
403
  try {
319
404
  await loadOnce(
405
+ step.role,
320
406
  step.table,
321
407
  `${baseJobId}_${step.kind}${suffix(step.attempt)}`,
322
408
  [...step.lines],
323
- step.attempt,
409
+ step.timeouts,
324
410
  signal
325
411
  );
326
412
  } catch (error) {
327
413
  if (isFinishedWithError(error)) {
328
- const column = step.kind === "rows" ? "planned_rows_attempt" : "planned_revisions_attempt";
329
414
  await options.db.query(
330
- `UPDATE adcp_reporting_warehouse_sinks SET ${column} = ${column} + 1,
415
+ `UPDATE adcp_reporting_warehouse_sinks SET ${step.attemptColumn} = ${step.attemptColumn} + 1,
331
416
  updated_at = clock_timestamp() ${matchesPlan}`,
332
417
  planValues
333
418
  );
419
+ } else if (isLoadTimedOut(error)) {
420
+ await options.db.query(
421
+ `UPDATE adcp_reporting_warehouse_sinks
422
+ SET ${step.timeoutColumn} = $8::integer + 1, updated_at = clock_timestamp()
423
+ ${matchesPlan} AND ${step.timeoutColumn} = $8::integer`,
424
+ [...planValues, step.timeouts]
425
+ );
334
426
  }
335
427
  throw error;
336
428
  }
@@ -339,7 +431,7 @@ function createBigQueryReportingWarehouseSinkV1(options) {
339
431
  `UPDATE adcp_reporting_warehouse_sinks
340
432
  SET committed_cursor = $3, planned_from_cursor = NULL, planned_to_cursor = NULL,
341
433
  planned_revisions = NULL, planned_rows_attempt = 0, planned_revisions_attempt = 0,
342
- updated_at = clock_timestamp()
434
+ planned_rows_timeouts = 0, planned_revisions_timeouts = 0, updated_at = clock_timestamp()
343
435
  ${matchesPlan} RETURNING sink_name`,
344
436
  planValues
345
437
  );
@@ -358,6 +450,9 @@ function missingMigration(error) {
358
450
  }
359
451
  throw error;
360
452
  }
453
+ function reportingWarehouseLoadDeadlineV1(baseMilliseconds, timeouts) {
454
+ return Math.min(baseMilliseconds * 2 ** Math.min(Math.max(timeouts, 0), MAX_TIMEOUT_DOUBLINGS), MAX_LOAD_TIMEOUT);
455
+ }
361
456
  function reportingWarehouseJobIdV1(name, from, to, generation = 0) {
362
457
  return `adcp_rr_${createHash("sha256").update(JSON.stringify(generation > 0 ? [name, from, to, generation] : [name, from, to])).digest("hex").slice(0, 40)}`;
363
458
  }
@@ -430,7 +525,7 @@ function sourceLocalDate(start, timeZone) {
430
525
  return instant.toISOString().slice(0, 10);
431
526
  }
432
527
  }
433
- async function withinDeadline(operation, milliseconds, signal) {
528
+ async function withinDeadline(operation, milliseconds, signal, context) {
434
529
  signal?.throwIfAborted();
435
530
  let timer;
436
531
  let onAbort;
@@ -439,7 +534,9 @@ async function withinDeadline(operation, milliseconds, signal) {
439
534
  operation(),
440
535
  new Promise((_, reject) => {
441
536
  timer = setTimeout(() => {
442
- const error = new Error("BigQuery call exceeded its deadline; the batch will be retried");
537
+ const error = new Error(
538
+ `BigQuery call exceeded its deadline${context ? ` of ${milliseconds} ms for ${context}` : ""}; the batch will be retried`
539
+ );
443
540
  Object.defineProperty(error, "adcpDeadlineExceeded", { value: true });
444
541
  reject(error);
445
542
  }, milliseconds);
@@ -461,24 +558,31 @@ function isAlreadyExists(error) {
461
558
  const record = error;
462
559
  return record?.code === 409 || !!record?.errors?.some((entry) => entry.reason === "duplicate");
463
560
  }
561
+ function isLoadTimedOut(error) {
562
+ return error?.adcpLoadTimedOut === true;
563
+ }
564
+ function sleep(milliseconds, signal) {
565
+ signal?.throwIfAborted();
566
+ return new Promise((resolve, reject) => {
567
+ const onAbort = () => {
568
+ clearTimeout(timer);
569
+ reject(signal?.reason ?? new Error("aborted"));
570
+ };
571
+ const timer = setTimeout(() => {
572
+ signal?.removeEventListener("abort", onAbort);
573
+ resolve();
574
+ }, milliseconds);
575
+ signal?.addEventListener("abort", onAbort, { once: true });
576
+ });
577
+ }
464
578
  function isFinishedWithError(error) {
465
579
  return error?.adcpJobFailed === true;
466
580
  }
467
- function assertJobSucceeded(metadata) {
468
- const record = Array.isArray(metadata) ? metadata[0] : metadata;
469
- if (record?.status?.state !== "DONE") {
470
- throw new Error("A BigQuery load job for this batch is still running; retry later");
471
- }
472
- if (record.status.errorResult) {
473
- const failure = new Error("A BigQuery load job for this batch failed; it will be retried as a new attempt");
474
- Object.defineProperty(failure, "adcpJobFailed", { value: true });
475
- throw failure;
476
- }
477
- }
478
581
  export {
479
582
  REPORTING_WAREHOUSE_SINK_MIGRATION,
480
583
  createBigQueryReportingWarehouseSinkV1,
481
584
  reportingWarehouseCurrentRowsViewSqlV1,
482
585
  reportingWarehouseDefaultTablesSqlV1,
483
- reportingWarehouseJobIdV1
586
+ reportingWarehouseJobIdV1,
587
+ reportingWarehouseLoadDeadlineV1
484
588
  };
@@ -4,5 +4,5 @@
4
4
  "source_sha": "4e553ad955f83b49c7d221ab5c3ff78237ad02e3",
5
5
  "source_tarball_sha256": "580656d6466ef9f0d1119985e6726c2efea718dc671e2ad30957fcb2fd54af0f",
6
6
  "upstream_adcp_version": "2.5.3",
7
- "synced_at": "2026-10-13T02:34:34.174Z"
7
+ "synced_at": "2026-10-14T11:39:27.882Z"
8
8
  }
@@ -197,7 +197,7 @@ export interface UpstreamTrafficQueryResult {
197
197
  */
198
198
  export declare function runValidations(validations: StoryboardValidation[], context: ValidationContext): ValidationResult[];
199
199
  /** Capability semver emitted in run summaries and used for advisory expiry. */
200
- export declare const RUNNER_CAPABILITY_VERSION = "15.9.0";
200
+ export declare const RUNNER_CAPABILITY_VERSION = "15.9.1";
201
201
  /** True when a failed validation contributes to the owning step's grade. */
202
202
  export declare function validationFailsStep(result: ValidationResult): boolean;
203
203
  /**
@@ -197,7 +197,7 @@ export interface UpstreamTrafficQueryResult {
197
197
  */
198
198
  export declare function runValidations(validations: StoryboardValidation[], context: ValidationContext): ValidationResult[];
199
199
  /** Capability semver emitted in run summaries and used for advisory expiry. */
200
- export declare const RUNNER_CAPABILITY_VERSION = "15.9.0";
200
+ export declare const RUNNER_CAPABILITY_VERSION = "15.9.1";
201
201
  /** True when a failed validation contributes to the owning step's grade. */
202
202
  export declare function validationFailsStep(result: ValidationResult): boolean;
203
203
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * AdCP SDK library version
3
3
  */
4
- export declare const LIBRARY_VERSION = "15.9.0";
4
+ export declare const LIBRARY_VERSION = "15.9.1";
5
5
  /**
6
6
  * AdCP specification version this library is built for
7
7
  */
@@ -33,10 +33,10 @@ export type AdcpVersion = (typeof COMPATIBLE_ADCP_VERSIONS)[number];
33
33
  * Full version information
34
34
  */
35
35
  export declare const VERSION_INFO: {
36
- readonly library: "15.9.0";
36
+ readonly library: "15.9.1";
37
37
  readonly adcp: "3.2.4";
38
38
  readonly compatibleVersions: readonly ["v2.5", "v2.6", "v3", "3.0.0", "3.0", "3.0.1", "3.0.2", "3.0.3", "3.0.4", "3.0.5", "3.0.6", "3.0.7", "3.0.8", "3.0.9", "3.0.10", "3.0.11", "3.0.12", "3.0.13", "3.0.14", "3.0.15", "3.0.16", "3.0.17", "3.0.18", "3.0.19", "3.0.20", "3.0.21", "3.0.22", "3.0.23", "3.0.24", "3.0.25", "3.1.0", "3.1", "3.1.1", "3.1.2", "3.1.3", "3.1.4", "3.1.5", "3.1.6", "3.1.7", "3.1.8", "3.1.9", "3.1.10", "3.1.11", "3.1.12", "3.1.13", "3.1.14", "3.1.15", "3.1.16", "3.1.17", "3.1.18", "3.1.19", "3.1.20", "3.1.21", "3.1.22", "3.1.23", "3.1.24", "3.2.1", "3.2", "3.2.2", "3.2.3", "3.2.4"];
39
- readonly generatedAt: "2026-10-13T01:26:11.275Z";
39
+ readonly generatedAt: "2026-10-14T11:09:46.080Z";
40
40
  };
41
41
  /**
42
42
  * Get the AdCP specification version this library is built for
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * AdCP SDK library version
3
3
  */
4
- export declare const LIBRARY_VERSION = "15.9.0";
4
+ export declare const LIBRARY_VERSION = "15.9.1";
5
5
  /**
6
6
  * AdCP specification version this library is built for
7
7
  */
@@ -33,10 +33,10 @@ export type AdcpVersion = (typeof COMPATIBLE_ADCP_VERSIONS)[number];
33
33
  * Full version information
34
34
  */
35
35
  export declare const VERSION_INFO: {
36
- readonly library: "15.9.0";
36
+ readonly library: "15.9.1";
37
37
  readonly adcp: "3.2.4";
38
38
  readonly compatibleVersions: readonly ["v2.5", "v2.6", "v3", "3.0.0", "3.0", "3.0.1", "3.0.2", "3.0.3", "3.0.4", "3.0.5", "3.0.6", "3.0.7", "3.0.8", "3.0.9", "3.0.10", "3.0.11", "3.0.12", "3.0.13", "3.0.14", "3.0.15", "3.0.16", "3.0.17", "3.0.18", "3.0.19", "3.0.20", "3.0.21", "3.0.22", "3.0.23", "3.0.24", "3.0.25", "3.1.0", "3.1", "3.1.1", "3.1.2", "3.1.3", "3.1.4", "3.1.5", "3.1.6", "3.1.7", "3.1.8", "3.1.9", "3.1.10", "3.1.11", "3.1.12", "3.1.13", "3.1.14", "3.1.15", "3.1.16", "3.1.17", "3.1.18", "3.1.19", "3.1.20", "3.1.21", "3.1.22", "3.1.23", "3.1.24", "3.2.1", "3.2", "3.2.2", "3.2.3", "3.2.4"];
39
- readonly generatedAt: "2026-10-13T01:26:11.275Z";
39
+ readonly generatedAt: "2026-10-14T11:09:46.080Z";
40
40
  };
41
41
  /**
42
42
  * Get the AdCP specification version this library is built for
@@ -31,7 +31,7 @@ __export(version_exports, {
31
31
  toReleasePrecisionVersion: () => toReleasePrecisionVersion
32
32
  });
33
33
  module.exports = __toCommonJS(version_exports);
34
- const LIBRARY_VERSION = "15.9.0";
34
+ const LIBRARY_VERSION = "15.9.1";
35
35
  const ADCP_VERSION = "3.2.4";
36
36
  const ADCP_MAJOR_VERSION = 3;
37
37
  const COMPATIBLE_ADCP_VERSIONS = [
@@ -98,10 +98,10 @@ const COMPATIBLE_ADCP_VERSIONS = [
98
98
  "3.2.4"
99
99
  ];
100
100
  const VERSION_INFO = {
101
- library: "15.9.0",
101
+ library: "15.9.1",
102
102
  adcp: "3.2.4",
103
103
  compatibleVersions: COMPATIBLE_ADCP_VERSIONS,
104
- generatedAt: "2026-10-13T01:26:11.275Z"
104
+ generatedAt: "2026-10-14T11:09:46.080Z"
105
105
  };
106
106
  function getAdcpVersion() {
107
107
  return ADCP_VERSION;
@@ -1,4 +1,4 @@
1
- const LIBRARY_VERSION = "15.9.0";
1
+ const LIBRARY_VERSION = "15.9.1";
2
2
  const ADCP_VERSION = "3.2.4";
3
3
  const ADCP_MAJOR_VERSION = 3;
4
4
  const COMPATIBLE_ADCP_VERSIONS = [
@@ -65,10 +65,10 @@ const COMPATIBLE_ADCP_VERSIONS = [
65
65
  "3.2.4"
66
66
  ];
67
67
  const VERSION_INFO = {
68
- library: "15.9.0",
68
+ library: "15.9.1",
69
69
  adcp: "3.2.4",
70
70
  compatibleVersions: COMPATIBLE_ADCP_VERSIONS,
71
- generatedAt: "2026-10-13T01:26:11.275Z"
71
+ generatedAt: "2026-10-14T11:09:46.080Z"
72
72
  };
73
73
  function getAdcpVersion() {
74
74
  return ADCP_VERSION;
@@ -1,7 +1,7 @@
1
1
  # AdCP Type Summary
2
2
 
3
- > Generated at: 2026-10-13
4
- > @adcp/sdk v15.9.0
3
+ > Generated at: 2026-10-14
4
+ > @adcp/sdk v15.9.1
5
5
 
6
6
  Curated reference of the types that matter for using the AdCP client. For full generated types see `src/lib/types/tools.generated.ts` and `src/lib/types/core.generated.ts`.
7
7
 
@@ -911,29 +911,53 @@ setInterval(() => {
911
911
  `revision_number`, `finality`, `period_date` (the period's date in its source
912
912
  timezone) and `ordinal` to your mapped columns.
913
913
  - Batches are planned durably before loading, and job IDs derive from the
914
- change-feed range. A crash between a load and the cursor commit replays the
915
- same batch under the same job ID, which BigQuery refuses to run twice. A job
916
- that finished with an error is retried under a new attempt for that table
917
- only.
914
+ change-feed range. Before every submission the sink looks that job ID up in
915
+ the destination project and location: a job that finished cleanly counts as
916
+ loaded, a job that is pending or running is waited for (never uploaded
917
+ again), and only a missing job (404) is uploaded. A crash or timeout between a
918
+ load and the cursor commit therefore replays the same batch under the same
919
+ job ID without a second upload. An upload an earlier run abandoned at its
920
+ deadline is still running in the same process (the official client cannot
921
+ abort it), so the next run waits for it, within its own deadline, rather than
922
+ starting another, however long ago it started (that age includes the gap
923
+ between runs). If that wait outlasts the run's deadline it counts as a timeout
924
+ and the deadline grows. Only an upload still pending after 6 hours, the
925
+ largest deadline, is abandoned and replaced; BigQuery refuses a duplicate job
926
+ ID, so a replacement can never load the batch twice. A job that finished with
927
+ an error is retried under a new attempt for that table only. Deadline and
928
+ timeout errors name the table (`rows` or `revisions`, and its ID), the
929
+ deadline in force and that table's timeout count, never a file path or
930
+ credential.
918
931
  - `projectId` scopes both load submission and replay status checks. When it is
919
932
  omitted, both use the BigQuery client's default project. Each replica must
920
- be able to read the BigQuery jobs submitted by the other replicas.
921
- - `loadTimeoutMilliseconds` (default 10 minutes) is a client-side deadline for
922
- one load. When a load passes it, the sink asks BigQuery to cancel that job by
923
- its deterministic ID, in `projectId` and `location`, and rejects with the
924
- plan retained. The next run finds the cancelled job finished with an error,
925
- advances that table's attempt and rejects; the run after that loads under the
926
- new attempt. If the cancel has not taken effect yet it waits for the job.
927
- Each attempt doubles the load deadline (up to 6 hours), so a slow but healthy
928
- load converges; set the deadline above your slowest expected load to avoid
929
- the cancelled attempts. Cancellation is best effort (BigQuery may finish the
930
- job anyway, which the next run then accepts), a failed cancel other than "not
931
- found" raises a `ADCP_BIGQUERY_SINK_CANCEL_FAILED` process warning, and a
932
- status check that times out is not cancelled. BigQuery's server-side
933
- `jobTimeoutMs` is not used: for a local-file load the official client builds
934
- `configuration.load` from the load options and cannot set job-level fields,
935
- and BigQuery silently drops `labels` and `jobTimeoutMs` found inside
936
- `configuration.load`. (Staging the file in GCS and submitting the load with
933
+ be able to read the BigQuery jobs submitted by the other replicas. Set
934
+ `location` for any dataset outside the US and EU multi-regions: BigQuery
935
+ answers a job lookup without the right location with 404, and the sink then
936
+ cannot find the jobs it created.
937
+ - `loadTimeoutMilliseconds` (default 10 minutes) is the base deadline for one
938
+ run's work on one load: uploading the file, or waiting for a job that is still
939
+ running. It bounds a run, not the job. BigQuery does not stop a load when the
940
+ client gives up (a cancel is accepted and the job finishes anyway), and it
941
+ refuses a resubmitted job ID only after the whole file is uploaded again, so
942
+ the sink never cancels and never relies on that refusal. When the deadline
943
+ passes, the run rejects with the plan retained and nothing advanced: the job
944
+ ID, the attempt and the cursor stay as they were, and the next run looks the
945
+ job up by its ID again. A job whose upload outlasted a deadline is created in
946
+ the background and is picked up by the next run with no second upload. Each
947
+ such timeout doubles that table's deadline for later runs (up to 6 hours).
948
+ The growth is kept with the plan in `planned_rows_timeouts` and
949
+ `planned_revisions_timeouts` (so it survives restarts and applies to every
950
+ replica, and clears when the batch commits), so an upload slower than the
951
+ deadline eventually gets enough time. Set the deadline above your slowest
952
+ expected load to avoid the extra runs. Only a job BigQuery reports as
953
+ finished with an error advances the attempt. A status check that times out
954
+ is bounded by the base deadline and does not grow it. Re-run
955
+ `REPORTING_WAREHOUSE_SINK_MIGRATION` after upgrading: it adds the two timeout
956
+ columns (a sink table without them rejects with a message saying so).
957
+ BigQuery's server-side `jobTimeoutMs` is not used: for a local-file load the
958
+ official client builds `configuration.load` from the load options and cannot
959
+ set job-level fields, and BigQuery silently drops `labels` and `jobTimeoutMs`
960
+ found inside `configuration.load`. (Staging the file in GCS and submitting the load with
937
961
  `bigquery.createJob` can set both; the sink does not stage files.) For the
938
962
  same reason the `labels` option has no effect (it warns once per process and
939
963
  is deprecated). To attribute load cost, query
@@ -974,7 +998,9 @@ setInterval(() => {
974
998
  jobs may have succeeded; reconcile the plan against BigQuery, then call
975
999
  `reset({ discardRetainedPlan: true })`. It does not touch the warehouse: a sink
976
1000
  that already loaded history reloads the retained revisions, so empty the
977
- destination tables first or use a new sink name against new tables. Every
1001
+ destination tables first (after stopping every worker and letting in-flight
1002
+ uploads and jobs finish, since the sink never cancels a load) or use a new
1003
+ sink name against new tables. Every
978
1004
  reset starts a new job ID generation, so reloaded batches submit fresh BigQuery
979
1005
  jobs instead of matching the finished jobs of the earlier history. Nothing holds
980
1006
  change retention between `reset()` and the first committed batch. If `reset()`
@@ -334,9 +334,20 @@ What it checks:
334
334
  duplicate rows, and that `current_rows` returns one revision per obligation.
335
335
  It also asserts (`PASS`/`FAIL`) that the sink sends no `labels` or
336
336
  `jobTimeoutMs` inside `configuration.load`, where BigQuery silently drops
337
- them, and that a job cancel addressed with the destination project and
338
- location (what the sink sends when a load passes its client-side deadline) is
339
- accepted by BigQuery.
337
+ them. A deadline scenario runs a sink with the minimum 1-second load deadline
338
+ and holds the first load's result past it: the run must reject with the plan
339
+ and job ID unchanged and that table's persisted deadline grown, the job must
340
+ end `DONE` without an error (the sink never cancels it), and later runs under
341
+ the same sink name must converge with no duplicate rows, the same job ID and
342
+ no second upload. A #3234 scenario runs a sink whose upload of a 50,000-row
343
+ batch outlasts its 1-second deadline and asserts that it converges within 8
344
+ runs, loads every row exactly once, never changes the job ID, and never
345
+ starts an upload after the job exists (a link fast enough to beat the
346
+ deadline passes and says so).
347
+ - **Cancel probe of the service** (`INFO`, never a failure). It cancels a
348
+ 5000-row load the moment it is created and reports whether BigQuery stopped
349
+ it; in practice it accepts the cancel and lets the load finish, which is why
350
+ the sink never cancels a load and finds it by job ID instead.
340
351
  - **Overlapping runs** (`INFO`, never a failure). Two concurrent `runOnce()`
341
352
  calls over one pending batch, and a deterministic reproduction of #3196: one
342
353
  run reads its sink state, another completes the batch and a new revision
package/docs/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # Ad Context Protocol (AdCP)
2
2
 
3
- > Generated at: 2026-10-13
4
- > Library: @adcp/sdk v15.9.0
3
+ > Generated at: 2026-10-14
4
+ > Library: @adcp/sdk v15.9.1
5
5
  > AdCP major version: 3
6
6
  > Canonical URL: https://adcontextprotocol.github.io/adcp-client/llms.txt
7
7
  > Note: the `Library` stamp reflects the package.json version at doc-generation time. The narrative below describes the surface that lands on the next-published minor — including any 6.7 helpers documented here ahead of the release tag.
@@ -11,8 +11,8 @@ below as the publication/deployment gate.
11
11
 
12
12
  | Fact | Value |
13
13
  | --- | --- |
14
- | Exact npm package | `@adcp/sdk@15.9.0` |
15
- | npm integrity | registry-derived after publication; run `npm view @adcp/sdk@15.9.0 dist.integrity` |
14
+ | Exact npm package | `@adcp/sdk@15.9.1` |
15
+ | npm integrity | registry-derived after publication; run `npm view @adcp/sdk@15.9.1 dist.integrity` |
16
16
  | Node.js runtime | `^20.19.0 || >=22.12.0` |
17
17
  | Default AdCP wire release | `3.2.4` |
18
18
  | Maintained wire releases | `v2.5`, `v2.6`, `v3`, `3.0.0`, `3.0`, `3.0.1`, `3.0.2`, `3.0.3`, `3.0.4`, `3.0.5`, `3.0.6`, `3.0.7`, `3.0.8`, `3.0.9`, `3.0.10`, `3.0.11`, `3.0.12`, `3.0.13`, `3.0.14`, `3.0.15`, `3.0.16`, `3.0.17`, `3.0.18`, `3.0.19`, `3.0.20`, `3.0.21`, `3.0.22`, `3.0.23`, `3.0.24`, `3.0.25`, `3.1.0`, `3.1`, `3.1.1`, `3.1.2`, `3.1.3`, `3.1.4`, `3.1.5`, `3.1.6`, `3.1.7`, `3.1.8`, `3.1.9`, `3.1.10`, `3.1.11`, `3.1.12`, `3.1.13`, `3.1.14`, `3.1.15`, `3.1.16`, `3.1.17`, `3.1.18`, `3.1.19`, `3.1.20`, `3.1.21`, `3.1.22`, `3.1.23`, `3.1.24`, `3.2.1`, `3.2`, `3.2.2`, `3.2.3`, `3.2.4` |
@@ -21,8 +21,8 @@ below as the publication/deployment gate.
21
21
  Install exact production inputs rather than a moving range or dist-tag:
22
22
 
23
23
  ```bash
24
- npm install --save-exact '@adcp/sdk@15.9.0'
25
- npm view '@adcp/sdk@15.9.0' dist.integrity
24
+ npm install --save-exact '@adcp/sdk@15.9.1'
25
+ npm view '@adcp/sdk@15.9.1' dist.integrity
26
26
  ```
27
27
 
28
28
  ### Required and optional peers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adcp/sdk",
3
- "version": "15.9.0",
3
+ "version": "15.9.1",
4
4
  "description": "AdCP SDK — client, server, and compliance harnesses for the AdContext Protocol (MCP + A2A)",
5
5
  "workspaces": [
6
6
  ".",