Fix: append a session event in a single database transaction - #492
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: append a session event in a single database transaction#492AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 1, 2026 19:48
appendEvent called txEm.commit() inside em.transactional(), which ended the transaction before the callback had finished. The trailing flush that MikroORM performs after the callback returns then wrote sessions.update_time in a second, implicit transaction, so a failure there left the event row durably inserted while the caller saw a rejection. It also released the PESSIMISTIC_WRITE row lock in the middle of the read-check-write critical section. Drop the explicit commit, move the update_time assignment inside the callback, and mutate the caller's in-memory session only after the transaction has committed.
Addresses complexity review on the atomicity fix:
- Replace the four repeated `as unknown as {orm: MikroORM}` casts in the
new tests with a single documented `ormOf()` helper.
- Pass the StorageEvent / StorageSession classes to em.find / em.findOne
instead of their string names, which drops the hand-written result
type casts.
- Collapse the reloadedEvents guard to a single nullish-coalescing
assignment.
This was referenced Aug 4, 2026
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
No existing public issue.
Problem:
DatabaseSessionService.appendEventwraps its work inem.transactional(...)but callsawait txEm.commit()inside the callback,before the callback has finished. MikroORM's
transactional()already flushesand commits when the callback returns, so the explicit commit ends the
transaction early and the trailing flush runs in a second, implicit
transaction. Three consequences:
sessions.update_timewrite happens in transaction 2. If the second writefails,
appendEventrejects while the event row is durably persisted with astale
update_time.PESSIMISTIC_WRITErow lock is released mid-critical-section. Thestale-session check reads
storageSession.updateTimeunder the lock taken bythe initial
findOne, but theupdate_timewrite happens after that lock wasreleased — on the server drivers, on a different pooled connection. Two
concurrent appends can interleave between the two transactions and clobber the
value the stale check depends on.
COMMITis issued (knex tolerates it on SQLite today; nothingpromises that for every dialect) and the caller's in-memory
sessionobjectwas mutated before the durable write finished, so a failed append left it
partially updated.
Observed statement log for one
appendEvent, captured withdebug: ['query']against SQLite (theupdate sessions … create_time, update_timeinside the first transaction is MikroORM's flush rewriting the rowwith its existing timestamps):
Before
After
Solution:
await txEm.commit()and letem.transactional()flush and commitwhen the callback returns.
storageSession.updateTime = new Date(event.timestamp)inside thecallback so it is part of the single flush, and keep
LockMode.PESSIMISTIC_WRITEexactly where it is — it now covers the wholeread-check-write critical section, which is the point of the fix.
mergedState,updateTime,reloadedEvents) from the callback and mutate the caller'ssessiononlyafter
em.transactional(...)resolves.Notes for the reviewer:
session.state = mergedStatein the stale-session branch was removeddeliberately, as provably dead code. The final
mergeStatescallunconditionally overwrote
session.statewith a merge over the same threestate objects after the deltas were applied, so the intermediate value was
never observable. The branch now only carries the reloaded events out to the
post-commit block.
sessionobject is now mutated only after a successfulcommit. No call site depends on the old partial-mutation behaviour:
core/src/runner/runner.tsusestry/finallywith nocatch;dev/src/cli/cli_run.tscatches, logs and returns without touchingsession;core/src/a2a/agent_executor.tscatches but reads a differentSessionobject. On the success path the mutations are identical and still happen
before
appendEventresolves, so the runner's reliance on in-place mutationof the shared
InvocationContext.sessionis preserved.resultobject rather thandestructured inline at the
await. Inline destructuring pushes the assignmentpast 80 columns, and Prettier then re-indents the entire 100-line callback
body — a 219-line diff that is 90% whitespace instead of the 51-line
behavioural diff here. Semantics are identical.
return value and exports. Not a breaking change.
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000(392 open PRs) surfaced three that touchcore/src/sessions/database_session_service.ts: Fix: remove private-member casts from the DatabaseSessionService test and add a public close() #386 (adds a publicclose(), removes private-member casts from the test), Fix: align the listSessions() state contract across all session services (stacked on #376) #477 and Fix: make ListSessionsRequest.userId optional (adk-python parity) #376(
listSessionsfilter/state contract).gh pr diff <n> --name-onlyplus thehunks confirm none of them touch
appendEvent, so this branches frommainrather than stacking. Fix: remove private-member casts from the DatabaseSessionService test and add a public close() #386 may conflict textually with the test file if itlands first. The new tests reach the service's MikroORM handle through a
single documented
ormOf()seam in the test file rather than repeating thefile's existing per-site cast, and deliberately do not widen
private ormoradd a test-only getter.
adk-pythonadditionally carries anexplicit storage revision marker plus an in-process per-session lock, and
rejects stale writers instead of silently reloading. That is a behaviour
change needing its own design review; this PR only restores the atomicity and
lock-window guarantees the existing code already intends.
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.
Five new tests in
core/test/sessions/database_session_service_test.ts, under anew
appendEvent atomicitydescribe. No existing test was modified or deleted —in particular "should align session updateTime with event timestamp" and
"should append event and update state" still pass verbatim.
Every new test was run against the unfixed code and confirmed to fail
(mutation → failure message):
should append an event in a single transaction— reverts the whole sourcehunk:
AssertionError: expected [ 'begin', 'begin' ] to have a length of 1 but got 2(pre-fix also emits threecommits, with theupdate_timewriteafter the first one).
should not persist the event when the session update fails— samerevert:
AssertionError: expected [ …(1) ] to have a length of +0 but got 1(the event row is leaked: durably inserted while the caller sees a
rejection).
should leave the in-memory session untouched when the append fails— samerevert:
AssertionError: expected [ { timestamp: 1785638820267, …(6) } ] to have a length of +0 but got 1(the in-memorysession.eventshad alreadygrown before the trailing flush threw).
should reload events from storage when the in-memory session is stale—passes before and after the fix (it covers the restructured branch), so it
was proved by mutation instead: dropping
session.events = result.reloadedEvents ?? session.events;givesAssertionError: expected [ 'zgZnoFHZ' ] to deeply equal [ 'hpBcfxSF', 'zgZnoFHZ' ].should replace an event that was already appended— covers the movedindex >= 0post-commit branch; replacing it with an unconditionalsession.events.push(event)givesAssertionError: expected [ …(2) ] to have a length of 1 but got 2.Tests 2 and 3 install a SQLite trigger that aborts the
update_timewrite. Thetrigger carries a
WHEN NEW.update_time <> OLD.update_timeclause on purpose:MikroORM's flush also rewrites the session row with its existing timestamps
inside the transaction, and blocking that write would abort the append before it
ever reaches the
update_timechange under test — which would make both testspass against the buggy code.
Test 1 strips ANSI escapes from the captured query log even though the service
is constructed with
colors: false. MikroORM'scolorsflag is process-global(
process.env.MIKRO_ORM_COLORS, see@mikro-orm/core/logging/colors.js), soanother service in the same suite re-enables it; this was observed, not
assumed.
Coverage (
npx vitest run --project unit:core --coverage core/test/sessions/database_session_service_test.ts): the restructuredstale-session branch (previously 18 uncovered lines) and the post-commit
mutation block are now fully covered. Four blocks inside
appendEventremainuncovered, all of them pre-existing gaps that this change only re-indents or
does not touch at all: the
event.partialearly return (unchanged by thisdiff), and the
StorageAppState/StorageUserStaterow-creation branches,which are unreachable through the public API because
createSessionalwayscreates both rows.
Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Run against a real file-backed SQLite store through the public
getSessionServiceFromUrientry point (no mocks), with a service restart in themiddle:
getSessionServiceFromUri('sqlite:///tmp/adk.sqlite').user/agent, each with astateDelta.getSession(...).every delta,
lastUpdateTimeequals the last event's timestamp, andlistSessionsreturns the session.This was run locally and passes. Equivalently, with an agent directory:
Hold a 3-4 turn conversation, exit, and re-run against the same URI: the prior
turns are restored in order. With
debug: ['query']each turn now emits exactlyone
begin/commitpair per append.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.