Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions kits/bigquery-firestore-export/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ import {
Geography,
} from "@google-cloud/bigquery";
import {
type DocumentData,
type DocumentReference,
type Firestore,
Timestamp,
type WriteResult,
} from "firebase-admin/firestore";
import type { ResolvedBigqueryFirestoreExportConfig } from "./export-config";
import * as logs from "./logs";
Expand Down Expand Up @@ -62,6 +61,17 @@ const TRANSFER_RUN_NAME_REGEX =
const TRANSFER_CONFIG_NAME_REGEX =
/^projects\/([^/]+)\/locations\/([^/]+)\/transferConfigs\/([^/]+)$/;
const FIRESTORE_WRITE_CHUNK_SIZE = 10_000;
const OUTPUT_DOC_ID_LENGTH = 12;

/**
* Document id for the row at `index` of a run's results. `processMessages`
* deploys with `retry: true`, so a run that dies part way through is redelivered
* and rewritten; keying output documents by row index makes that rewrite an
* overwrite instead of a second copy of the result set.
*/
function outputDocumentId(index: number): string {
return String(index).padStart(OUTPUT_DOC_ID_LENGTH, "0");
}
Comment on lines +72 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Non-Deterministic Row Ordering Risk

Using the row's index as the document ID is unsafe because BigQuery does not guarantee the order of query results for SELECT * without an explicit ORDER BY clause.

On retries or redeliveries (which this PR aims to handle safely), BigQuery may return the rows in a different order. If the order changes:

  1. The row index mapping will change.
  2. A retry will overwrite existing documents with different row data.
  3. This leads to silent data corruption (some rows will be duplicated under different index IDs, while other rows will be completely lost).

Recommended Solution

Instead of using the row index, generate a deterministic ID by hashing the row content (e.g., using SHA-256). This guarantees 100% idempotency and correctness regardless of the query result order.

Note: You will also need to import crypto at the top of the file: import * as crypto from "crypto";

Suggested change
function outputDocumentId(index: number): string {
return String(index).padStart(OUTPUT_DOC_ID_LENGTH, "0");
}
function outputDocumentId(row: unknown): string {
const serialized = JSON.stringify(row, Object.keys(row as object).sort());
return crypto.createHash("sha256").update(serialized).digest("hex");
}


export function parseTransferRunName(name: string): ParsedTransferRunName {
const match = name.match(TRANSFER_RUN_NAME_REGEX);
Expand Down Expand Up @@ -233,13 +243,17 @@ export async function writeRunResultsToFirestore(
let succeededRowCount = 0;

for (let i = 0; i < rows.length; i += FIRESTORE_WRITE_CHUNK_SIZE) {
const writes: Array<Promise<DocumentReference<DocumentData>>> = [];
const writes: Array<Promise<WriteResult>> = [];
for (
let j = i;
j < i + FIRESTORE_WRITE_CHUNK_SIZE && j < rows.length;
j++
) {
writes.push(collection.add(convertUnsupportedDataTypes(rows[j])));
writes.push(
collection
.doc(outputDocumentId(j))
.set(convertUnsupportedDataTypes(rows[j]))
);
Comment on lines +252 to +256

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the call site to pass the actual row object rows[j] to outputDocumentId instead of the index j to support deterministic hashing.

Suggested change
writes.push(
collection
.doc(outputDocumentId(j))
.set(convertUnsupportedDataTypes(rows[j]))
);
writes.push(
collection
.doc(outputDocumentId(rows[j]))
.set(convertUnsupportedDataTypes(rows[j]))
);

}

const results = await Promise.allSettled(writes);
Expand Down
159 changes: 158 additions & 1 deletion kits/bigquery-firestore-export/tests/helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,29 @@
* limitations under the License.
*/

import type { BigQuery } from "@google-cloud/bigquery";
import { Geography } from "@google-cloud/bigquery";
import type { Firestore } from "firebase-admin/firestore";
import { Timestamp } from "firebase-admin/firestore";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { resolveConfig } from "../src/export-config";
import {
convertUnsupportedDataTypes,
parseTransferConfigName,
parseTransferRunName,
writeRunResultsToFirestore,
} from "../src/helper";
import type { BigQueryRow, TransferRunMessage } from "../src/types";

vi.mock("../src/logs", () => ({
bigqueryJobStarted: vi.fn(),
bigqueryQueryFailed: vi.fn(),
bigqueryResultsRowCount: vi.fn(),
errorWritingToFirestore: vi.fn(),
handlingNonSuccessRun: vi.fn(),
latestDocUpdateSkipped: vi.fn(),
runResultsWrittenToFirestore: vi.fn(),
}));

describe("transfer resource parsing", () => {
test("parses config and run resource names", () => {
Expand Down Expand Up @@ -72,3 +87,145 @@ describe("convertUnsupportedDataTypes", () => {
expect(converted.nested).toEqual([{ value: true }]);
});
});

const CONFIG = resolveConfig({
bigqueryDatasetLocation: "US",
projectId: "test-project",
instanceId: "users-export",
datasetId: "analytics",
tableName: "out",
queryString: "SELECT * FROM source.users",
displayName: "Users export",
schedule: "every 24 hours",
firestoreCollection: "transferConfigs",
});
const RUN_ID = "run-1";
const RUNS_PATH = "transferConfigs/config-1/runs";
const OUTPUT_PATH = `${RUNS_PATH}/${RUN_ID}/output`;
const MESSAGE = {
json: {
name: `projects/test-project/locations/us/transferConfigs/config-1/runs/${RUN_ID}`,
runTime: "2026-08-20T10:05:39Z",
state: "SUCCEEDED",
destinationDatasetId: "analytics",
params: { destination_table_name_template: 'out_{run_time|"%H%M%S"}' },
},
} as unknown as TransferRunMessage;

/** Minimal Firestore double keyed by collection path and document id. */
function makeDb(rejectDocIds: string[] = []) {
const collections = new Map<string, Map<string, unknown>>();
const rejected = new Set(rejectDocIds);

const docsFor = (path: string) => {
const existing = collections.get(path);
if (existing) return existing;
const created = new Map<string, unknown>();
collections.set(path, created);
return created;
};
const docRef = (path: string, id: string) => ({
set(data: unknown) {
if (rejected.has(id)) {
return Promise.reject(new Error(`write refused for ${id}`));
}
docsFor(path).set(id, data);
return Promise.resolve();
},
read: () => docsFor(path).get(id),
});
const db = {
collection: (path: string) => ({ doc: (id: string) => docRef(path, id) }),
runTransaction: (
fn: (tx: {
get: (
ref: ReturnType<typeof docRef>
) => Promise<{ data: () => unknown }>;
set: (ref: ReturnType<typeof docRef>, data: unknown) => void;
}) => Promise<void>
) =>
fn({
get: (ref) => Promise.resolve({ data: () => ref.read() }),
set: (ref, data) => void ref.set(data),
}),
} as unknown as Firestore;

return {
db,
docs: (path: string) => docsFor(path),
output: () => docsFor(OUTPUT_PATH),
};
}

function makeBigquery(rows: BigQueryRow[]) {
return {
createQueryJob: () =>
Promise.resolve([
{ id: "job-1", getQueryResults: () => Promise.resolve([rows]) },
]),
} as unknown as BigQuery;
}

function rows(count: number): BigQueryRow[] {
return Array.from({ length: count }, (_unused, index) => ({
id: index,
label: `row-${index}`,
}));
}

describe("writeRunResultsToFirestore", () => {
test("keys each output document by its zero-padded row index", async () => {
const { db, output } = makeDb();

await writeRunResultsToFirestore(
{ db, bigquery: makeBigquery(rows(3)), config: CONFIG },
MESSAGE
);

expect([...output().keys()]).toEqual([
"000000000000",
"000000000001",
"000000000002",
]);
expect(output().get("000000000001")).toEqual({ id: 1, label: "row-1" });
Comment on lines +185 to +190

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the test assertions to expect the deterministic SHA-256 hashes of the rows instead of the zero-padded index IDs.

Suggested change
expect([...output().keys()]).toEqual([
"000000000000",
"000000000001",
"000000000002",
]);
expect(output().get("000000000001")).toEqual({ id: 1, label: "row-1" });
const expectedKeys = rows(3).map(row => {
const serialized = JSON.stringify(row, Object.keys(row).sort());
return require("crypto").createHash("sha256").update(serialized).digest("hex");
});
expect([...output().keys()]).toEqual(expectedKeys);
expect(output().get(expectedKeys[1])).toEqual({ id: 1, label: "row-1" });

});

test("a redelivered run overwrites its output instead of appending", async () => {
const { db, output } = makeDb();
const ctx = { db, bigquery: makeBigquery(rows(50)), config: CONFIG };

await writeRunResultsToFirestore(ctx, MESSAGE);
const firstPass = [...output().keys()];
await writeRunResultsToFirestore(ctx, MESSAGE);

expect(output().size).toBe(50);
expect([...output().keys()]).toEqual(firstPass);
});

test("continues past a chunk boundary without restarting ids", async () => {
const { db, output } = makeDb();

await writeRunResultsToFirestore(
{ db, bigquery: makeBigquery(rows(10_001)), config: CONFIG },
MESSAGE
);

expect(output().size).toBe(10_001);
expect(output().has("000000010000")).toBe(true);
});

test("counts a rejected row without dropping the others", async () => {
const { db, docs, output } = makeDb(["000000000001"]);

await writeRunResultsToFirestore(
{ db, bigquery: makeBigquery(rows(3)), config: CONFIG },
MESSAGE
);

expect([...output().keys()]).toEqual(["000000000000", "000000000002"]);
Comment on lines +217 to +225

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the rejected row test to dynamically compute the SHA-256 hash of the rejected row, ensuring the test remains correct and passes with the new hashing strategy.

  test("counts a rejected row without dropping the others", async () => {
    const testRows = rows(3);
    const serializedRow1 = JSON.stringify(testRows[1], Object.keys(testRows[1]).sort());
    const rejectedId = require("crypto").createHash("sha256").update(serializedRow1).digest("hex");
    const { db, docs, output } = makeDb([rejectedId]);

    await writeRunResultsToFirestore(
      { db, bigquery: makeBigquery(testRows), config: CONFIG },
      MESSAGE
    );

    const expectedKeys = [testRows[0], testRows[2]].map(row => {
      const serialized = JSON.stringify(row, Object.keys(row).sort());
      return require("crypto").createHash("sha256").update(serialized).digest("hex");
    });
    expect([...output().keys()]).toEqual(expectedKeys);

expect(docs(RUNS_PATH).get(RUN_ID)).toMatchObject({
failedRowCount: 1,
totalRowCount: 3,
});
});
});
Loading