diff --git a/kits/firestore-vector-search/CHANGELOG.md b/kits/firestore-vector-search/CHANGELOG.md index 711eb60d36..d09896eb76 100644 --- a/kits/firestore-vector-search/CHANGELOG.md +++ b/kits/firestore-vector-search/CHANGELOG.md @@ -1 +1,2 @@ - Initial release of kit, see README for differences between the legacy extension and this kit +- No Eventarc events are published, matching the extension. The extension declares `onStart`, `onSuccess`, `onError` and `onCompletion` under `firebase.extensions.firestore-vector-search.v1.*` in its `extension.yaml` but never publishes any of them. Earlier `0.0.2-rc` builds of this kit published all four from `embedOnWrite` when `EVENTARC_CHANNEL` was set; they no longer do, and `EVENTARC_CHANNEL` is no longer read. If you subscribed to those events on an rc build, the subscription now receives nothing. diff --git a/kits/firestore-vector-search/README.md b/kits/firestore-vector-search/README.md index 0552ca77b5..28709cc417 100644 --- a/kits/firestore-vector-search/README.md +++ b/kits/firestore-vector-search/README.md @@ -144,12 +144,6 @@ the instances cannot collide. Set `INSTANCE_ID` in each config directory to the same value as that directory's key in the `instances` map; it also namespaces the internal Firestore metadata/query paths and task queue references. -## Events - -When `EVENTARC_CHANNEL` is configured, the functions publish lifecycle events -such as `onStart`, `onError`, `onSuccess`, and `onCompletion` under -`firebase.extensions.firestore-vector-search.v1.*`. - ## Differences from the Vector Search with Firestore extension This kit is version 0.1.3 of the extension repackaged as an npm package, and it is @@ -283,16 +277,6 @@ rather than the install-time location. Gemini embedding is not served in every region; if you deploy somewhere it is unavailable, embedding fails and the error is written to the document's status field. -### Events are actually published now - -The extension declared four event types but never published any. The kit -publishes `onStart`, `onSuccess`, `onError` and `onCompletion` under -`firebase.extensions.firestore-vector-search.v1.*` from `embedOnWrite`, once you -set `EVENTARC_CHANNEL` in your `.env` to a channel you have created. Per-event -selection is not available, because the CLI rejects any `.env` key beginning with -`EXT_`, so `EXT_SELECTED_EVENTS` cannot be set and every event type is published. -With `EVENTARC_CHANNEL` unset, nothing is published. - ### The triggers are 2nd gen All seven functions are 2nd gen. Their service accounts need @@ -302,6 +286,10 @@ for; the Firebase CLI grants these for you. ### Unchanged +- No Eventarc events are published. The extension declared `onStart`, + `onSuccess`, `onError` and `onCompletion` under + `firebase.extensions.firestore-vector-search.v1.*` but never published any of + them, and the kit publishes none either. `EVENTARC_CHANNEL` is not read. - The indexed collection is still `COLLECTION_NAME` (default `products`), the input, output and status fields still default to `input`, `embedding` and `status`, and embeddings are still written as native Firestore vectors. diff --git a/kits/firestore-vector-search/src/events.ts b/kits/firestore-vector-search/src/events.ts deleted file mode 100644 index 601c844695..0000000000 --- a/kits/firestore-vector-search/src/events.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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. - */ - -import * as eventArc from "firebase-admin/eventarc"; - -const EXTENSION_NAME = "firestore-vector-search"; - -const getEventType = (eventName: string): string => - `firebase.extensions.${EXTENSION_NAME}.v1.${eventName}`; - -let eventChannel: eventArc.Channel | undefined; - -export const setupEventChannel = (): void => { - eventChannel = process.env.EVENTARC_CHANNEL - ? eventArc.getEventarc().channel(process.env.EVENTARC_CHANNEL, { - allowedEventTypes: process.env.EXT_SELECTED_EVENTS, - }) - : undefined; -}; - -export async function recordStartEvent(data: object): Promise { - if (!eventChannel) return Promise.resolve(); - return eventChannel.publish({ type: getEventType("onStart"), data }); -} - -export async function recordErrorEvent(err: Error): Promise { - if (!eventChannel) return Promise.resolve(); - return eventChannel.publish({ - type: getEventType("onError"), - data: { message: err.message }, - }); -} - -export async function recordSuccessEvent(params: { - subject: string; - data: object; -}): Promise { - if (!eventChannel) return Promise.resolve(); - return eventChannel.publish({ - type: getEventType("onSuccess"), - subject: params.subject, - data: params.data, - }); -} - -export async function recordCompletionEvent(data: object): Promise { - if (!eventChannel) return Promise.resolve(); - return eventChannel.publish({ type: getEventType("onCompletion"), data }); -} diff --git a/kits/firestore-vector-search/src/handlers.ts b/kits/firestore-vector-search/src/handlers.ts index d62061ac9e..48227abc86 100644 --- a/kits/firestore-vector-search/src/handlers.ts +++ b/kits/firestore-vector-search/src/handlers.ts @@ -21,7 +21,6 @@ import type { CallableRequest } from "firebase-functions/v2/https"; import { HttpsError } from "firebase-functions/v2/https"; import type { Request } from "firebase-functions/v2/tasks"; import { createEmbedClient } from "./embeddings"; -import * as events from "./events"; import type { ResolvedVectorSearchConfig } from "./export-config"; import * as logs from "./logs"; import { @@ -74,7 +73,6 @@ export async function handleEmbedOnWrite( ctx: HandlerContext ): Promise { if (!event.data?.after.exists) return; - await events.recordStartEvent({ params: event.params }); logs.start("embedOnWrite"); const data = event.data.after.data() ?? {}; @@ -94,10 +92,6 @@ export async function handleEmbedOnWrite( }, { merge: true } ); - await events.recordSuccessEvent({ - subject: event.data.after.ref.path, - data: { outputFieldName: ctx.config.outputFieldName }, - }); logs.complete("embedOnWrite"); } catch (err) { await event.data.after.ref.set( @@ -109,11 +103,8 @@ export async function handleEmbedOnWrite( }, { merge: true } ); - await events.recordErrorEvent(err as Error); logs.error("embedOnWrite", err); throw err; - } finally { - await events.recordCompletionEvent({ params: event.params }); } } diff --git a/kits/firestore-vector-search/src/index.ts b/kits/firestore-vector-search/src/index.ts index b8770b5370..f82704c9f6 100644 --- a/kits/firestore-vector-search/src/index.ts +++ b/kits/firestore-vector-search/src/index.ts @@ -31,7 +31,6 @@ import { geminiApiKey, openAiApiKey, } from "./config"; -import * as events from "./events"; import { type ResolvedVectorSearchConfig, resolveVectorSearchConfig, @@ -141,8 +140,6 @@ function getContext(): HandlerContext { ensureDefaultApp(); - events.setupEventChannel(); - ctx = { firestore: getFirestore(), config: getConfig(), diff --git a/kits/firestore-vector-search/tests/events.test.ts b/kits/firestore-vector-search/tests/events.test.ts new file mode 100644 index 0000000000..3d9ca54880 --- /dev/null +++ b/kits/firestore-vector-search/tests/events.test.ts @@ -0,0 +1,158 @@ +/** + * 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. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// The extension declares four event types in its `extension.yaml` and publishes +// none of them, so the kit must publish none either. These tests fail if event +// publishing is reintroduced on the embed path. + +const { getSingleEmbedding } = vi.hoisted(() => ({ + getSingleEmbedding: vi.fn(), +})); + +vi.mock("../src/embeddings", () => ({ + createEmbedClient: vi.fn(() => ({ + batchSize: 1, + getEmbeddings: vi.fn(), + getSingleEmbedding, + })), +})); + +vi.mock("../src/queries/setup", () => ({ createIndex: vi.fn() })); + +// Records whether `firebase-admin/eventarc` ever enters the module graph. Vitest +// only evaluates this factory if something actually imports the module, so the +// flag catches a reintroduced `events.ts` even when no channel is configured and +// the publish helpers would return early. +const eventarc = vi.hoisted(() => ({ imported: false, publish: vi.fn() })); + +vi.mock("firebase-admin/eventarc", () => { + eventarc.imported = true; + return { + getEventarc: vi.fn(() => ({ + channel: vi.fn(() => ({ publish: eventarc.publish })), + })), + }; +}); + +import { resolveVectorSearchConfig } from "../src/export-config"; +import { + type HandlerContext, + handleEmbedOnWrite, + type VectorWriteEvent, +} from "../src/handlers"; + +const config = resolveVectorSearchConfig({ + projectId: "test-project", + instanceId: "test-instance", +}); + +const EMBEDDING = [0.1, 0.2, 0.3]; + +function makeCtx(): HandlerContext { + return { firestore: {}, config } as unknown as HandlerContext; +} + +/** A write event whose `after` holds `after` and whose `before` holds `before`. */ +function writeEvent( + after: Record | null, + before: Record | null = null +) { + const set = vi.fn().mockResolvedValue(undefined); + const snapshot = (data: Record | null) => ({ + exists: data !== null, + data: () => data ?? undefined, + get: (field: string) => (data ? data[field] : undefined), + ref: { path: `${config.collectionPath}/doc-1`, set }, + }); + const event = { + data: { after: snapshot(after), before: snapshot(before) }, + params: { docId: "doc-1" }, + } as unknown as VectorWriteEvent; + return { event, set }; +} + +describe("event publishing", () => { + beforeEach(() => { + // `eventarc.imported` is deliberately never reset: the import it records + // happens once, when the module graph loads, before any test body runs. + eventarc.publish.mockClear(); + getSingleEmbedding.mockReset(); + getSingleEmbedding.mockResolvedValue(EMBEDDING); + // Configured exactly as a user would to opt into events, so a reintroduced + // publish path would be live rather than short-circuited. + process.env.EVENTARC_CHANNEL = "locations/us-central1/channels/firebase"; + process.env.EXT_SELECTED_EVENTS = [ + "firebase.extensions.firestore-vector-search.v1.onStart", + "firebase.extensions.firestore-vector-search.v1.onSuccess", + "firebase.extensions.firestore-vector-search.v1.onError", + "firebase.extensions.firestore-vector-search.v1.onCompletion", + ].join(","); + }); + + afterEach(() => { + delete process.env.EVENTARC_CHANNEL; + delete process.env.EXT_SELECTED_EVENTS; + }); + + test("does not pull Eventarc into the handler module graph", () => { + expect(eventarc.imported).toBe(false); + }); + + test("does not reach Eventarc when an embedding succeeds", async () => { + const { event, set } = writeEvent({ [config.inputFieldName]: "hello" }); + + await handleEmbedOnWrite(event, makeCtx()); + + expect(set).toHaveBeenCalledTimes(1); + expect(eventarc.imported).toBe(false); + expect(eventarc.publish).not.toHaveBeenCalled(); + }); + + test("does not reach Eventarc when an embedding fails", async () => { + const { event, set } = writeEvent({ [config.inputFieldName]: "hello" }); + getSingleEmbedding.mockRejectedValue(new Error("Error with embedding")); + + await expect(handleEmbedOnWrite(event, makeCtx())).rejects.toThrow( + "Error with embedding" + ); + + expect(set).toHaveBeenCalledTimes(1); + expect(eventarc.imported).toBe(false); + expect(eventarc.publish).not.toHaveBeenCalled(); + }); + + test("does not reach Eventarc when the write is skipped", async () => { + const unchanged = { + [config.inputFieldName]: "hello", + [config.outputFieldName]: [0.1], + }; + const skipped = [ + writeEvent(null), + writeEvent({ [config.inputFieldName]: 42 }), + writeEvent(unchanged, { [config.inputFieldName]: "hello" }), + ]; + + for (const { event } of skipped) { + await handleEmbedOnWrite(event, makeCtx()); + } + + expect(getSingleEmbedding).not.toHaveBeenCalled(); + expect(eventarc.imported).toBe(false); + expect(eventarc.publish).not.toHaveBeenCalled(); + }); +});