Fix: delete a session and its events in a single database transaction - #641
Open
AmaadMartin wants to merge 3 commits into
Open
Fix: delete a session and its events in a single database transaction#641AmaadMartin wants to merge 3 commits into
AmaadMartin wants to merge 3 commits into
Conversation
added 3 commits
August 4, 2026 08:08
deleteSession() issued two independent nativeDelete calls on a forked EntityManager, each running in its own implicit auto-commit transaction. A failure between them (constraint, lock timeout, connection drop) left the sessions row deleted and its events rows orphaned: the events table has no foreign key to sessions, so nothing ever reaps them and no read path can reach them. Re-creating a session with the same id then resurrects the deleted events. Wrap both deletes in em.transactional() so they commit together or roll back together. Order (sessions then events) and the propagated driver error are unchanged.
The commit-path test now proves the event rows were removed by re-creating the same session key and asserting the event list is empty, instead of reaching into the service's private ORM to count rows. The rollback test keeps its single private access, which is unavoidable: the abort trigger has to be installed on the live in-memory connection.
The test file reached DatabaseSessionService's private orm field with an inline unknown-cast at five separate call sites, one of them added by this branch and one double-casting redundantly. Name the cast once in an ormOf() helper and route every site through it. The helper returns MikroORM | undefined to match the optional field, so afterEach keeps its existing guard. The service keeps its private field unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Closes: #issue_number
Related: #issue_number
Problem:
DatabaseSessionService.deleteSession()issued two independentnativeDeletecalls on a forkedEntityManager:No transaction is open on that fork, so each statement runs in its own implicit auto-commit transaction and the session row is durably deleted before the event delete is even attempted. If the second statement fails (constraint/trigger, lock timeout, connection drop, permission error) or the process dies in between, the event rows are permanently orphaned.
Those orphans are unreachable and never reaped:
getSession()returnsundefinedas soon as thesessionslookup misses, so it never reads them.listSessions()selects fromStorageSessiononly.StorageEvent(writes inappendEvent(), reads ingetSession()/appendEvent()).StorageSessionandStorageEventincore/src/sessions/db/schema.tsare unrelated entities — there is no foreign key, so there is noON DELETE CASCADEto clean up after the failure. (adk-python declares that FK —src/google/adk/sessions/schemas/v1.py— which is why it cannot exhibit this bug. Adding the constraint to adk-js is deliberately out of scope here and queued separately.)Two concrete consequences: an unbounded storage leak (the full
event_datahistory of every failed delete, forever), and resurrection of deleted data —createSession()accepts a caller-suppliedsessionIdand only checks thesessionstable for a collision, so re-creating the same(appName, userId, sessionId)makes the previous session's leaked events readable again on the nextgetSession().Solution: wrap both deletes in a single
em.transactional()block, following the patternappendEvent()in the same file already uses. Both statements now commit together or roll back together.Why this shape:
txEm, not on the outer fork — a statement issued on the outeremruns outside the transaction and reintroduces the bug in a harder-to-see form (see mutation 2 below, which deadlocks).txEm.commit()in the callback:em.transactional()commits on return and rolls back + rethrows on rejection (@mikro-orm/core, declared as^6.6.10incore/package.json, resolved to 6.6.14 in the lockfile).sessionsthenevents) is unchanged.core/src/runner/runner.ts:207,dev/src/server/adk_api_server.ts:522) need no change.Not a breaking change. The public signature, exports and success-path behaviour are identical; deleting a non-existent session is still a no-op that resolves. Only the failure path changes, by leaving no partial state behind. One consequence worth stating: both DELETEs now hold their locks until the single commit, marginally widening the lock window on concurrent backends. That is the intended cost of atomicity and matches what
appendEvent()already does.Collision check. All 539 open PRs on the fork were listed; three touch
core/src/sessions/database_session_service.ts— #492 (wrapsappendEventin one transaction), #386 (removes private-ORM casts from the test, adds a publicclose()), #607 (temp:state visibility).gh pr diffon each shows none of them touchesdeleteSession, andgh search prsfor "deleteSession transaction" / "atomically delete session events" returns nothing. No overlap, so this branches frommainrather than stacking. This change stays strictly insidedeleteSessionplus two added test blocks so it rebases cleanly over all three.Deviation from the plan, disclosed. The commit-path test asserts through the public API (re-create the same key, assert the event list is empty) instead of counting rows via
(service as unknown as {orm: MikroORM}). It pins the same invariant — and states the user-visible "resurrection" consequence directly — without adding a private-member cast, which the JS guidelines forbid and which #386 is actively removing from this file.One unavoidable private access, named once. The rollback test has to install a sqlite abort trigger on the live in-memory connection the service holds, and
DatabaseSessionServiceexposes no accessor for its ORM handle on this base. Rather than add a fifth inline cast, the test file now names it once in a module-levelormOf()helper and routes all call sites through it:The return type mirrors the optional
private orm?: MikroORMfield, soafterEachkeeps its existing guard and the initialized sites assert with!. The production file is unchanged for this — wideningprivate ormto satisfy a test would be strictly worse than the cast. Net effect: this branch takes the file from four inline unknown-casts (one of them double-casting redundantly with a trailingas MikroORM) down to one, in a named and documented helper.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.
Two tests added to the existing
describe('DatabaseSessionService', ...)block incore/test/sessions/database_session_service_test.ts. The pre-existing'should delete a session'test is untouched, and no existing test was modified or removed.'should delete a session and its events in one transaction'(commit path) — creates a session, appends an event, deletes, assertsgetSession()isundefined, then re-creates the same(appName, userId, sessionId)and assertseventsis[].'should not delete the session when deleting its events fails'(rollback path — the regression test) — creates a session, appends an event, installs a sqliteBEFORE DELETE ON eventstrigger that doesRAISE(ABORT, 'event delete blocked'), assertsdeleteSession()rejects with that message, then asserts the session row count is1, the event row count is1, andgetSession()still returns a session with one event.ABORT(notROLLBACK) is used deliberately: it undoes only the offending statement and leaves the enclosing transaction open for MikroORM to roll back, which is exactly the failure shape this fix addresses.Each test runs against a fresh
:memory:database from the file'sbeforeEach, so the trigger cannot leak between tests and noDROP TRIGGERcleanup is needed.npm run ts:checkreports 273 pre-existingtsc --noEmiterrors in the test tree on the base commit; this branch adds none (identical count with the change stashed and applied).Proof the tests can fail (mutation testing). Coverage was not treated as proof — each new test was run against mutated source:
deleteSessionto the two bareem.nativeDeletecalls (the unfixed code)AssertionError: expected +0 to be 1on the session-count assertion — the session row was committed away while the blocked event row survived.rejects.toThrowstill passes, which is why the count assertion is the regression signal. Test 1 passes, as expected for a companion assertion.eminstead oftxEmStorageEventdelete from the transactionexpected [ { timestamp: … } ] to deeply equal [](the orphaned event resurfaces on the re-created session); test 2 FAILS:promise resolved "undefined" instead of rejecting.Coverage. New lines 357–375 of
core/src/sessions/database_session_service.tshave zero uncovered statements and zero uncovered branches (measured with@vitest/coverage-v8, parsed fromcoverage-final.jsonrestricted to the new range). Whole-file coverage from this one test file is 85.67% lines / 81.63% branches; the shortfall is entirely pre-existing gaps inappendEvent,initand error paths outside this change, which other test files exercise.Manual End-to-End (E2E) Tests:
No CLI, server or model interaction is involved, so no e2e fixture was added — the behaviour is exercised end-to-end against a real sqlite engine through the public
DatabaseSessionServiceAPI by the two unit tests above. To sanity-check by hand:Confirm the two new tests pass and that the pre-existing
'should delete a session'test still passes unchanged, which demonstrates the success path is untouched.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.