From 0e5f1d1fa3061f99a45210d1f4b3812d53007c2e Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 20 Oct 2026 14:04:39 +0100 Subject: [PATCH 1/4] fix(delete-user-data): match the extension's cpu, concurrency and ingress The kit's functions took the 2nd gen defaults (concurrency 80, 1 vCPU, ALLOW_ALL). With auto-discovery on, one 256MiB handleSearch instance took many searches at once, ran out of memory and dropped them, so user data was left undeleted. Pin concurrency 1, cpu gcf_gen1 and internal-only ingress on clearData, handleSearch and handleDeletion. Fixes #3250 --- kits/delete-user-data/CHANGELOG.md | 2 ++ kits/delete-user-data/README.md | 9 +++++++++ kits/delete-user-data/src/index.ts | 17 ++++++++++++++++- kits/delete-user-data/tests/index.test.ts | 21 ++++++++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/kits/delete-user-data/CHANGELOG.md b/kits/delete-user-data/CHANGELOG.md index 0e40c6a6fc..39a68990af 100644 --- a/kits/delete-user-data/CHANGELOG.md +++ b/kits/delete-user-data/CHANGELOG.md @@ -1,3 +1,5 @@ +- fix: deploy `clearData`, `handleSearch` and `handleDeletion` with the extension's runtime settings: `concurrency: 1`, `cpu: "gcf_gen1"` (0.1666 vCPU) and `ingressSettings: "ALLOW_INTERNAL_ONLY"`. Before, the kit took the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. With auto-discovery on, one 256MiB `handleSearch` instance took many searches at once, ran out of memory, and dropped every search in flight on it without an error, so user data was left undeleted. Existing kit deployments switch on their next deploy. + - fix: an Eventarc publish failure no longer fails the function. A channel that was deleted or is unreachable answers `PERMISSION_DENIED`, and the publish was awaited after the work, so the deletion completed but the invocation still failed. The publish failure is now logged as a warning and the invocation continues. Deploys also declare `eventarcpublishing.googleapis.com`, so the API is enabled on your project whether or not you use events. The failed invocation used to be redelivered, re-running a deletion that had already succeeded; it no longer is. - fix: `ENABLE_AUTO_DISCOVERY` accepts the extension's `yes` / `no` values again. The kit had declared it as a boolean param, which only counts the literal `true` as enabled, so a `.env` copied from an installed extension instance carried `yes` and silently left auto-discovery switched off. The param is now the extension's labeled `Yes` / `No` select and is read the same way the extension read it. diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index 465d7a7cd0..ed49dbedef 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -202,6 +202,15 @@ Auth event `google.firebase.auth.user.v2.deleted` instead of the 1st gen This mainly matters if you have infrastructure or alerting keyed to function generation. +Each function still runs like the extension's: one event per instance +(`concurrency: 1`), 0.1666 vCPU (`cpu: "gcf_gen1"`) and internal-only ingress, +rather than the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. +One difference remains: 2nd gen caps each function at 100 instances by default, +where the extension's 1st gen functions could scale to 3000. To raise it, call +`setGlobalOptions({ maxInstances })` from `firebase-functions/v2` in your +codebase before you load the kit, since the kit's functions read the global +options when they are defined. + ### Events need an existing channel, and their source has changed The extension created its Eventarc channel when you installed it with events diff --git a/kits/delete-user-data/src/index.ts b/kits/delete-user-data/src/index.ts index cca4a43d0f..45f637d097 100644 --- a/kits/delete-user-data/src/index.ts +++ b/kits/delete-user-data/src/index.ts @@ -109,7 +109,20 @@ function getContext(): HandlerContext { return ctx; } -export const clearData = onUserDeleted((event) => { +/** + * The extension's 1st gen functions ran at 0.1666 vCPU, one event per + * instance, with internal-only ingress. Without these options the CLI deploys + * the 2nd gen defaults: 1 vCPU, concurrency 80 and `ALLOW_ALL`. At concurrency + * 80, auto-discovery fans many `handleSearch` events onto one 256MiB instance, + * which runs out of memory and drops every event in flight on it. + */ +const EXTENSION_RUNTIME_OPTIONS = { + concurrency: 1, + cpu: "gcf_gen1", + ingressSettings: "ALLOW_INTERNAL_ONLY", +} as const; + +export const clearData = onUserDeleted(EXTENSION_RUNTIME_OPTIONS, (event) => { // The Auth event delivers no user record when the payload envelope is empty, // so bail before getContext() rather than initialising the SDKs for nothing. const uid = event.data?.uid; @@ -122,6 +135,7 @@ export const clearData = onUserDeleted((event) => { export const handleSearch = onMessagePublished( { + ...EXTENSION_RUNTIME_OPTIONS, topic: CONFIG_EXPRESSIONS.discoveryTopicName, }, (event) => runSearch(event.data.message.json, getContext()) @@ -129,6 +143,7 @@ export const handleSearch = onMessagePublished( export const handleDeletion = onMessagePublished( { + ...EXTENSION_RUNTIME_OPTIONS, topic: CONFIG_EXPRESSIONS.deletionTopicName, }, (event) => runDeletion(event.data.message.json, getContext()) diff --git a/kits/delete-user-data/tests/index.test.ts b/kits/delete-user-data/tests/index.test.ts index b031ab6387..2d2ac68986 100644 --- a/kits/delete-user-data/tests/index.test.ts +++ b/kits/delete-user-data/tests/index.test.ts @@ -23,7 +23,7 @@ vi.mock("../src/handlers", async (importOriginal) => ({ })); import { handleClear } from "../src/handlers"; -import { clearData } from "../src/index"; +import { clearData, handleDeletion, handleSearch } from "../src/index"; import * as logs from "../src/logs"; function deletionEvent(data: unknown) { @@ -68,3 +68,22 @@ describe("clearData", () => { expect(logs.deletionEventMissingUid).toHaveBeenCalledWith("event-id"); }); }); + +describe("runtime options", () => { + // Concurrency 80 let auto-discovery fan many searches onto one 256MiB + // instance, which ran out of memory and dropped them. + test.each([ + ["clearData", clearData], + ["handleSearch", handleSearch], + ["handleDeletion", handleDeletion], + ])( + "%s runs one event per 0.1666 vCPU instance with internal-only ingress", + (_name, fn) => { + const endpoint = (fn as any).__endpoint; + + expect(endpoint.concurrency).toBe(1); + expect(endpoint.cpu).toBe("gcf_gen1"); + expect(endpoint.ingressSettings).toBe("ALLOW_INTERNAL_ONLY"); + } + ); +}); From 89dfcc472795489924c7ba36d576b8d44b4a0419 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 27 Oct 2026 10:10:00 +0000 Subject: [PATCH 2/4] fix(delete-user-data): keep the globally set ingress instead of pinning it --- kits/delete-user-data/CHANGELOG.md | 2 +- kits/delete-user-data/README.md | 2 ++ kits/delete-user-data/src/index.ts | 8 +++++++- kits/delete-user-data/tests/index.test.ts | 23 ++++++++++++++++++++++- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/kits/delete-user-data/CHANGELOG.md b/kits/delete-user-data/CHANGELOG.md index 39a68990af..cd182b2159 100644 --- a/kits/delete-user-data/CHANGELOG.md +++ b/kits/delete-user-data/CHANGELOG.md @@ -1,4 +1,4 @@ -- fix: deploy `clearData`, `handleSearch` and `handleDeletion` with the extension's runtime settings: `concurrency: 1`, `cpu: "gcf_gen1"` (0.1666 vCPU) and `ingressSettings: "ALLOW_INTERNAL_ONLY"`. Before, the kit took the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. With auto-discovery on, one 256MiB `handleSearch` instance took many searches at once, ran out of memory, and dropped every search in flight on it without an error, so user data was left undeleted. Existing kit deployments switch on their next deploy. +- fix: deploy `clearData`, `handleSearch` and `handleDeletion` with the extension's runtime settings: `concurrency: 1`, `cpu: "gcf_gen1"` (0.1666 vCPU) and `ingressSettings: "ALLOW_INTERNAL_ONLY"`, unless `ingressSettings` is already set with `setGlobalOptions`. Before, the kit took the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. With auto-discovery on, one 256MiB `handleSearch` instance took many searches at once, ran out of memory, and dropped every search in flight on it without an error, so user data was left undeleted. Existing kit deployments switch on their next deploy. - fix: an Eventarc publish failure no longer fails the function. A channel that was deleted or is unreachable answers `PERMISSION_DENIED`, and the publish was awaited after the work, so the deletion completed but the invocation still failed. The publish failure is now logged as a warning and the invocation continues. Deploys also declare `eventarcpublishing.googleapis.com`, so the API is enabled on your project whether or not you use events. The failed invocation used to be redelivered, re-running a deletion that had already succeeded; it no longer is. diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index ed49dbedef..ef4929f234 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -205,6 +205,8 @@ generation. Each function still runs like the extension's: one event per instance (`concurrency: 1`), 0.1666 vCPU (`cpu: "gcf_gen1"`) and internal-only ingress, rather than the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. +If you set `ingressSettings` with `setGlobalOptions` before you load the kit, as +`ext:migrate` does, the kit uses that instead of internal-only ingress. One difference remains: 2nd gen caps each function at 100 instances by default, where the extension's 1st gen functions could scale to 3000. To raise it, call `setGlobalOptions({ maxInstances })` from `firebase-functions/v2` in your diff --git a/kits/delete-user-data/src/index.ts b/kits/delete-user-data/src/index.ts index 45f637d097..538a636c47 100644 --- a/kits/delete-user-data/src/index.ts +++ b/kits/delete-user-data/src/index.ts @@ -20,6 +20,7 @@ import { getFirestore } from "firebase-admin/firestore"; import type { Role } from "firebase-functions/v2"; import { requiresAPI, requiresRole } from "firebase-functions/v2"; import { onUserDeleted } from "firebase-functions/v2/identity"; +import { __getSpec } from "firebase-functions/v2/options"; import { onMessagePublished } from "firebase-functions/v2/pubsub"; import { assertRequiredParams, @@ -115,11 +116,16 @@ function getContext(): HandlerContext { * the 2nd gen defaults: 1 vCPU, concurrency 80 and `ALLOW_ALL`. At concurrency * 80, auto-discovery fans many `handleSearch` events onto one 256MiB instance, * which runs out of memory and drops every event in flight on it. + * + * Ingress was an install-time setting on the extension, which `ext:migrate` + * carries over with `setGlobalOptions`. A function-level option always beats a + * global one, so only default ingress when no global value is set. */ const EXTENSION_RUNTIME_OPTIONS = { concurrency: 1, cpu: "gcf_gen1", - ingressSettings: "ALLOW_INTERNAL_ONLY", + ingressSettings: + __getSpec().globalOptions.ingressSettings ?? "ALLOW_INTERNAL_ONLY", } as const; export const clearData = onUserDeleted(EXTENSION_RUNTIME_OPTIONS, (event) => { diff --git a/kits/delete-user-data/tests/index.test.ts b/kits/delete-user-data/tests/index.test.ts index 2d2ac68986..aadcf00e55 100644 --- a/kits/delete-user-data/tests/index.test.ts +++ b/kits/delete-user-data/tests/index.test.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { setGlobalOptions } from "firebase-functions/v2"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("../src/logs"); vi.mock("../src/handlers", async (importOriginal) => ({ @@ -86,4 +87,24 @@ describe("runtime options", () => { expect(endpoint.ingressSettings).toBe("ALLOW_INTERNAL_ONLY"); } ); + + describe("with ingress set globally", () => { + afterEach(() => { + delete (globalThis as any)[ + Symbol.for("firebase-functions:v2:globalOptions") + ].value; + }); + + // `ext:migrate` carries the extension's ingress over with + // setGlobalOptions, which a function-level option would override. + test("keeps the global ingress", async () => { + setGlobalOptions({ ingressSettings: "ALLOW_ALL" }); + vi.resetModules(); + const kit = await import("../src/index"); + + for (const fn of [kit.clearData, kit.handleSearch, kit.handleDeletion]) { + expect((fn as any).__endpoint.ingressSettings).toBe("ALLOW_ALL"); + } + }); + }); }); From 7da615830410a592a87bad28041060867173ef9a Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 27 Oct 2026 11:14:01 +0000 Subject: [PATCH 3/4] docs(delete-user-data): explain the fixed runtime options and test global options still apply --- kits/delete-user-data/README.md | 12 +++++++---- kits/delete-user-data/tests/index.test.ts | 25 ++++++++++++++++------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index ef4929f234..0c3f8707cc 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -203,10 +203,14 @@ This mainly matters if you have infrastructure or alerting keyed to function generation. Each function still runs like the extension's: one event per instance -(`concurrency: 1`), 0.1666 vCPU (`cpu: "gcf_gen1"`) and internal-only ingress, -rather than the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. -If you set `ingressSettings` with `setGlobalOptions` before you load the kit, as -`ext:migrate` does, the kit uses that instead of internal-only ingress. +(`concurrency: 1`) with the 1st gen CPU for its memory (`cpu: "gcf_gen1"`, +0.1666 vCPU at the default 256MiB) and internal-only ingress, rather than the +2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. `setGlobalOptions` +can't change concurrency or CPU. If you set `ingressSettings` with +`setGlobalOptions` before you load the kit, as `ext:migrate` does, the kit uses +that instead of internal-only ingress. During a burst, Cloud Run may log +`no available instance` errors while it scales; Eventarc redelivers those +events. One difference remains: 2nd gen caps each function at 100 instances by default, where the extension's 1st gen functions could scale to 3000. To raise it, call `setGlobalOptions({ maxInstances })` from `firebase-functions/v2` in your diff --git a/kits/delete-user-data/tests/index.test.ts b/kits/delete-user-data/tests/index.test.ts index aadcf00e55..e8dd096e9a 100644 --- a/kits/delete-user-data/tests/index.test.ts +++ b/kits/delete-user-data/tests/index.test.ts @@ -78,7 +78,7 @@ describe("runtime options", () => { ["handleSearch", handleSearch], ["handleDeletion", handleDeletion], ])( - "%s runs one event per 0.1666 vCPU instance with internal-only ingress", + "%s runs one event per gen1 CPU instance with internal-only ingress", (_name, fn) => { const endpoint = (fn as any).__endpoint; @@ -88,22 +88,33 @@ describe("runtime options", () => { } ); - describe("with ingress set globally", () => { + describe("with global options set", () => { afterEach(() => { delete (globalThis as any)[ Symbol.for("firebase-functions:v2:globalOptions") ].value; }); - // `ext:migrate` carries the extension's ingress over with - // setGlobalOptions, which a function-level option would override. - test("keeps the global ingress", async () => { - setGlobalOptions({ ingressSettings: "ALLOW_ALL" }); + // The README tells users to raise maxInstances and set the region with + // setGlobalOptions, and `ext:migrate` carries ingress over the same way, + // so the kit's function-level options must not override them. + test("keeps the global options and the extension's runtime", async () => { + setGlobalOptions({ + ingressSettings: "ALLOW_ALL", + maxInstances: 3000, + region: "europe-west1", + }); vi.resetModules(); const kit = await import("../src/index"); for (const fn of [kit.clearData, kit.handleSearch, kit.handleDeletion]) { - expect((fn as any).__endpoint.ingressSettings).toBe("ALLOW_ALL"); + const endpoint = (fn as any).__endpoint; + + expect(endpoint.ingressSettings).toBe("ALLOW_ALL"); + expect(endpoint.maxInstances).toBe(3000); + expect(endpoint.region).toEqual(["europe-west1"]); + expect(endpoint.concurrency).toBe(1); + expect(endpoint.cpu).toBe("gcf_gen1"); } }); }); From 9be7ef33786124be47c34a250f22005a5f2a87e2 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 27 Oct 2026 15:45:39 +0000 Subject: [PATCH 4/4] fix(delete-user-data): always use internal-only ingress --- kits/delete-user-data/CHANGELOG.md | 2 +- kits/delete-user-data/README.md | 5 ++--- kits/delete-user-data/src/index.ts | 9 +++------ kits/delete-user-data/tests/index.test.ts | 6 +++--- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/kits/delete-user-data/CHANGELOG.md b/kits/delete-user-data/CHANGELOG.md index cd182b2159..a69845f224 100644 --- a/kits/delete-user-data/CHANGELOG.md +++ b/kits/delete-user-data/CHANGELOG.md @@ -1,4 +1,4 @@ -- fix: deploy `clearData`, `handleSearch` and `handleDeletion` with the extension's runtime settings: `concurrency: 1`, `cpu: "gcf_gen1"` (0.1666 vCPU) and `ingressSettings: "ALLOW_INTERNAL_ONLY"`, unless `ingressSettings` is already set with `setGlobalOptions`. Before, the kit took the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. With auto-discovery on, one 256MiB `handleSearch` instance took many searches at once, ran out of memory, and dropped every search in flight on it without an error, so user data was left undeleted. Existing kit deployments switch on their next deploy. +- fix: deploy `clearData`, `handleSearch` and `handleDeletion` with the extension's runtime settings: `concurrency: 1`, `cpu: "gcf_gen1"` (0.1666 vCPU) and `ingressSettings: "ALLOW_INTERNAL_ONLY"`. `setGlobalOptions` can't change these. Before, the kit took the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. With auto-discovery on, one 256MiB `handleSearch` instance took many searches at once, ran out of memory, and dropped every search in flight on it without an error, so user data was left undeleted. Existing kit deployments switch on their next deploy. - fix: an Eventarc publish failure no longer fails the function. A channel that was deleted or is unreachable answers `PERMISSION_DENIED`, and the publish was awaited after the work, so the deletion completed but the invocation still failed. The publish failure is now logged as a warning and the invocation continues. Deploys also declare `eventarcpublishing.googleapis.com`, so the API is enabled on your project whether or not you use events. The failed invocation used to be redelivered, re-running a deletion that had already succeeded; it no longer is. diff --git a/kits/delete-user-data/README.md b/kits/delete-user-data/README.md index 0c3f8707cc..54d7d5247b 100644 --- a/kits/delete-user-data/README.md +++ b/kits/delete-user-data/README.md @@ -206,9 +206,8 @@ Each function still runs like the extension's: one event per instance (`concurrency: 1`) with the 1st gen CPU for its memory (`cpu: "gcf_gen1"`, 0.1666 vCPU at the default 256MiB) and internal-only ingress, rather than the 2nd gen defaults of concurrency `80`, 1 vCPU and `ALLOW_ALL`. `setGlobalOptions` -can't change concurrency or CPU. If you set `ingressSettings` with -`setGlobalOptions` before you load the kit, as `ext:migrate` does, the kit uses -that instead of internal-only ingress. During a burst, Cloud Run may log +can't change concurrency, CPU or ingress, so an ingress setting carried over by +`ext:migrate` doesn't apply to the kit. During a burst, Cloud Run may log `no available instance` errors while it scales; Eventarc redelivers those events. One difference remains: 2nd gen caps each function at 100 instances by default, diff --git a/kits/delete-user-data/src/index.ts b/kits/delete-user-data/src/index.ts index 538a636c47..2d981b536f 100644 --- a/kits/delete-user-data/src/index.ts +++ b/kits/delete-user-data/src/index.ts @@ -20,7 +20,6 @@ import { getFirestore } from "firebase-admin/firestore"; import type { Role } from "firebase-functions/v2"; import { requiresAPI, requiresRole } from "firebase-functions/v2"; import { onUserDeleted } from "firebase-functions/v2/identity"; -import { __getSpec } from "firebase-functions/v2/options"; import { onMessagePublished } from "firebase-functions/v2/pubsub"; import { assertRequiredParams, @@ -117,15 +116,13 @@ function getContext(): HandlerContext { * 80, auto-discovery fans many `handleSearch` events onto one 256MiB instance, * which runs out of memory and drops every event in flight on it. * - * Ingress was an install-time setting on the extension, which `ext:migrate` - * carries over with `setGlobalOptions`. A function-level option always beats a - * global one, so only default ingress when no global value is set. + * Ingress is always internal-only, so `setGlobalOptions({ ingressSettings })` + * doesn't change it, as in the firestore-bigquery-export kit. */ const EXTENSION_RUNTIME_OPTIONS = { concurrency: 1, cpu: "gcf_gen1", - ingressSettings: - __getSpec().globalOptions.ingressSettings ?? "ALLOW_INTERNAL_ONLY", + ingressSettings: "ALLOW_INTERNAL_ONLY", } as const; export const clearData = onUserDeleted(EXTENSION_RUNTIME_OPTIONS, (event) => { diff --git a/kits/delete-user-data/tests/index.test.ts b/kits/delete-user-data/tests/index.test.ts index e8dd096e9a..c8709200e8 100644 --- a/kits/delete-user-data/tests/index.test.ts +++ b/kits/delete-user-data/tests/index.test.ts @@ -96,8 +96,8 @@ describe("runtime options", () => { }); // The README tells users to raise maxInstances and set the region with - // setGlobalOptions, and `ext:migrate` carries ingress over the same way, - // so the kit's function-level options must not override them. + // setGlobalOptions, so the kit's function-level options must not override + // them. Ingress stays internal-only, like the extension's. test("keeps the global options and the extension's runtime", async () => { setGlobalOptions({ ingressSettings: "ALLOW_ALL", @@ -110,7 +110,7 @@ describe("runtime options", () => { for (const fn of [kit.clearData, kit.handleSearch, kit.handleDeletion]) { const endpoint = (fn as any).__endpoint; - expect(endpoint.ingressSettings).toBe("ALLOW_ALL"); + expect(endpoint.ingressSettings).toBe("ALLOW_INTERNAL_ONLY"); expect(endpoint.maxInstances).toBe(3000); expect(endpoint.region).toEqual(["europe-west1"]); expect(endpoint.concurrency).toBe(1);