@malloydata/db-bigquery 0.0.426 → 0.0.427

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.
@@ -1,4 +1,4 @@
1
- import type { RowMetadata } from '@google-cloud/bigquery';
1
+ import type { Job, PagedResponse, Query, QueryResultsOptions, RowMetadata } from '@google-cloud/bigquery';
2
2
  import type bigquery from '@google-cloud/bigquery/build/src/types';
3
3
  import type { ResourceStream } from '@google-cloud/paginator';
4
4
  import type { Connection, ConnectionConfig, ConnectionParameterValue, MalloyQueryData, PersistSQLResults, QueryData, QueryRecord, QueryOptionsReader, QueryRunStats, RunSQLOptions, StreamingConnection, TableSourceDef, SQLSourceDef, SQLSourceRequest } from '@malloydata/malloy';
@@ -54,6 +54,31 @@ interface SchemaInfo {
54
54
  export declare class BigQueryAuthenticationError extends Error {
55
55
  constructor(message: string);
56
56
  }
57
+ /**
58
+ * Fetch a job's query results, polling until the job completes, `deadlineMs`
59
+ * elapses, or the caller aborts. A single getQueryResults call cannot wait out a
60
+ * slow query: BigQuery bounds how long one call blocks server-side (its docs
61
+ * note the call typically returns after ~200s even when a larger timeoutMs is
62
+ * requested) and then reports `jobComplete: false` while the job is still
63
+ * running normally. So for a query that runs longer than one call's server wait
64
+ * we re-issue getQueryResults until it finishes.
65
+ *
66
+ * `deadlineMs` bounds the total wait (the connection's configured timeoutMs).
67
+ * On reaching it we cancel the job before throwing: BigQuery's jobTimeoutMs
68
+ * should already be cancelling it server-side, but if that timeout error has not
69
+ * come back yet, cancelling here stops the job from running (and billing) past
70
+ * the point we stopped waiting. `abortSignal` lets the loop exit promptly on an
71
+ * external cancel (the caller cancels the job in that case). A bounded number of
72
+ * retries absorbs the transient access-denied error BigQuery intermittently
73
+ * returns on first fetch. Between polls a minimum interval is enforced so a poll
74
+ * that returns sooner than requested does not spin the loop. `now` and `wait`
75
+ * are injectable for testing.
76
+ */
77
+ export declare function getQueryResultsUntilComplete(job: Pick<Job, 'getQueryResults' | 'cancel'>, getQueryResultsOptions: QueryResultsOptions, deadlineMs: number, { abortSignal, now, wait, }?: {
78
+ abortSignal?: AbortSignal;
79
+ now?: () => number;
80
+ wait?: (ms: number, abortSignal?: AbortSignal) => Promise<void>;
81
+ }): Promise<PagedResponse<RowMetadata, Query, bigquery.IGetQueryResultsResponse>>;
57
82
  export declare class BigQueryConnection extends BaseConnection implements Connection, PersistSQLResults, StreamingConnection {
58
83
  readonly name: string;
59
84
  private readonly dialect;
@@ -97,6 +122,15 @@ export declare class BigQueryConnection extends BaseConnection implements Connec
97
122
  private encodeProjectSegment;
98
123
  fetchTableSchema(tableName: string, tablePath: string): Promise<TableSourceDef | string>;
99
124
  private getSQLBlockSchema;
125
+ /**
126
+ * The effective query timeout in milliseconds. A configured value that is
127
+ * unset, blank, non-numeric, zero, or negative falls back to TIMEOUT_MS; only
128
+ * a positive number overrides the default. The result bounds both the
129
+ * BigQuery job (jobTimeoutMs) and the getQueryResultsUntilComplete poll
130
+ * deadline, so it must be positive: a zero or negative would make the job
131
+ * cancel on the first poll instead of running.
132
+ */
133
+ private resolvedTimeoutMs;
100
134
  private createBigQueryJobAndGetResults;
101
135
  private createBigQueryJob;
102
136
  initiateJobAndGetLinkToConsole(sqlCommand: string, dryRun?: boolean): Promise<string>;
@@ -38,6 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.BigQueryConnection = exports.BigQueryAuthenticationError = void 0;
41
+ exports.getQueryResultsUntilComplete = getQueryResultsUntilComplete;
41
42
  const bigquery_1 = require("@google-cloud/bigquery");
42
43
  const googleCommon = __importStar(require("@google-cloud/common"));
43
44
  const gaxios_1 = require("gaxios");
@@ -104,9 +105,185 @@ const maybeRewriteError = (e) => {
104
105
  */
105
106
  const MAXIMUM_BYTES_BILLED = String(25 * 1024 * 1024 * 1024);
106
107
  /**
107
- * Default timeoutMs value, 10 Mins
108
+ * Default connection timeoutMs, 10 minutes. Bounds both the BigQuery job
109
+ * (jobTimeoutMs) and, via getQueryResultsUntilComplete, how long results are
110
+ * polled. When this connector runs behind an HTTP layer (e.g. the Malloy
111
+ * Publisher), set the connection's timeoutMs below that layer's socket/request
112
+ * timeout so a long query is ended by this deadline with a clear error, rather
113
+ * than racing an opaque socket reset.
108
114
  */
109
115
  const TIMEOUT_MS = 1000 * 60 * 10;
116
+ /**
117
+ * How long each getQueryResults call asks to wait for the job to finish before
118
+ * returning. BigQuery returns after at most ~200s regardless of the requested
119
+ * value, so this is kept under that ceiling; queries that take longer are
120
+ * covered by polling across multiple calls (see getQueryResultsUntilComplete).
121
+ */
122
+ const GET_QUERY_RESULTS_POLL_MS = 1000 * 60 * 2;
123
+ /**
124
+ * Minimum spacing between getQueryResults polls. Each call is asked to block
125
+ * server-side for up to GET_QUERY_RESULTS_POLL_MS, but BigQuery may return
126
+ * `jobComplete: false` sooner than requested; this floor keeps the loop from
127
+ * busy-polling in that case. Only the shortfall below it is waited out, so a
128
+ * poll that already blocked longer adds nothing.
129
+ */
130
+ const GET_QUERY_RESULTS_MIN_POLL_INTERVAL_MS = 1000;
131
+ /**
132
+ * Issue one getQueryResults call via the callback overload so we can read the
133
+ * structured `jobComplete` flag rather than pattern-matching the client's error
134
+ * string. On the still-running path the client invokes the callback with a
135
+ * synthetic "did not complete before ..." Error *and* an apiResponse whose
136
+ * `jobComplete === false`; we key off that boolean, which does not drift across
137
+ * client releases the way the message can.
138
+ *
139
+ * `autoPaginate: false` is forced, not optional: getQueryResults is
140
+ * paginator-wrapped, and at the default the paginator hands the callback the
141
+ * error alone, dropping the apiResponse read here — so every still-running poll
142
+ * would be misread as a fetch error. It does not change what a completed fetch
143
+ * returns.
144
+ *
145
+ * Resolves promptly if `abortSignal` fires, so a caller's timeout/cancel is not
146
+ * left blocked behind BigQuery's server-side wait.
147
+ */
148
+ function pollQueryResults(job, options, abortSignal) {
149
+ return new Promise(resolve => {
150
+ let settled = false;
151
+ const settle = (outcome) => {
152
+ if (settled) {
153
+ return;
154
+ }
155
+ settled = true;
156
+ abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.removeEventListener('abort', onAbort);
157
+ resolve(outcome);
158
+ };
159
+ const onAbort = () => settle({ kind: 'aborted' });
160
+ if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) {
161
+ settle({ kind: 'aborted' });
162
+ return;
163
+ }
164
+ abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.addEventListener('abort', onAbort);
165
+ const pollOptions = { ...options, autoPaginate: false };
166
+ job.getQueryResults(pollOptions, (err, rows, nextQuery, apiResponse) => {
167
+ if ((apiResponse === null || apiResponse === void 0 ? void 0 : apiResponse.jobComplete) === false) {
168
+ settle({ kind: 'stillRunning' });
169
+ }
170
+ else if (err) {
171
+ settle({ kind: 'error', error: err });
172
+ }
173
+ else {
174
+ settle({
175
+ kind: 'complete',
176
+ value: [
177
+ rows !== null && rows !== void 0 ? rows : [],
178
+ nextQuery !== null && nextQuery !== void 0 ? nextQuery : null,
179
+ apiResponse,
180
+ ],
181
+ });
182
+ }
183
+ });
184
+ });
185
+ }
186
+ /** Cancel a job without letting a cancel failure mask the caller's real error. */
187
+ async function cancelJobQuietly(job) {
188
+ try {
189
+ await job.cancel();
190
+ }
191
+ catch {
192
+ // Best-effort: the job may already be finishing or gone; the caller still
193
+ // reports the timeout that prompted the cancel.
194
+ }
195
+ }
196
+ /**
197
+ * A delay used to space out polls; resolves early if `abortSignal` fires, and is
198
+ * injectable so tests need not wait real time.
199
+ */
200
+ function delay(ms, abortSignal) {
201
+ return new Promise(resolve => {
202
+ if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) {
203
+ resolve();
204
+ return;
205
+ }
206
+ const timer = setTimeout(() => {
207
+ abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.removeEventListener('abort', onAbort);
208
+ resolve();
209
+ }, ms);
210
+ const onAbort = () => {
211
+ clearTimeout(timer);
212
+ resolve();
213
+ };
214
+ abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.addEventListener('abort', onAbort, { once: true });
215
+ });
216
+ }
217
+ /**
218
+ * Fetch a job's query results, polling until the job completes, `deadlineMs`
219
+ * elapses, or the caller aborts. A single getQueryResults call cannot wait out a
220
+ * slow query: BigQuery bounds how long one call blocks server-side (its docs
221
+ * note the call typically returns after ~200s even when a larger timeoutMs is
222
+ * requested) and then reports `jobComplete: false` while the job is still
223
+ * running normally. So for a query that runs longer than one call's server wait
224
+ * we re-issue getQueryResults until it finishes.
225
+ *
226
+ * `deadlineMs` bounds the total wait (the connection's configured timeoutMs).
227
+ * On reaching it we cancel the job before throwing: BigQuery's jobTimeoutMs
228
+ * should already be cancelling it server-side, but if that timeout error has not
229
+ * come back yet, cancelling here stops the job from running (and billing) past
230
+ * the point we stopped waiting. `abortSignal` lets the loop exit promptly on an
231
+ * external cancel (the caller cancels the job in that case). A bounded number of
232
+ * retries absorbs the transient access-denied error BigQuery intermittently
233
+ * returns on first fetch. Between polls a minimum interval is enforced so a poll
234
+ * that returns sooner than requested does not spin the loop. `now` and `wait`
235
+ * are injectable for testing.
236
+ */
237
+ async function getQueryResultsUntilComplete(job, getQueryResultsOptions, deadlineMs, { abortSignal, now = Date.now, wait = delay, } = {}) {
238
+ const startedAt = now();
239
+ let transientRetries = 0;
240
+ for (;;) {
241
+ if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) {
242
+ throw new Error('BigQuery getQueryResults was aborted before the query completed.');
243
+ }
244
+ const remainingMs = deadlineMs - (now() - startedAt);
245
+ if (remainingMs <= 0) {
246
+ await cancelJobQuietly(job);
247
+ throw new Error(`BigQuery query did not complete within the configured timeout of ${deadlineMs}ms. ` +
248
+ "Raise the connection's timeoutMs to allow longer-running queries.");
249
+ }
250
+ // Clamp the per-poll server wait to what's left of the deadline: a short
251
+ // timeout then fails fast, and the overall deadline can't be overshot by a
252
+ // full poll interval. timeoutMs is applied last so a caller-supplied
253
+ // timeoutMs cannot override the poll interval.
254
+ const pollTimeoutMs = Math.max(1, Math.min(GET_QUERY_RESULTS_POLL_MS, remainingMs));
255
+ const polledAt = now();
256
+ const outcome = await pollQueryResults(job, { ...getQueryResultsOptions, timeoutMs: pollTimeoutMs }, abortSignal);
257
+ switch (outcome.kind) {
258
+ case 'complete':
259
+ return outcome.value;
260
+ case 'stillRunning': {
261
+ // BigQuery can return `jobComplete: false` sooner than the timeoutMs we
262
+ // asked for; without a floor between polls the loop would busy-poll and
263
+ // hammer the API. Wait out only the shortfall below a minimum interval,
264
+ // bounded by the remaining deadline, so a poll that already blocked adds
265
+ // no latency. The abort/deadline checks at the top of the loop then
266
+ // decide whether to continue, and a still-running poll never consumes
267
+ // the transient-retry budget.
268
+ const backoffMs = Math.min(GET_QUERY_RESULTS_MIN_POLL_INTERVAL_MS - (now() - polledAt), deadlineMs - (now() - startedAt));
269
+ if (backoffMs > 0) {
270
+ await wait(backoffMs, abortSignal);
271
+ }
272
+ continue;
273
+ }
274
+ case 'aborted':
275
+ throw new Error('BigQuery getQueryResults was aborted before the query completed.');
276
+ case 'error':
277
+ // A (possibly transient) fetch error — e.g. the intermittent
278
+ // access-denied BigQuery returns on first fetch. Retry a bounded number
279
+ // of times, then surface the real error.
280
+ if (transientRetries++ < 3) {
281
+ continue;
282
+ }
283
+ throw outcome.error;
284
+ }
285
+ }
286
+ }
110
287
  // manage access to BQ, control costs, enforce global data/API limits
111
288
  class BigQueryConnection extends connection_1.BaseConnection {
112
289
  constructor(arg, queryOptions, config = {}) {
@@ -197,7 +374,8 @@ class BigQueryConnection extends connection_1.BaseConnection {
197
374
  const totalRows = +(((_a = jobResult[2]) === null || _a === void 0 ? void 0 : _a.totalRows)
198
375
  ? jobResult[2].totalRows
199
376
  : '0');
200
- // TODO even though we have 10 minute timeout limit, we still should confirm that resulting metadata has "jobComplete: true"
377
+ // jobComplete is guaranteed here: getQueryResultsUntilComplete only
378
+ // returns once BigQuery reports the job complete (it polls otherwise).
201
379
  const queryCostBytes = (_b = jobResult[2]) === null || _b === void 0 ? void 0 : _b.totalBytesProcessed;
202
380
  const data = {
203
381
  rows: jobResult[0],
@@ -542,9 +720,18 @@ class BigQueryConnection extends connection_1.BaseConnection {
542
720
  }
543
721
  throw lastFetchError;
544
722
  }
545
- // TODO this needs to extend the wait for results using a timeout set by the user,
546
- // and probably needs to loop to check for results - BQ docs now say that after ~2min of waiting,
547
- // no matter what you set for timeoutMs, they will probably just return.
723
+ /**
724
+ * The effective query timeout in milliseconds. A configured value that is
725
+ * unset, blank, non-numeric, zero, or negative falls back to TIMEOUT_MS; only
726
+ * a positive number overrides the default. The result bounds both the
727
+ * BigQuery job (jobTimeoutMs) and the getQueryResultsUntilComplete poll
728
+ * deadline, so it must be positive: a zero or negative would make the job
729
+ * cancel on the first poll instead of running.
730
+ */
731
+ resolvedTimeoutMs() {
732
+ const configured = Number(this.config.timeoutMs);
733
+ return configured > 0 ? configured : TIMEOUT_MS;
734
+ }
548
735
  async createBigQueryJobAndGetResults(sqlCommand, createQueryJobOptions, getQueryResultsOptions, abortSignal) {
549
736
  try {
550
737
  const job = await this.createBigQueryJob({
@@ -555,37 +742,29 @@ class BigQueryConnection extends connection_1.BaseConnection {
555
742
  job.cancel();
556
743
  };
557
744
  abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.addEventListener('abort', cancel);
558
- // TODO we should check if this is still required?
559
- // We do a simple retry-loop here, as a temporary fix for a transient
560
- // error in which sometimes requesting results from a job yields an
561
- // access denied error. It seems that in these cases, simply trying again
562
- // solves the problem. This is being currently investigated by
563
- // @christopherswenson and @lloydtabb.
564
- let lastFetchError;
565
- for (let retries = 0; retries < 3; retries++) {
566
- try {
567
- return await job.getQueryResults({
568
- timeoutMs: 1000 * 60 * 2, // TODO - this requires some rethinking, and is a hack to resolve some issues. talk to @bporterfield
569
- wrapIntegers: {
570
- integerTypeCastFunction: (val) => {
571
- const num = Number(val);
572
- if (Number.isSafeInteger(num)) {
573
- return num;
574
- }
575
- return String(val);
576
- },
745
+ try {
746
+ // Poll for results until the job completes or the connection's
747
+ // configured timeout elapses; a single getQueryResults call can't wait
748
+ // out a long-running query (see getQueryResultsUntilComplete). The
749
+ // deadline is the same resolved timeout that bounds the job's
750
+ // jobTimeoutMs, so the client stops waiting right about when BigQuery
751
+ // stops running the job.
752
+ return await getQueryResultsUntilComplete(job, {
753
+ wrapIntegers: {
754
+ integerTypeCastFunction: (val) => {
755
+ const num = Number(val);
756
+ if (Number.isSafeInteger(num)) {
757
+ return num;
758
+ }
759
+ return String(val);
577
760
  },
578
- ...getQueryResultsOptions,
579
- });
580
- }
581
- catch (fetchError) {
582
- lastFetchError = fetchError;
583
- }
584
- finally {
585
- abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.removeEventListener('abort', cancel);
586
- }
761
+ },
762
+ ...getQueryResultsOptions,
763
+ }, this.resolvedTimeoutMs(), { abortSignal });
764
+ }
765
+ finally {
766
+ abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.removeEventListener('abort', cancel);
587
767
  }
588
- throw lastFetchError;
589
768
  }
590
769
  catch (e) {
591
770
  throw maybeRewriteError(e);
@@ -599,7 +778,10 @@ class BigQueryConnection extends connection_1.BaseConnection {
599
778
  const [job] = await this.bigQuery.createQueryJob({
600
779
  location: this.location,
601
780
  maximumBytesBilled: this.config.maximumBytesBilled || MAXIMUM_BYTES_BILLED,
602
- jobTimeoutMs: Number(this.config.timeoutMs) || TIMEOUT_MS,
781
+ // Shadow the resolved timeout to the job's server-side limit so BigQuery
782
+ // cancels a runaway job on its own; getQueryResultsUntilComplete polls up
783
+ // to the same deadline on the client side.
784
+ jobTimeoutMs: this.resolvedTimeoutMs(),
603
785
  ...options,
604
786
  });
605
787
  return job;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/db-bigquery",
3
- "version": "0.0.426",
3
+ "version": "0.0.427",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,7 +26,7 @@
26
26
  "@google-cloud/bigquery": "7.9.4",
27
27
  "@google-cloud/common": "5.0.2",
28
28
  "@google-cloud/paginator": "5.0.2",
29
- "@malloydata/malloy": "0.0.426",
29
+ "@malloydata/malloy": "0.0.427",
30
30
  "gaxios": "^4.2.0"
31
31
  }
32
32
  }