fix(node): normalize record batches before IPC serialization (#4871)

Passing a record batch created by an ESM `apache-arrow` import to
`RRFReranker.rerankHybrid` throws an IPC footer parse error. The SDK's
CommonJS Arrow writer receives data types from another module instance.

Normalize single-record-batch input with the existing table sanitizer
before writing IPC. Extend the Arrow 15–21 round-trip test with
caller-created batches and nulls, and add a native ESM reranker
regression covering 64-bit row IDs, scores, and an empty FTS side.

Validation:

- Reproduced with published `0.42.0-beta.3`; CommonJS and explicitly
normalized ESM controls pass.
- Same native module and inputs: all 8 targeted cases fail with the
parent serializer and pass with this change.
- Local native build, TypeScript build, lint, and formatting pass.
- Current-head CI passes on Linux Node 22/24/26, macOS, and Arrow 19/20.
The macOS run passes 1,643 tests; Linux Node 24 also passes docs and
example checks.
This commit is contained in:
Yang Cen
2026-11-05 19:50:50 +08:00
committed by GitHub
parent 181ac2b567
commit 9ba1920e0e
3 changed files with 41 additions and 4 deletions
+8 -2
View File
@@ -1567,11 +1567,17 @@ describe.each(arrowVersions)("Arrow %#", (arrow: ApacheArrow) => {
const records = [
{ text: "dog", vector: [0.1, 0.2] },
{ text: "cat", vector: [0.3, 0.4] },
{ text: null, vector: null },
];
const table = await convertToTable(records);
const table = arrow.tableFromArrays({
text: records.map((row) => row.text),
vector: records.map((row) => row.vector),
});
const batch = table.batches[0];
const buffer = await fromRecordBatchToBuffer(batch);
const buffer = await fromRecordBatchToBuffer(
batch as Parameters<typeof fromRecordBatchToBuffer>[0],
);
const result = await fromBufferToRecordBatch(buffer);
expect(JSON.stringify(batch.toArray())).toEqual(
+30 -1
View File
@@ -1,13 +1,42 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import { spawn } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import * as path from "node:path";
import { RecordBatch } from "apache-arrow";
import * as tmp from "tmp";
import { Connection, Index, Table, connect, makeArrowTable } from "../lancedb";
import { RRFReranker } from "../lancedb/rerankers";
it("reranks record batches created by an ESM Arrow import", () => {
const script = `
import assert from "node:assert/strict";
import { tableFromArrays } from "apache-arrow";
import { rerankers } from "./dist/index.js";
const ids = [1n, 9007199254740993n];
const batch = tableFromArrays({ _rowid: new BigUint64Array(ids) }).batches[0];
const reranker = await rerankers.RRFReranker.create();
const result = await reranker.rerankHybrid("", batch, batch);
assert.equal(result.numRows, 2);
assert.deepEqual(Array.from(result.getChild("_rowid").toArray()), ids);
const scores = result.getChild("_relevance_score").toArray();
assert.ok(Math.abs(scores[0] - 2 / 61) < 1e-8);
assert.ok(Math.abs(scores[1] - 2 / 62) < 1e-8);
const oneSide = await reranker.rerankHybrid("", batch, batch.slice(0, 0));
assert.deepEqual(Array.from(oneSide.getChild("_rowid").toArray()), ids);
const oneSideScores = oneSide.getChild("_relevance_score").toArray();
assert.ok(Math.abs(oneSideScores[0] - 1 / 61) < 1e-8);
assert.ok(Math.abs(oneSideScores[1] - 1 / 62) < 1e-8);
`;
execFileSync(process.execPath, ["--input-type=module", "-e", script], {
cwd: path.resolve(__dirname, ".."),
timeout: 10_000,
});
});
describe("rerankers", function () {
let tmpDir: tmp.DirResult;
let conn: Connection;
+3 -1
View File
@@ -1386,7 +1386,9 @@ export async function fromBufferToRecordBatch(
export async function fromRecordBatchToBuffer(
batch: RecordBatch,
): Promise<Buffer> {
const writer = new RecordBatchFileWriter().writeAll([batch]);
// Arrow writers require data types from their own library instance.
const table = sanitizeTable({ schema: batch.schema, batches: [batch] });
const writer = new RecordBatchFileWriter().writeAll(table);
return Buffer.from(await writer.toUint8Array());
}