Skip to content

Feat: port FirestoreSessionService from adk-python to @google/adk-integrations - #466

Open
AmaadMartin wants to merge 7 commits into
mainfrom
feat/firestore-session-service
Open

Feat: port FirestoreSessionService from adk-python to @google/adk-integrations#466
AmaadMartin wants to merge 7 commits into
mainfrom
feat/firestore-session-service

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    N/A — no existing issue.
  2. Or, if no issue exists, describe the change:
    Problem: adk-js ships three session backends — InMemorySessionService, DatabaseSessionService (SQL via MikroORM) and VertexAiSessionService — and no document-store backend. adk-python has one, google/adk/integrations/firestore/firestore_session_service.py. Users already running Firestore have to fall back to SQL or write their own backend. git grep -il firestore -- 'core/src/**' 'integrations/src/**' returned nothing before this change, so this is a from-scratch port.

Solution: FirestoreSessionService in integrations/src/firestore/, exported from @google/adk-integrations.

  • createSession / getSession / listSessions / deleteSession / appendEvent, using the adk-js single-request-object signatures.
  • Three-tier state: session state on the session document, app and user state in sibling top-level collections, merged back under the app: / user: prefixes on read.
  • appendEvent runs in a Firestore transaction and bumps a revision counter, serialized per session by a reference-counted in-process lock (SessionLockMap).
  • deleteSession writes a status: "DELETING" marker first, so a concurrent append fails rather than resurrecting a half-deleted session.

It lives in integrations, not core, because integrations/package.json exists to hold integrations that pull third-party SDKs; putting @google-cloud/firestore in core would put a Firestore SDK on every adk-js user's install path.

Collision check

gh pr list --repo AmaadMartin/adk-js --state all --limit 300 — no open or closed PR touches Firestore or adds a session backend, so nobody is building this feature.

One PR overlaps on a prerequisite: #236 ("run the orphaned unit:integrations vitest project from the root test scripts") changes the same three package.json test scripts and the same integrations/test/version_test.ts expectation. I did not stack on it. Its branch is 25 commits behind main and its base predates Release: v1.5.0 (#503), so stacking would have rebased this work back across a release commit and made the dependency install resolve against a pre-1.5.0 lockfile — and a stacked base also means CI never runs (validation.yaml triggers on pull_request: branches: [main]). Whichever lands first, the other resolves trivially; #236 additionally de-rots the version test against package.json, which is the better long-term fix.

One repo-level fix this PR needs

  1. --project unit:integrations added to the root test, test:unit and test:coverage scripts. vitest.config.ts has defined that project since it was added, but no root script ran it, while the coverage config already includes integrations/src/**/*.ts. Adding ~640 lines of integrations/src to a coverage run that never executes its tests would have pushed global coverage down. Measured on this tree:

    run statements branches functions lines
    --project unit:core --project unit:dev (the old script list) 87.31 88.88 89.85 87.31
    + --project unit:integrations (this PR) 88.98 89.16 90.33 88.98

    Thresholds are statements 86 / branches 87 / functions 88 / lines 86. Wiring the project in raises coverage rather than lowering it, because the new code is 100% covered.

    This changes one line of an existing test assertion, and that is deliberate. integrations/test/version_test.ts:12 expected '1.3.0' while integrations/src/version.ts exports '1.5.0'. Normally I would add a case rather than edit one, but this assertion had gone stale unrun: vitest.config.ts:64 defined the unit:integrations project and no root script listed it, so the file never executed. There is no regression signal to preserve — the assertion has never passed at 1.5.0, and the same commit is what starts running it. It is corrected in place, not skipped, .only'd or deleted, and it still pins exactly what it always pinned: that the exported version matches the package's released version.

One repo-level fix, not two

An earlier revision of this PR also re-exported randomUUID from @google/adk so the new backend could reach it. That is gone: it permanently widened the @google/adk public API for a single internal call site. @google-cloud/firestore is Node-only and this module is deliberately excluded from index_web.ts, so it now takes randomUUID from node:crypto — stdlib, already used elsewhere in core (a2a/auth.ts) and dev, and without the Math.random() fallback branch that env_aware_utils.randomUUID carries for browsers. core/src/common.ts is no longer touched at all.

Divergences from firestore_session_service.py (all deliberate)

Parity wins for anything observable outside the process; local convention wins for in-process concerns.

  • Parity, byte for byte — collection names (adk-session, sessions, events, app_states, user_states), the users path segment, the document hierarchy, the session/event document field names (including the stored snake_case event_data), and the DELETING status string. Pinned by an explicit test asserting each literal, and by tests asserting the full literal document path adk-session/test-app/users/test-user/sessions/<id>.
  • Timestamp units. adk-js Event.timestamp and Session.lastUpdateTime are epoch milliseconds (core/src/events/event.ts uses Date.now()); Python's are seconds. This port uses milliseconds throughout, via Timestamp.fromMillis / toMillis.
  • Client-side Timestamp instead of SERVER_TIMESTAMP. A server sentinel does not resolve until a follow-up read, so session.lastUpdateTime would be unknowable at write time — Python papers over that by using a local clock for the returned session anyway. Client-side values also match DatabaseSessionService, which uses new Date().
  • No collection_group / optional-userId path. ListSessionsRequest.userId is required in adk-js, so Python's cross-user branch is unreachable through the typed interface, and it would need a composite index in production. listSessions queries the user's sessions subcollection directly. Python's extra where('appName', '==', appName) filter is dropped: it is redundant once the query is path-scoped under <root>/<appName>/users/<userId>/sessions.
  • In-memory sorting and pagination, mirroring in_memory_session_service.ts exactly (same asc/desc handling with the id.localeCompare tie-break, same limit === 0 / page / offset arithmetic). Server-side pagination would still need a separate count query for totalItems, and the other two backends already paginate in memory.
  • temp: keys are dropped from the in-memory session too. Python's _apply_temp_state leaves them on session.state; adk-js's BaseSessionService.appendEvent deliberately strips them for every backend. This port calls super.appendEvent(...) and follows the base class rather than special-casing itself.
  • numRecentEvents: 0 returns no events. adk-python's Firestore backend uses a truthy check (if config.num_recent_events:), so 0 there returns every event. That is the outlier: database_session_service.py, sqlite_session_service.py, in_memory_session_service.py and vertex_ai_session_service.py all special-case == 0, as does adk-js vertex_ai_session_service.ts. This port follows the majority convention.
  • No typed errors. adk-js has no google.adk.errors equivalent, so failures use the wording the existing adk-js backends use (Session with id ${id} already exists., Session ${id} not found for appendEvent) rather than AlreadyExistsError / SessionNotFoundError. Swapping to typed errors when that module lands is a follow-up.

Concurrency: fixed since the first revision of this PR

The first revision derived the persisted session state from the caller's in-memory session.state (as adk-python's Firestore backend does) and guarded appends with a reference-counted in-process mutex. A complexity review caught that this was wrong on three counts, and it has been reworked:

  • the written value was outside the transaction's read set, so Firestore could not see a conflicting concurrent write at all;
  • the mutex did not even close the race it existed for — it released when the transaction resolved, but super.appendEvent merges the delta into session.state after that, so a queued append could still read a pre-merge view;
  • a stale caller could clobber keys another writer had already committed.

appendEvent now derives the persisted state from data.state, the snapshot the transaction itself read. Every written value is therefore in the read set, and runTransaction aborts and re-runs the callback on conflict (DEFAULT_MAX_TRANSACTION_ATTEMPTS = 5, verified in @google-cloud/firestore/build/src/index.js:124). That serializes concurrent appends across processes — strictly stronger than the mutex, which only ever covered one. SessionLockMap, sessionLockKey, the sessionLocks field and session_lock_test.ts are all deleted; the test file goes with the module it tested.

This also converges with the rest of adk-js: DatabaseSessionService and InMemorySessionService both persist storage state plus the delta, never the caller's in-memory view. It is a deliberate divergence from firestore_session_service.py:541-548, which uses session.state; parity loses here because the Python behaviour is the defect.

The shared app- and user-state documents are no longer read inside the append transaction. set(ref, delta, {merge: true}) merges field by field on the server, so {...currentApp, ...appDelta} produced an identical document — the reads were dead work. Removing them also keeps those documents out of the read set, so two sessions of the same app no longer abort each other; a test pins that (transactionRetryCount === 0).

revision is still written and is read inside the transaction in order to be incremented. It is on-disk schema shared with adk-python.

Scope note

The diff is ~2.6k lines, but 1.67k of that is tests and the in-memory fake; the shipped source is 527 lines in one new file plus small edits to base_session_service.ts, database_session_service.ts, index.ts and the root package.json. I did not split it into a stack: after the rework there is no seam left that leaves a coherent, working PR, and putting the substantive half on a non-main base means CI never runs there.

Two helpers moved, one left in place

splitStateDelta now lives in core/src/sessions/base_session_service.ts, directly beside mergeStates, whose exact inverse it is. It replaces the two hand-rolled copies in database_session_service.ts (createSession and appendEvent), and its tests moved with it to the new core/test/sessions/base_session_service_test.ts, mirroring the source path. core/test/sessions/ is green (137 tests).

paginateSessions I deliberately left in the Firestore module, against the review's suggestion. Converging it would mean rewriting the pagination of three shipped backends (in_memory, database, vertex_ai) plus their test suites inside a feature PR — real regression risk to working code, for a readability win, and explicitly out of scope in the approved spec. splitStateDelta was a safe move because the copies are byte-equivalent logic; the four pagination implementations are not (vertex_ai_session_service.ts:355-381 differs in shape). It is queued as its own refactor.

One suppression, disclosed

integrations/test/firestore/fake_firestore.ts contains a single as unknown as Firestore, in the createFakeFirestore factory, commented with its reason. There is no any, @ts-expect-error, @ts-ignore or eslint-disable anywhere in the diff.

The review proposed removing it by declaring a structural slice (SessionFirestore) and typing the option as that instead of Firestore. I tried it and rejected it, as the review permitted if it cost more than it saved. The service does not touch three members but a whole reference graph — collection/doc/listDocuments/orderBy/where/limitToLast/get on collections and queries, collection/get/delete on document refs, get/getAll/set/update on transactions, delete/commit on batches, plus snapshots — so the slice is seven interfaces (~45 lines with JSDoc at this repo's print width) of hand-maintained shadow of the SDK's type surface, shipped in production code, that must be kept in sync by hand. It also degrades the public API: client?: Firestore is precise and discoverable; client?: SessionFirestore is neither. Trading one commented cast in a test double for that is a net loss.

Follow-up queued (not done here)

Converging the four hand-rolled listSessions pagination implementations (in_memory_session_service.ts:136-229, database_session_service.ts:280-323, vertex_ai_session_service.ts:355-381 and this one) onto a shared core helper, for the reasons above. in_memory_session_service.ts's appendEvent also still has an inline app/user split that splitStateDelta could replace; its loop has a different shape (it writes into nested maps rather than building delta objects), so I left it rather than convert it untested in a feature PR.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.

Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

65 tests across three files, all offline — no emulator, no network, no credentials, on any OS in the CI matrix. (splitStateDelta's four tests moved to core/test/sessions/base_session_service_test.ts with the function; session_lock_test.ts was deleted along with the module it tested.)

$ npx vitest run --project unit:integrations
 Test Files  3 passed (3)
      Tests  65 passed (65)

$ npx vitest run --project unit:core core/test/sessions/
 Test Files  7 passed (7)
      Tests  137 passed (137)

integrations/test/firestore/fake_firestore.ts is an in-memory Firestore holding every document in one flat map keyed by full path. Three properties make it worth trusting, and it has its own tests:

  • its transaction buffers writes and applies them only when the callback resolves, so the duplicate-id and DELETING rollback tests mean something;
  • it versions every document, records the versions a transaction read, refuses a commit whose read set moved, and retries the callback up to 5 attempts — Firestore's actual concurrency model. The first revision of this fake committed unconditionally, which is precisely why a lost update went unnoticed there;
  • its reads suspend on a shared per-tick barrier rather than a plain setTimeout. Node drains the microtask queue between timer callbacks, so with a per-call setTimeout each transaction would run to completion before the next resumed and no conflict would ever arise.

It also refuses query shapes it does not implement instead of quietly returning a wrong answer. It is deliberately stricter than the real SDK on limitToLast(0), which the real client accepts.

Coverage of the new source is 100% on all four axes:

$ npx vitest run --project unit:integrations --coverage \
    --coverage.include='integrations/src/firestore/**/*.ts'
File                  | % Stmts | % Branch | % Funcs | % Lines
----------------------|---------|----------|---------|--------
 ...ion_service.ts    |     100 |      100 |     100 |     100

Coverage is not the proof — every test was re-run against broken code after the rework. Eleven mutations, each reverted afterwards:

# mutation test that failed failure
1 derive the persisted state from session.state again (the bug this revision fixes) persists the stored state, not a stale caller in-memory view expected { b: 2 } to deeply equal { a: 1, b: 2 }
2 read the shared app-state doc inside the transaction again lets appends to different sessions of one app proceed without contending expected 1 to be +0
3 fake commits without checking its read set serializes concurrent appends to the same session expected 1 to be 5
4 remove the status === 'DELETING' guard refuses to append to a session marked for deletion promise resolved "{ author: 'user', …(6) }" instead of rejecting
5 remove the numRecentEvents === 0 guard in fetchEvents skips the events query entirely when numRecentEvents is 0 expected [ { author: 'user', …(5) } ] to deeply equal []
6 write a constant revision: 1 revision bump + concurrent appends (2 tests) expected 1 to be 2, expected 1 to be 5
7 remove the missing-session guard rejects an append to an unknown session expected [Function] to throw error including 'Session ghost not found for appendEve…' but got 'Cannot read properties of undefined (…'
8 shift the afterTimestamp cursor by +1ms includes an event whose timestamp equals afterTimestamp expected [] to deeply equal [ 'boundary' ]
9 remove the event.partial early return writes nothing for a partial event expected [ [ …(2) ], [ …(2) ] ] to deeply equal [ [ …(2) ] ]
10 raise MAX_DELETES_PER_BATCH to 1000 deletes more events than fit in one write batch expected 1 to be 2
11 rename the users path segment to user constants + three root-collection tests expected 'user' to be 'users'

Mutations 1–3 are new in this revision and are the ones that matter most: 1 and 2 pin the correctness fix, and 3 proves the concurrency test now depends on real Firestore semantics rather than on a fake that could not lose an update. Two earlier rounds are worth recording because both found real weaknesses in my own tests:

  • the original temporary-state test passed against its mutant, because trimTempDeltaState had already stripped the key before the filter ran — the filter was never exercised;
  • the original concurrent-append test passed only because the lock existed and the fake could not detect conflicts. Against the real client it was asserting nothing about Firestore's behaviour.

Other gates, on the exact pushed commit:

$ npm run build          # OK
$ npm run lint           # OK
$ npm run format:check   # All matched files use Prettier code style!
$ npm run docs:check     # OK
$ bash scripts/check_license.sh   # ✅ All files have the correct license header.
$ npx tsc --noEmit       # 0 errors in any file this PR touches

npm run ts:check is not green on main (281 pre-existing errors in core/test, dev/test and tests/, and it is not wired into validation.yaml); this PR adds zero errors — verified by diffing the error list before and after.

npm run test:coverage cannot complete locally: 16 e2e files need a Gemini API key, 4 install-heavy integration fixtures are the known-flaky ones, and dev/test/cli/cli_create_test.ts asserts against the machine's gcloud config get project. All 21 failures reproduce without this change and none are in integrations or core/test/sessions. CI runs the full gate and is the authority here — it was green on all three OSes for the previous revision.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

I was not able to run this against a real Firestore backend, and I am not going to claim otherwise. The sandbox has no GCP credentials, and gcloud emulators firestore start fails there — the cloud-firestore-emulator component is not installed and the CLI component manager is disabled. No document paths in this PR body are transcribed from a live console; the paths asserted in the tests come from the fake and from the reference Python implementation.

What I could verify against the real SDK, offline and with no fake involved, is that the reference paths and query shapes this service builds are ones the real client accepts. Running the real Firestore class (no network — reference construction is lazy):

session doc : adk-session/my-app/users/user-1/sessions/sess-1
event doc   : adk-session/my-app/users/user-1/sessions/sess-1/events/evt-1
app state   : app_states/my-app
user state  : user_states/my-app/users/user-1

That is byte-identical to the hierarchy documented at firestore_session_service.py:79-99. The orderBy('timestamp').where('timestamp','>=',…).limitToLast(n) chain also builds against the real Query. It is also how I found that the real SDK's limitToLast validates integrality only and accepts limitToLast(0); the numRecentEvents === 0 short-circuit had been commented as working around a rejection that does not happen. The guard now sits at the top of fetchEvents, where the falsy-zero hazard actually is, with an accurate comment.

To verify end to end against a real backend:

gcloud components install cloud-firestore-emulator
gcloud emulators firestore start --host-port=127.0.0.1:8080
export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
npm run build

then, from the repo root, drive createSessionappendEventgetSessionlistSessionsdeleteSession with
new FirestoreSessionService({client: new Firestore({projectId: '<your-project>'})})
and confirm in the emulator UI that the documents appear at the four paths above, that revision increments once per append, that no app:, user: or temp: key appears in the session document's state, and that the session and every event document are gone after the delete.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 7 commits August 1, 2026 09:18
…cripts

vitest.config.ts has defined a unit:integrations project since it was
added, but the root test/test:unit/test:coverage scripts never passed
--project unit:integrations, so integrations/test never ran even though
integrations/src is in the coverage include list.

Wiring the project in surfaces integrations/test/version_test.ts, which
had rotted to expect 1.3.0 while integrations/src/version.ts exports
1.5.0. The expectation is corrected, not skipped or deleted.

Also re-export randomUUID from @google/adk so consumer workspaces can
generate session IDs from the same source core uses, rather than a deep
subpath import that core/package.json does not expose.
Added with npm install --workspace=integrations so npm picks the caret
range and regenerates the lockfile. It goes in integrations, not core,
so the Firestore SDK stays off every adk-js user's install path.
Adds a document-store session backend so users already running Firestore
do not have to fall back to SQL. Sessions live under
<root>/<appName>/users/<userId>/sessions/<sessionId>, with app- and
user-scoped state in the sibling app_states and user_states collections
and merged back under the app:/user: prefixes on read.

appendEvent runs in a Firestore transaction, serialized per session by a
reference-counted in-process lock, and bumps a revision counter.
deleteSession writes a DELETING marker first so a concurrent append
fails rather than resurrecting a half-deleted session.

Collection names, the document hierarchy and the DELETING marker match
firestore_session_service.py byte for byte. Timestamps diverge
deliberately: client-side Timestamp values in epoch milliseconds rather
than SERVER_TIMESTAMP sentinels in seconds, matching adk-js's
Event.timestamp and Session.lastUpdateTime units.
…y fake

Adds an in-memory Firestore that needs no emulator, network or
credentials, so the suite runs on every OS in the CI matrix. Its
transaction buffers writes until the callback resolves, and its reads
suspend on a shared per-tick barrier so concurrent transactions really
do interleave -- a plain setTimeout would not, because Node drains the
microtask queue between timer callbacks and each transaction would run
to completion before the next resumed.

The fake refuses query shapes it does not implement rather than
returning a wrong answer, and has its own tests for merge semantics and
transaction rollback.
…port

The numRecentEvents === 0 short-circuit was justified as working around
the Node SDK rejecting limitToLast(0). It does not: validateInteger only
rejects non-integers, so limitToLast(0) is accepted. The short-circuit is
still load-bearing for a different reason -- 0 is falsy, so without it the
limit is skipped and every event comes back -- and returning no events is
what VertexAiSessionService and the adk-python database, sqlite,
in-memory and Vertex AI backends do.

The 500-write batch cap is a Firestore server-side limit, not something
the client enforces, so the comment no longer implies otherwise.
…rocess lock

appendEvent derived the persisted session state from the caller's
in-memory session.state rather than from the snapshot the transaction
read. That put the written value outside the transaction's read set, so
Firestore could not detect a conflicting concurrent write, and it let a
stale caller clobber keys another writer had committed.

Deriving it from data.state fixes both. Every value the transaction
writes now comes from the session document it read, so Firestore aborts
and re-runs the callback on conflict -- across processes, which the
in-process lock never covered. SessionLockMap and its plumbing are
deleted; the lock did not even close the race it existed for, because
super.appendEvent merges the delta into session.state after the lock is
released. It also matches DatabaseSessionService and
InMemorySessionService, which both persist storage state plus the delta.

The shared app/user state documents are no longer read inside the
transaction: set(..., {merge: true}) merges field by field on the
server, so the reads were dead work, and keeping those documents out of
the read set stops unrelated sessions of one app aborting each other.

The fake now tracks document versions, refuses a commit whose read set
moved, and retries like the real client -- without that, the
concurrent-append test passed against a fake weaker than Firestore.

splitStateDelta moves to core beside its inverse mergeStates, replacing
the two hand-rolled copies in DatabaseSessionService, and randomUUID
comes from node:crypto rather than widening the @google/adk public API
for one Node-only call site.
The Firestore backend is the only consumer and it is Node-only, so it
takes randomUUID from node:crypto rather than permanently widening the
@google/adk public API for one call site.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant