Skip to content
Open
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
28 changes: 20 additions & 8 deletions kits/bigquery-firestore-export/src/dts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import * as bigqueryDataTransfer from "@google-cloud/bigquery-data-transfer";
import { PermanentConfigurationError } from "./errors";
import type { ResolvedBigqueryFirestoreExportConfig } from "./export-config";
import * as logs from "./logs";

Expand All @@ -31,6 +32,13 @@ export const PARTITIONING_FIELD_REMOVAL_ERROR_PREFIX =
PACKAGE_PARTITIONING_ERROR;
export const PARTITIONING_FIELD_REMOVAL_ERROR = `${PACKAGE_PARTITIONING_ERROR}. The BigQuery Data Transfer API does not support clearing this parameter once it has been set. To change partitioning, create a new transfer config with the desired setting.`;

const STRUCTURE_ERROR_PREFIX = "Transfer config has invalid structure:";
// This check is only reached from the stored-document branch of the upsert,
// which runs when TRANSFER_CONFIG_NAME is unset, so that param is never the
// remedy here.
const STRUCTURE_REMEDIATION =
"Only scheduled queries are supported. Delete this deployment's document from the configs collection so a new scheduled query is created, then redeploy.";

function isNotFoundError(err: unknown): boolean {
return (
typeof err === "object" &&
Expand All @@ -43,18 +51,18 @@ function isNotFoundError(err: unknown): boolean {
function transferConfigFields(config: TransferConfig) {
const fields = config.params?.fields;
if (!fields) {
throw new Error(
"Transfer config has invalid structure: missing params.fields"
throw new PermanentConfigurationError(
`${STRUCTURE_ERROR_PREFIX} missing params.fields. ${STRUCTURE_REMEDIATION}`
);
}
if (!fields.query) {
throw new Error(
"Transfer config has invalid structure: missing params.fields.query"
throw new PermanentConfigurationError(
`${STRUCTURE_ERROR_PREFIX} missing params.fields.query. ${STRUCTURE_REMEDIATION}`
);
}
if (!fields.destination_table_name_template) {
throw new Error(
"Transfer config has invalid structure: missing params.fields.destination_table_name_template"
throw new PermanentConfigurationError(
`${STRUCTURE_ERROR_PREFIX} missing params.fields.destination_table_name_template. ${STRUCTURE_REMEDIATION}`
);
}

Expand Down Expand Up @@ -136,7 +144,11 @@ export async function constructUpdateTransferConfigRequest(
config: ResolvedBigqueryFirestoreExportConfig
): Promise<bigqueryDataTransfer.protos.google.cloud.bigquery.datatransfer.v1.IUpdateTransferConfigRequest> {
const transferConfig = await getTransferConfig(client, transferConfigName);
if (!transferConfig) throw new Error("Transfer config not found");
if (!transferConfig) {
throw new PermanentConfigurationError(
`Transfer config not found: ${transferConfigName}. The scheduled query recorded for this deployment no longer exists in BigQuery. Delete its document from the configs collection so a new query is created, then redeploy.`
);
}

const fields = transferConfigFields(transferConfig);
const updatedConfig = JSON.parse(
Expand Down Expand Up @@ -169,7 +181,7 @@ export async function constructUpdateTransferConfigRequest(
transferConfigName,
existingPartitioningField
);
throw new Error(PARTITIONING_FIELD_REMOVAL_ERROR);
throw new PermanentConfigurationError(PARTITIONING_FIELD_REMOVAL_ERROR);
Comment thread
IzaakGough marked this conversation as resolved.
}
updateMask.push("params");
updatedFields.partitioning_field ??= {};
Expand Down
28 changes: 28 additions & 0 deletions kits/bigquery-firestore-export/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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
*
* https://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.
*/

/**
* A deploy-time misconfiguration that no retry can resolve. Cloud Tasks retries
* every non-2xx response, so the upsert task handler reports these and returns
* rather than throwing. The message must name the misconfiguration and the
* action the user has to take.
*/
export class PermanentConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = "PermanentConfigurationError";
}
}
10 changes: 8 additions & 2 deletions kits/bigquery-firestore-export/src/export-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import type { Expression } from "firebase-functions/params";

import { PermanentConfigurationError } from "./errors";

/** Log levels supported by the original extension. */
export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";

Expand Down Expand Up @@ -77,7 +79,9 @@ export interface DeployTimeOptions {
function required(value: string, field: string): string {
const normalized = value.trim();
if (!normalized) {
throw new Error(`${field} must be a non-empty string.`);
throw new PermanentConfigurationError(
`${field} must be a non-empty string. Set it in the deployment configuration, then redeploy.`
);
}
return normalized;
}
Expand All @@ -96,7 +100,9 @@ export function resolveConfig(
const logLevel = config.logLevel ?? "info";

if (!["debug", "info", "warn", "error", "silent"].includes(logLevel)) {
throw new Error(`Unsupported logLevel: ${logLevel}`);
throw new PermanentConfigurationError(
`Unsupported logLevel: ${logLevel}. Use one of debug, info, warn, error, silent, then redeploy.`
);
}

return {
Expand Down
33 changes: 27 additions & 6 deletions kits/bigquery-firestore-export/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getTransferConfig,
updateTransferConfig,
} from "./dts";
import { PermanentConfigurationError } from "./errors";
import type { ResolvedBigqueryFirestoreExportConfig } from "./export-config";
import { handleTransferRunMessage, parseTransferConfigName } from "./helper";
import * as logs from "./logs";
Expand Down Expand Up @@ -93,10 +94,30 @@ export async function handleMessagePublished(
}
}

/** Idempotently creates, links, or updates this deployment's DTS config. */
/**
* Runs the upsert and stops permanently on a misconfiguration.
*
* Cloud Tasks retries every non-2xx response and the enqueued lifecycle task
* has no channel for reporting deploy status, so a failure that no retry can
* resolve is logged at error level and the task returns successfully.
*
* The context arrives as a factory so that config resolution runs inside the
* try. Resolving it in the caller would put param validation outside this
* catch, which is the one failure most certain that no retry can fix.
*/
export async function handleUpsertTransferConfig(
ctx: HandlerContext
getCtx: () => HandlerContext
): Promise<void> {
try {
await upsertTransferConfig(getCtx());
} catch (err) {
if (!(err instanceof PermanentConfigurationError)) throw err;
logs.upsertTransferConfigAborted(err);
}
}

/** Idempotently creates, links, or updates this deployment's DTS config. */
async function upsertTransferConfig(ctx: HandlerContext): Promise<void> {
await ensureNotificationTopic(ctx);

if (ctx.config.transferConfigName) {
Expand All @@ -105,8 +126,8 @@ export async function handleUpsertTransferConfig(
ctx.config.transferConfigName
);
if (!linked) {
throw new Error(
`Transfer config not found: ${ctx.config.transferConfigName}`
throw new PermanentConfigurationError(
`Transfer config not found: ${ctx.config.transferConfigName}. Set TRANSFER_CONFIG_NAME to a scheduled query that exists in this project, then redeploy. To have this deployment create its own scheduled query instead, clear TRANSFER_CONFIG_NAME and delete this instance's document from the ${ctx.config.firestoreCollection} collection, otherwise a previously linked query is updated in place.`
);
}
await storeTransferConfig(ctx, linked);
Expand All @@ -127,8 +148,8 @@ export async function handleUpsertTransferConfig(

const transferConfigName = existing.docs[0].data().name;
if (typeof transferConfigName !== "string" || !transferConfigName) {
throw new Error(
`Existing transfer config document in ${ctx.config.firestoreCollection} is missing required 'name' field.`
throw new PermanentConfigurationError(
`Existing transfer config document ${existing.docs[0].id} in ${ctx.config.firestoreCollection} is missing required 'name' field. Delete that document so this deployment creates a new scheduled query, or restore its 'name' field, then redeploy.`
);
}

Expand Down
2 changes: 1 addition & 1 deletion kits/bigquery-firestore-export/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,5 @@ export const upsertTransferConfig = onTaskDispatched(
memory: "1GiB",
retryConfig: { maxAttempts: 5, minBackoffSeconds: 30 },
},
() => handleUpsertTransferConfig(getContext())
() => handleUpsertTransferConfig(getContext)
);
1 change: 1 addition & 0 deletions kits/bigquery-firestore-export/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {
type TransferConfig,
updateTransferConfig,
} from "./dts";
export { PermanentConfigurationError } from "./errors";
export {
type BigqueryFirestoreExportConfig,
type DeployTimeOptions,
Expand Down
7 changes: 7 additions & 0 deletions kits/bigquery-firestore-export/src/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ export function partitioningFieldRemovalAttempted(
});
}

export function upsertTransferConfigAborted(err: Error): void {
logger.error(
"Could not set up the scheduled query. This is a configuration problem that a retry cannot fix, so the deployment task has stopped. Fix the configuration and deploy again.",
{ reason: err.message }
);
}

export function topicCreated(name: string): void {
logger.info("Created Pub/Sub topic for transfer notifications", { name });
}
61 changes: 61 additions & 0 deletions kits/bigquery-firestore-export/tests/dts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type DataTransferClient,
PARTITIONING_FIELD_REMOVAL_ERROR,
} from "../src/dts";
import { PermanentConfigurationError } from "../src/errors";
import { resolveConfig } from "../src/export-config";

const config = resolveConfig({
Expand Down Expand Up @@ -120,5 +121,65 @@ describe("constructUpdateTransferConfigRequest", () => {
{ ...config, partitioningField: undefined }
)
).rejects.toThrow(PARTITIONING_FIELD_REMOVAL_ERROR);

await expect(
constructUpdateTransferConfigRequest(
client,
"projects/p/locations/us/transferConfigs/c",
{ ...config, partitioningField: undefined }
)
).rejects.toBeInstanceOf(PermanentConfigurationError);
});

test.for([
["missing params.fields", {}],
[
"missing params.fields.query",
{ params: { fields: { destination_table_name_template: {} } } },
],
[
"missing params.fields.destination_table_name_template",
{ params: { fields: { query: { stringValue: "SELECT 1" } } } },
],
] as const)(
"reports a config the kit cannot update as permanent: %s",
async ([expectedMessage, shape]) => {
const client = clientWithTransferConfig({
name: "projects/p/locations/us/transferConfigs/c",
...shape,
});

const rejects = expect(
constructUpdateTransferConfigRequest(
client,
"projects/p/locations/us/transferConfigs/c",
config
)
).rejects;

await rejects.toBeInstanceOf(PermanentConfigurationError);
await rejects.toThrow(
`Transfer config has invalid structure: ${expectedMessage}`
);
await rejects.toThrow("Only scheduled queries are supported");
await rejects.toThrow(
"Delete this deployment's document from the configs collection"
);
}
);

test("reports a vanished transfer config as permanent", async () => {
const notFound = Object.assign(new Error("5 NOT_FOUND"), { code: 5 });
const client = {
getTransferConfig: vi.fn().mockRejectedValue(notFound),
} as unknown as DataTransferClient;

await expect(
constructUpdateTransferConfigRequest(
client,
"projects/p/locations/us/transferConfigs/gone",
config
)
).rejects.toThrow(PermanentConfigurationError);
});
});
15 changes: 14 additions & 1 deletion kits/bigquery-firestore-export/tests/export-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { describe, expect, test } from "vitest";
import { PermanentConfigurationError } from "../src/errors";
import {
type BigqueryFirestoreExportConfig,
resolveConfig,
Expand Down Expand Up @@ -62,7 +63,7 @@ describe("resolveConfig", () => {
"rejects an empty %s",
(field) => {
expect(() => resolveConfig({ ...minimal, [field]: " " })).toThrow(
`${field} must be a non-empty string.`
`${field} must be a non-empty string. Set it in the deployment configuration, then redeploy.`
);
}
);
Expand All @@ -75,4 +76,16 @@ describe("resolveConfig", () => {
})
).toThrow("Unsupported logLevel: verbose");
});

test("reports validation failures as permanent", () => {
expect(() => resolveConfig({ ...minimal, datasetId: " " })).toThrow(
PermanentConfigurationError
);
expect(() =>
resolveConfig({
...minimal,
logLevel: "verbose" as BigqueryFirestoreExportConfig["logLevel"],
})
).toThrow(PermanentConfigurationError);
});
});
Loading
Loading