UNPKG

@google-cloud/bigquery

Version:
702 lines • 28.9 kB
"use strict"; /*! * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.checkArrowDependencies = checkArrowDependencies; exports.getStorageReadClient = getStorageReadClient; exports.createEmptyRecordBatch = createEmptyRecordBatch; exports.decodeArrowPayload = decodeArrowPayload; exports.createQueryArrowStream_ = createQueryArrowStream_; const paginator_1 = require("@google-cloud/paginator"); const readClients = new WeakMap(); // gRPC status codes safe to resume from a row offset on ReadRows. const GRPC_STATUS_INTERNAL = 13; const GRPC_STATUS_UNAVAILABLE = 14; // How many times in a row a failed ReadRows call is reopened before the // error is passed to the caller. Any rows read reset the count. const MAX_STREAM_RETRIES = 3; /** * Validates and normalizes `arrowSerializationOptions`, rejecting compressed * Arrow buffers (`apache-arrow` doesn't bundle LZ4 or ZSTD codecs) and * defaulting `picosTimestampPrecision` to `'TIMESTAMP_PRECISION_MICROS'`. * Other options aren't forwarded, so every batch stays decodable. * * @private */ function resolveArrowSerializationOptions(options) { if (options?.bufferCompression && options.bufferCompression !== 'COMPRESSION_UNSPECIFIED') { throw new Error(`Unsupported arrowSerializationOptions.bufferCompression "${options.bufferCompression}": ` + 'apache-arrow does not bundle LZ4 or ZSTD decompression codecs.'); } const precision = options?.picosTimestampPrecision; return { picosTimestampPrecision: precision && precision !== 'PICOS_TIMESTAMP_PRECISION_UNSPECIFIED' ? precision : 'TIMESTAMP_PRECISION_MICROS', }; } /** * Returns the first project ID from `candidates`, falling back to the * `BigQuery` client's project and ignoring the uninitialized `'{{projectId}}'` * placeholder. * * @private */ function resolveProjectId(bq, ...candidates) { for (const candidate of [...candidates, bq.projectId]) { if (candidate && candidate !== '{{projectId}}') { return candidate; } } return undefined; } /** * Requires a dependency and verifies its minimum major.minor version. * * @private */ function requireWithMinVersion(requireFn, pkgName, minMajor, minMinor = 0) { const minVersion = `${minMajor}.${minMinor}.0`; let mod; let pkg; try { mod = requireFn(pkgName); pkg = requireFn(`${pkgName}/package.json`); } catch (err) { throw new Error(`Arrow support requires the "${pkgName}" package (>= ${minVersion}), ` + `but it could not be loaded: ${err.message}. ` + `Please install it using: npm install ${pkgName}`, { cause: err }); } const [majorStr, minorStr] = (pkg.version || '0.0.0').split('.'); const major = Number.parseInt(majorStr, 10); const minor = Number.parseInt(minorStr, 10); const versionOk = !Number.isNaN(major) && (major > minMajor || (major === minMajor && !Number.isNaN(minor) && minor >= minMinor)); if (!versionOk) { throw new Error(`Arrow support requires "${pkgName}" >= ${minVersion} (found ${pkg.version || 'unknown'}). ` + `Please upgrade using: npm install ${pkgName}@latest`); } return mod; } /** * Loads `apache-arrow` and the optional `@google-cloud/bigquery-storage` * (>= 6.3.0) package, verifying the latter's version. * * `apache-arrow` is a regular dependency, so its version is guaranteed by * `package.json`. It is loaded lazily so that only Arrow callers pay for it. * * @private */ function checkArrowDependencies(requireFn = require) { const arrow = requireFn('apache-arrow'); // Package managers only warn when an installed version is outside an // optional peer range, and an older version's protos silently drop newer // request fields, so check the version here. const storage = requireWithMinVersion(requireFn, '@google-cloud/bigquery-storage', 6, 3); return { arrow, storage }; } /** * Returns the shared `BigQueryReadClient` for a `BigQuery` instance, creating * it lazily on first use with the `BigQuery` client's credentials, universe * domain, and gRPC keepalive settings. * * @private */ function getStorageReadClient(bq, storage) { let client = readClients.get(bq); if (!client) { client = new storage.BigQueryReadClient({ auth: bq.authClient, projectId: resolveProjectId(bq), universeDomain: bq.universeDomain, // Ping while ReadRows is paused for a slow consumer, so a connection // silently dropped by a proxy or NAT errors instead of hanging. 'grpc.keepalive_time_ms': 60_000, 'grpc.keepalive_timeout_ms': 60_000, }); readClients.set(bq, client); } return client; } /** * Converts a serialized Arrow IPC payload (base64 string or Uint8Array) to a * Uint8Array. * * @private */ function toUint8Array(serialized) { return typeof serialized === 'string' ? Buffer.from(serialized, 'base64') : serialized; } /** * Creates a 0-row `RecordBatch` that preserves the provided Arrow `Schema`. * * @private */ function createEmptyRecordBatch(arrow, schema) { // Use makeBuilder so apache-arrow allocates valid 0-length typed array // buffers for every column (including nested Struct and List fields). const structData = arrow .makeBuilder({ type: new arrow.Struct(schema.fields) }) .finish() .flush(); return new arrow.RecordBatch(schema, structData); } /** * Decodes serialized Arrow IPC schema and record batch bytes into `RecordBatch` * objects, emitting a single 0-row `RecordBatch` (preserving `schema` when * present) if no record batches are present. * * @private */ function decodeArrowPayload(arrow, serializedSchema, serializedRecordBatch) { const hasSchema = serializedSchema !== undefined && serializedSchema.length > 0; const hasBatch = serializedRecordBatch !== undefined && serializedRecordBatch.length > 0; if (!hasSchema) { // Without a schema the batch can't be decoded; returning an empty batch // would silently drop its rows. if (hasBatch) { throw new Error('Got an Arrow record batch before its schema.'); } return [new arrow.RecordBatch({})]; } // BigQuery returns the Arrow IPC Schema message and RecordBatch message as // separate fields; the reader accepts them as consecutive chunks of one IPC // stream, which avoids copying them into a single buffer. const chunks = hasBatch ? [toUint8Array(serializedSchema), toUint8Array(serializedRecordBatch)] : [toUint8Array(serializedSchema)]; const reader = arrow.RecordBatchReader.from(chunks); reader.open(); // With no record batch, the reader yields its own 0-row placeholder batch, // which leaves nested Struct children empty and List children undefined. return hasBatch ? [...reader] : [createEmptyRecordBatch(arrow, reader.schema)]; } /** * Pushes `batches` to `stream`, stopping if the consumer destroys it. * * @private */ function pushBatches(stream, batches) { for (const batch of batches) { if (stream.destroyed) { return; } stream.push(batch); } } /** * Cuts `batches` at the caller's `maxResults` limit, slicing the batch that * crosses it and counting the kept rows against `ctx.remainingRows`. Always * keeps the first batch, so a result cut to 0 rows still carries its schema. * * @private */ function limitRows(ctx, batches) { if (ctx.remainingRows === undefined) { return batches; } const kept = []; for (const batch of batches) { if (kept.length > 0 && ctx.remainingRows === 0) { break; } const rows = Math.min(batch.numRows, ctx.remainingRows); kept.push(rows < batch.numRows ? batch.slice(0, rows) : batch); ctx.remainingRows -= rows; } return kept; } /** * Streams Arrow `RecordBatch`es from a job's sessionless default stream * (`projects/{projectId}/locations/{location}/jobs/{jobId}/streams/_default`) * starting at `offset`. Pass the finished `job` when reading from offset 0 so * a result without rows keeps its schema (see {@link readDestinationSchema}). * * @private */ async function streamDefaultJobStream(ctx, jobRef, offset, initialSchema, job) { const { bq, arrow, stream } = ctx; const projectId = resolveProjectId(bq, jobRef?.projectId); const jobId = jobRef?.jobId; const location = jobRef?.location || bq.location; if (!projectId || !jobId || !location) { throw new Error('Cannot stream remaining Arrow rows without a jobReference.'); } const readStream = `projects/${projectId}/locations/${location}/jobs/${jobId}/streams/_default`; const { emittedBatch, serializedSchema } = await streamReadRows(ctx, { readStream, offset, initialSchema, arrowSerializationOptions: ctx.arrowSerializationOptions, }); // When reading from offset = 0 (e.g., after waiting for an unfinished job) // and the stream finishes with 0 rows, still emit a 0-row RecordBatch. if (offset !== 0 || emittedBatch || stream.destroyed) { return; } const schema = serializedSchema ?? (job && (await readDestinationSchema(ctx, job))); pushBatches(stream, decodeArrowPayload(arrow, schema)); } /** * Creates an Arrow read session with a single stream on `table`, billed to * `parentProjectId`. * * @private */ async function createArrowReadSession(ctx, parentProjectId, table) { if (!parentProjectId) { throw new Error('Cannot create an Arrow read session without a project ID.'); } const [session] = await getStorageReadClient(ctx.bq, ctx.storage).createReadSession({ parent: `projects/${parentProjectId}`, readSession: { table: `projects/${table.projectId}/datasets/${table.datasetId}/tables/${table.tableId}`, dataFormat: 'ARROW', readOptions: { arrowSerializationOptions: ctx.arrowSerializationOptions }, }, // One stream keeps rows in the query's order (e.g., ORDER BY) for a table // the query just wrote. maxStreamCount: 1, }); return session ?? undefined; } /** * Reads the Arrow schema of a finished query job's results from its * destination table. The default job stream sends no `ReadRowsResponse`, and * so no schema, for a result without rows, but a read session on the * destination table still returns it. Statements without a result table * (e.g., DML) have no destination table, so this resolves to `undefined`. * * @private */ async function readDestinationSchema(ctx, job) { // A script's destination table is only set once the job finishes, so read // the job's current metadata instead of the response that created it. const [metadata] = await job.getMetadata(); const destRef = metadata?.configuration?.query?.destinationTable; // Skip the read session if the consumer destroyed the stream meanwhile. if (!destRef || ctx.stream.destroyed) { return undefined; } const session = await createArrowReadSession(ctx, resolveProjectId(ctx.bq, metadata?.jobReference?.projectId, destRef.projectId), destRef); return session?.arrowSchema?.serializedSchema || undefined; } /** * Returns `true` if a `ReadRows` gRPC stream error is transient and safe to * resume from the next unread row offset. * * @private */ function isRetryableStreamError(err) { // DEADLINE_EXCEEDED isn't retried: it means the caller's deadline ran out. if (err.code === GRPC_STATUS_UNAVAILABLE) { return true; } // Long-lived HTTP/2 streams closed by the server with RST_STREAM surface as // INTERNAL (code 13) in gRPC and are safe to reconnect at the next offset. return (err.code === GRPC_STATUS_INTERNAL && Boolean(err.message?.includes('RST_STREAM'))); } /** * Streams Arrow `RecordBatch`es from a Storage Read API stream starting at * `offset`. If `ReadRows` fails with a transient error, reopens it at the next * unread row. * * Resolves when the stream ends or the consumer destroys it, with whether any * batch was emitted and the last Arrow schema seen. Rejects with the error * once it isn't retryable or the retries run out. * * @private */ async function streamReadRows(ctx, options) { const { offset, initialSchema, ...request } = options; const progress = { offset, emittedBatch: false, serializedSchema: initialSchema ? toUint8Array(initialSchema) : undefined, }; let retries = 0; // Also stops at the caller's maxResults, without opening ReadRows for 0. while (!ctx.stream.destroyed && ctx.remainingRows !== 0) { const attemptOffset = progress.offset; try { await readRowsOnce(ctx, request, progress); break; } catch (err) { if (progress.offset > attemptOffset) { retries = 0; } if (!isRetryableStreamError(err) || retries >= MAX_STREAM_RETRIES) { throw err; } retries++; } } const { emittedBatch, serializedSchema } = progress; return { emittedBatch, serializedSchema }; } /** * Opens one `ReadRows` call at `progress.offset` and pushes its Arrow * `RecordBatch`es, pausing `ReadRows` while the consumer is behind. Updates * `progress` for each batch, so a retry resumes at the next unread row. * * Resolves when `ReadRows` ends or the consumer destroys the stream, and * rejects on a `ReadRows` or decode error. In every case, the listeners added * here are removed, and `ReadRows` is cancelled unless it already finished. * * @private */ async function readRowsOnce(ctx, request, progress) { const { bq, arrow, storage, stream } = ctx; return new Promise((resolve, reject) => { const grpcStream = getStorageReadClient(bq, storage).readRows({ ...request, offset: progress.offset, }); let settled = false; // Detaches every listener added here and settles the promise. Cancels // only this ReadRows call, keeping the shared BigQueryReadClient open. const settle = (opts) => { if (settled) { return; } settled = true; grpcStream.removeListener('data', onData); grpcStream.removeListener('error', onError); grpcStream.removeListener('end', onEnd); stream.removeListener('close', onClose); ctx.onRead = undefined; // cancel() makes gRPC emit a CANCELLED 'error' on a later tick, which // would crash the process if no 'error' listener were attached. This // listener swallows every later error on this ReadRows call, not just // CANCELLED: we're done with the call, so there's no value in // surfacing them. grpcStream.on('error', () => { }); if (opts.cancel) { grpcStream.cancel(); } if (opts.err) { reject(opts.err); } else { resolve(); } }; const onData = (resp) => { if (stream.destroyed) { settle({ cancel: true }); return; } // The Storage Read API sends arrowSchema on the first ReadRowsResponse // and may also emit responses without record batch bytes (e.g., // heartbeats). if (resp.arrowSchema?.serializedSchema) { progress.serializedSchema = toUint8Array(resp.arrowSchema.serializedSchema); } const rawBatch = resp.arrowRecordBatch?.serializedRecordBatch; const hasBatchBytes = rawBatch && (typeof rawBatch === 'string' ? rawBatch.length > 0 : rawBatch.byteLength > 0); if (!hasBatchBytes) { return; } try { const batches = limitRows(ctx, decodeArrowPayload(arrow, progress.serializedSchema, rawBatch)); for (const batch of batches) { if (stream.destroyed) { settle({ cancel: true }); return; } progress.offset += batch.numRows; progress.emittedBatch = true; if (!stream.push(batch)) { grpcStream.pause(); } } // Reached maxResults, so the rest of the stream isn't needed. if (ctx.remainingRows === 0) { settle({ cancel: true }); } } catch (parseErr) { settle({ cancel: true, err: parseErr }); } }; const onError = (err) => settle({ cancel: false, err }); const onEnd = () => settle({ cancel: false }); // The consumer destroyed the stream (e.g., ended iteration early). const onClose = () => settle({ cancel: true }); // ReadRows is paused when push() reports a full buffer; resume it when the // consumer asks for more so slow consumers don't buffer the whole result. ctx.onRead = () => grpcStream.resume(); grpcStream.on('data', onData); grpcStream.on('error', onError); grpcStream.on('end', onEnd); stream.on('close', onClose); }); } /** * Creates a Storage Read API session for an explicit destination table and * streams Arrow `RecordBatch`es from its single stream. * * @private */ async function streamDestinationTable(ctx, jobRef, table) { const { arrow, stream } = ctx; // The session is billed to the job's project; the table can be in another. const session = await createArrowReadSession(ctx, resolveProjectId(ctx.bq, jobRef?.projectId), table); const sessionSchema = session?.arrowSchema?.serializedSchema || undefined; const streams = session?.streams ?? []; const readStream = streams[0]?.name; if (streams.length === 0 || !readStream) { // An empty destination table returns 0 streams; emit a single 0-row // RecordBatch carrying the session's Arrow schema. pushBatches(stream, decodeArrowPayload(arrow, sessionSchema)); return; } // Session streams use the options passed to CreateReadSession. const { emittedBatch, serializedSchema } = await streamReadRows(ctx, { readStream, offset: 0, initialSchema: sessionSchema, }); // A stream that ends without rows still yields a 0-row RecordBatch. if (!emittedBatch && !stream.destroyed) { pushBatches(stream, decodeArrowPayload(arrow, serializedSchema)); } } /** * Waits for an unfinished BigQuery job to complete using `job.getQueryResults` * without fetching any `STRUCT_ENCODING` rows (`maxResults: 0`). Returns early * if the consumer destroys `stream`. If `timeoutMs` is set, fails like * {@link BigQuery#query} when the job doesn't complete in time instead of * polling again. * * @private */ async function waitForJobCompletion(job, stream, timeoutMs) { while (!stream.destroyed) { // Set maxResults: 0 so jobs.getQueryResults blocks up to the server's // default wait interval (about 10s) and surfaces job errors without // returning JSON rows, so polling again right away doesn't spin. const [, , res] = await job.getQueryResults({ autoPaginate: false, maxResults: 0, // 0 means no limit, as in BigQuery#query. Sending 0 would make // jobs.getQueryResults return at once and this loop spin. ...(timeoutMs && { timeoutMs }), }); if (res?.jobComplete !== false) { return; } } } /** * Waits for `job` to finish, then streams all of its rows: from * `destinationTable` through a read session when the caller set one, or from * the default job stream otherwise. An unfinished jobs.query and jobs.insert * return no rows or schema, so the default job stream is read from offset 0, * passing the job to look up the schema of an empty result. * * @private */ async function streamJobWhenComplete(ctx, job, jobRef, timeoutMs, destinationTable) { await waitForJobCompletion(job, ctx.stream, timeoutMs); if (ctx.stream.destroyed) { return; } if (destinationTable) { await streamDestinationTable(ctx, jobRef, destinationTable); return; } await streamDefaultJobStream(ctx, jobRef, 0, undefined, job); } /** * Runs `queryObj` with `jobs.insert` and streams its results. A dry run emits * one 0-row, 0-column `RecordBatch` without waiting or streaming. * * @private */ async function streamQueryJob(ctx, queryObj) { const { bq, arrow, stream } = ctx; // timeoutMs only bounds the wait below, and jobCreationMode only applies to // jobs.query, so keep both out of the job configuration. const { timeoutMs, ...jobQuery } = queryObj; delete jobQuery.jobCreationMode; // Throws for an invalid request, for example a destination that isn't a // Table, or invalid parameters. const [job, resp] = await bq.createQueryJob(jobQuery); // The consumer may have destroyed the stream while jobs.insert ran. if (stream.destroyed) { return; } if (!job) { throw new Error('jobs.insert returned no job.'); } stream.jobReference = resp?.jobReference; // For a dry run, job.metadata holds the query's statistics. stream.emit('job', job); // A dry run validates the query without running it. if (queryObj.dryRun) { pushBatches(stream, decodeArrowPayload(arrow)); return; } // BigQuery echoes the caller's destination table, with its resolved // project, in the job's configuration. const destinationTable = queryObj.destination ? (resp?.configuration?.query?.destinationTable ?? undefined) : undefined; await streamJobWhenComplete(ctx, job, resp?.jobReference, timeoutMs, destinationTable); } /** * Executes a query and returns a stream of `apache-arrow` `RecordBatch` objects. * * @private */ function createQueryArrowStream_(bq, query) { let started = false; // Set once the query starts, so later reads can resume the active ReadRows. let readCtx; const stream = new paginator_1.ResourceStream({ streamOptions: { // Buffer at most one decoded RecordBatch ahead of the consumer, since // each batch can hold many rows. highWaterMark: 1, // Called when the consumer wants more batches: the first call starts // the query, and later calls resume a paused ReadRows call. read: () => { if (started) { readCtx?.onRead?.(); return; } started = true; void runQuery(); }, }, }, () => { }); // Runs the query and pushes its batches. The returned promise settles once // all batches are pushed, the consumer destroys the stream, or a step fails. const streamQueryResults = async () => { const { arrow, storage } = checkArrowDependencies(); if (!query || (typeof query === 'object' && !query.query)) { throw new Error('A SQL query string is required.'); } // Keep arrowSerializationOptions and maxResults out of queryObj so they // never reach the jobs.insert configuration; they're applied explicitly. const { arrowSerializationOptions: requestedSerializationOptions, maxResults, ...queryObj } = typeof query === 'string' ? { query } : query; // NaN would never reach the row limit, and a negative limit would run // the whole query just to return no rows. if (maxResults !== undefined && (!Number.isInteger(maxResults) || maxResults < 0)) { throw new Error('maxResults must be a non-negative integer.'); } const ctx = { bq, arrow, storage, stream, arrowSerializationOptions: resolveArrowSerializationOptions(requestedSerializationOptions), remainingRows: maxResults, }; readCtx = ctx; // Throws for an invalid request, for example invalid query parameters. const queryReq = bq.buildQueryRequest_(queryObj, { timeoutMs: queryObj.timeoutMs, }); if (!queryReq) { // buildQueryRequest_ returns undefined when the query sets an option // that only jobs.insert accepts (e.g., destination, jobId, dryRun, or // jobTimeoutMs), or when FAST_QUERY_PATH=DISABLED. await streamQueryJob(ctx, queryObj); return; } // Default to JOB_CREATION_OPTIONAL unless overridden on the query or client. if (!queryReq.jobCreationMode) { queryReq.jobCreationMode = 'JOB_CREATION_OPTIONAL'; } queryReq.queryResultsFormat = 'ARROW'; queryReq.arrowSerializationOptions = ctx.arrowSerializationOptions; // Set directly: buildQueryRequest_ drops a maxResults of 0. queryReq.maxResults = maxResults; const [job, res] = (await bq.runJobsQuery(queryReq)); // The consumer may have destroyed the stream while jobs.query ran. if (stream.destroyed) { return; } if (res?.queryId) { stream.queryId = res.queryId; } if (res?.jobReference && job) { stream.jobReference = res.jobReference; stream.emit('job', job); } if (res?.jobComplete === false) { // jobs.query already waited (about 10s by default) and the query is // still running, so this depends on how long the query runs, not on // how many rows it returns. // Match BigQuery#query: if the caller provided a non-zero timeoutMs, // fail immediately instead of polling jobs.getQueryResults. if (queryReq.timeoutMs) { throw new Error(`The query did not complete before ${queryReq.timeoutMs}ms`); } if (!res.jobReference || !job) { throw new Error('Cannot wait for an incomplete query without a jobReference.'); } await streamJobWhenComplete(ctx, job, res.jobReference); return; } // pageRowCount and totalRows are returned as uint64 strings in REST JSON. const inlineRowsCount = BigInt(res?.pageRowCount ?? 0); const totalRowsCount = BigInt(res?.totalRows ?? 0); // Also check pageToken: on cached multi-page results, jobs.query can // return totalRows "0" and omit pageRowCount while setting pageToken. const hasRemainingRows = Boolean(res?.pageToken) || inlineRowsCount < totalRowsCount; // Emit the inline first page (or 0-row schema-preserving batch when totalRows is 0) // before resuming remaining rows from streams/_default at offset = pageRowCount. if (!hasRemainingRows || inlineRowsCount > 0n) { pushBatches(stream, limitRows(ctx, decodeArrowPayload(arrow, res?.arrowSchema?.serializedSchema, res?.arrowRecordBatch?.serializedRecordBatch))); } if (!hasRemainingRows || stream.destroyed) { return; } await streamDefaultJobStream(ctx, res?.jobReference, Number(inlineRowsCount), res?.arrowSchema?.serializedSchema, job ?? undefined); }; // The only place that ends or destroys the stream. const runQuery = async () => { try { await streamQueryResults(); } catch (err) { stream.destroy(err); return; } if (!stream.destroyed) { stream.end(); } }; return stream; } //# sourceMappingURL=arrow.js.map