From 456712a022e0422492b380e4a19b29837c75c95a Mon Sep 17 00:00:00 2001 From: ohmayr Date: Tue, 20 Oct 2026 23:41:00 +0000 Subject: [PATCH 1/3] fix(bigquery): raise when an Arrow batch arrives before its schema decodeArrowPayload returned an empty RecordBatch when a record batch arrived without a schema, so its rows were silently dropped from createQueryArrowStream results. Throw "Got an Arrow record batch before its schema." instead, matching the Python client (https://github.com/googleapis/google-cloud-python/pull/18791). --- handwritten/bigquery/src/arrow.ts | 5 +++++ handwritten/bigquery/test/arrow.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/handwritten/bigquery/src/arrow.ts b/handwritten/bigquery/src/arrow.ts index 12c5b938dc3..fca2c8ce567 100644 --- a/handwritten/bigquery/src/arrow.ts +++ b/handwritten/bigquery/src/arrow.ts @@ -143,6 +143,11 @@ export function decodeArrowPayload( serializedRecordBatch?: Uint8Array | string, ): RecordBatch[] { if (!serializedSchema) { + // Without a schema the batch can't be decoded; returning an empty batch + // would silently drop its rows. + if (serializedRecordBatch) { + 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 diff --git a/handwritten/bigquery/test/arrow.ts b/handwritten/bigquery/test/arrow.ts index 85d4e1609a0..47d84ea8cac 100644 --- a/handwritten/bigquery/test/arrow.ts +++ b/handwritten/bigquery/test/arrow.ts @@ -268,6 +268,25 @@ describe('Arrow Support', () => { assert.strictEqual(batches[0].numRows, 2); }); + it('should fail instead of dropping rows when jobs.query returns a record batch without a schema', async () => { + stubJobsQuery(bq, { + res: { + jobComplete: true, + totalRows: '2', + pageRowCount: '2', + arrowRecordBatch: { + serializedRecordBatch: SAMPLE_PAYLOAD.serializedRecordBatch, + }, + }, + }); + + // Verify the batch isn't silently replaced with an empty RecordBatch. + await assert.rejects( + bq.createQueryArrowStream('SELECT 1').toArray(), + /Got an Arrow record batch before its schema\./, + ); + }); + it('should emit a 0-row RecordBatch preserving the schema when arrowSchema is present without arrowRecordBatch', async () => { // Include nested Struct and List fields to verify 0-row buffer initialization. const schema = new arrow.Schema([ From 8f1679a81874e56c6e82773ccc503202d27874bb Mon Sep 17 00:00:00 2001 From: ohmayr Date: Tue, 20 Oct 2026 23:52:42 +0000 Subject: [PATCH 2/3] fix(bigquery): treat an empty Arrow batch like a missing one An empty Uint8Array batch is truthy, so decodeArrowPayload raised "before its schema" for a payload with no rows. Check its length, so an empty string and an empty Uint8Array both count as no batch. --- handwritten/bigquery/src/arrow.ts | 2 +- handwritten/bigquery/test/arrow.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/handwritten/bigquery/src/arrow.ts b/handwritten/bigquery/src/arrow.ts index fca2c8ce567..dc5128a4885 100644 --- a/handwritten/bigquery/src/arrow.ts +++ b/handwritten/bigquery/src/arrow.ts @@ -145,7 +145,7 @@ export function decodeArrowPayload( if (!serializedSchema) { // Without a schema the batch can't be decoded; returning an empty batch // would silently drop its rows. - if (serializedRecordBatch) { + if (serializedRecordBatch && serializedRecordBatch.length > 0) { throw new Error('Got an Arrow record batch before its schema.'); } return [new arrow.RecordBatch({})]; diff --git a/handwritten/bigquery/test/arrow.ts b/handwritten/bigquery/test/arrow.ts index 47d84ea8cac..b313e8dbc0b 100644 --- a/handwritten/bigquery/test/arrow.ts +++ b/handwritten/bigquery/test/arrow.ts @@ -287,6 +287,27 @@ describe('Arrow Support', () => { ); }); + it('should treat an empty record batch without a schema like a missing one', async () => { + stubJobsQuery(bq, { + res: { + jobComplete: true, + totalRows: '0', + arrowRecordBatch: { + // An empty Uint8Array is truthy but carries no rows. + serializedRecordBatch: new Uint8Array(0) as unknown as string, + }, + }, + }); + + // Verify one 0-row, 0-column RecordBatch is emitted instead of an error. + const batches: arrow.RecordBatch[] = await bq + .createQueryArrowStream('SELECT 1') + .toArray(); + assert.strictEqual(batches.length, 1); + assert.strictEqual(batches[0].numRows, 0); + assert.strictEqual(batches[0].numCols, 0); + }); + it('should emit a 0-row RecordBatch preserving the schema when arrowSchema is present without arrowRecordBatch', async () => { // Include nested Struct and List fields to verify 0-row buffer initialization. const schema = new arrow.Schema([ From fd2a7c4a445ed40f08fd2acee208a9bcbd9bb299 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 21 Oct 2026 23:17:59 +0000 Subject: [PATCH 3/3] fix(bigquery): check schema and batch length in decodeArrowPayload Treat empty Uint8Array(0) schema and record batch payloads as absent across both branches of decodeArrowPayload. --- handwritten/bigquery/src/arrow.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/handwritten/bigquery/src/arrow.ts b/handwritten/bigquery/src/arrow.ts index dc5128a4885..81291718e8c 100644 --- a/handwritten/bigquery/src/arrow.ts +++ b/handwritten/bigquery/src/arrow.ts @@ -142,10 +142,14 @@ export function decodeArrowPayload( serializedSchema?: Uint8Array | string, serializedRecordBatch?: Uint8Array | string, ): RecordBatch[] { - if (!serializedSchema) { + 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 (serializedRecordBatch && serializedRecordBatch.length > 0) { + if (hasBatch) { throw new Error('Got an Arrow record batch before its schema.'); } return [new arrow.RecordBatch({})]; @@ -153,14 +157,14 @@ export function decodeArrowPayload( // 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 = serializedRecordBatch + 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 serializedRecordBatch + return hasBatch ? [...reader] : [createEmptyRecordBatch(arrow, reader.schema)]; }