Skip to content

Fix: return the winner's session when getOrCreateSession loses a create race - #821

Open
AmaadMartin wants to merge 4 commits into
mainfrom
fix/get-or-create-session-create-race
Open

Fix: return the winner's session when getOrCreateSession loses a create race#821
AmaadMartin wants to merge 4 commits into
mainfrom
fix/get-or-create-session-create-race

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 8, 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):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:
    Problem: BaseSessionService.getOrCreateSession reads the session, then creates it. Two callers that ask for the same session id both read undefined and both call createSession, so the loser rejects. AgentTool.runAsync and the dev server route POST /api/reasoning_engine both hit this, because they pass a fixed session id. DatabaseSessionService fails today; InMemorySessionService instead overwrites the winner's session object.

Solution: getOrCreateSession now catches a createSession failure, reads the key once more, and returns the session a concurrent caller created. It rethrows the original error when no session exists, so a real outage still surfaces. The re-read runs only on the error path, so the success path issues the same calls as before. The method does not match on the error message or class: DatabaseSessionService alone reports a duplicate id as either its own Error or a sqlite constraint violation, and the remote services define their own shapes.

Two notes for the reviewer:

  • The losing call does not apply request.state. That is the behaviour the method already has when the session exists before the call.
  • adk-python has the same shape in Runner._get_or_create_session. It is not a mechanical port (different class, and Python can catch its own AlreadyExistsError), so it is queued as a separate task.

Collision check, per the pipeline rule: gh pr list --repo AmaadMartin/adk-js --state open --limit 300 returns 300 open PRs. No PR changes getOrCreateSession. Three PRs overlap on files: #818 (adds flush()), #616 (adds a pagination helper) and #711 (adds getUserState) each edit a different region of core/src/sessions/base_session_service.ts and each add core/test/sessions/base_session_service_test.ts. This PR branches from main rather than stacking, because the three siblings overlap equally and stacking on one would not remove the conflict with the other two.

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.

New file core/test/sessions/base_session_service_test.ts, 6 tests. No existing test was changed or deleted.

npx vitest run --project unit:core core/test/sessions/base_session_service_test.ts     # 6 passed
npx vitest run --project unit:core core/test/sessions/ core/test/tools/agent_tool_test.ts  # 161 passed
npx vitest run --project unit:core core/test/tools/agent_tool_test.ts \
  core/test/sessions/database_session_service_test.ts \
  core/test/sessions/in_memory_session_service_test.ts                                 # 74 passed
npx vitest run --project unit:dev dev/test/server/adk_api_server_test.ts               # 53 passed
npm run build      # green
npm run lint       # green
npm run format:check  # green
npm run docs:check    # green (typedoc treats warnings as errors, and the edited JSDoc is public API)

Coverage of the rewritten method is 100% of statements and branches. Measured with --coverage.include=core/src/sessions/base_session_service.ts, then read from the v8 JSON report: zero uncovered statements and zero uncovered branches between the method's first and last line. The file total is lower only because that run does not exercise the other helpers in the file. DatabaseSessionService.close() is executed by the new teardown, with no uncovered statement or branch.

Mutation check. I reverted the try/catch back to return this.createSession(request); and re-ran the new file. 3 of the 6 tests failed, with these messages:

getOrCreateSession with DatabaseSessionService > resolves two concurrent calls for the same id to one session
  UniqueConstraintViolationException: insert into `sessions` (...) - SQLITE_CONSTRAINT:
  UNIQUE constraint failed: sessions.id, sessions.app_name, sessions.user_id

getOrCreateSession when createSession fails > returns the session created by the winner of the race
  Error: Session with id shared-session already exists.

getOrCreateSession when createSession fails > rethrows the original error when the session still does not exist
  AssertionError: expected "getSession" to be called 2 times, but got 1 times

The sqlite failure is the driver-level error, not the service's own message. That is the reason the fix re-reads the key instead of matching the error.

The other 3 tests pass before and after, by design. They pin the two early-return branches (no sessionId, session exists) and the InMemorySessionService concurrent case. InMemorySessionService.createSession overwrites a duplicate id today rather than rejecting, so that third test is a forward guard: it becomes a race regression test when #717 lands.

DatabaseSessionService.close() is new. init() opens the MikroORM connection and nothing could release it, so all four existing teardowns in core/test/sessions/database_session_service_test.ts reach the private orm field through as unknown as {orm: MikroORM}. The new test calls await service.close() instead. This is a second file under src/, which the spec did not ask for; the complexity review asked for it, and it removes the only cast in the diff. The four pre-existing cast sites are left alone, as a separate cleanup. The diff now contains no suppression, any, or coverage pragma at all.

Manual End-to-End (E2E) Tests:
Start the dev server against a database session service, then send two simultaneous POST /api/reasoning_engine requests with the same body and no sessionId. Both default to 'default-session', so they race.

adk api_server --session_service_uri "$SESSION_DB_URI" &
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8000/api/reasoning_engine \
  -H 'content-type: application/json' \
  -d '{"input":{"message":"hi","user_id":"u1"}}' &
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8000/api/reasoning_engine \
  -H 'content-type: application/json' \
  -d '{"input":{"message":"hi","user_id":"u1"}}' &
wait

Before the change one request returns 500 with Session with id default-session already exists.. After the change both return 200.

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.

CI note

The first two runs failed on the Windows leg, and the first also failed on macOS. The failing tests differed between runs: UnsafeLocalCodeExecutor > should execute shell code and return stdout (5s timeout), and AgentLoader discovery and loading integration (40s timeouts). None of them touch session services, and core/test/sessions/base_session_service_test.ts passed on every leg of every run. A rerun turned all three platforms green.

Amaad Martin added 4 commits August 8, 2026 12:55
Two callers that ask for the same session id can both read undefined and
both call createSession. The loser rejects, with a service error or a
driver constraint error. Re-read the key once when createSession fails,
and return the session a concurrent caller created. Rethrow the original
error when no session exists.
init() opens the MikroORM connection and nothing could release it, so
every test reached the private field through a cast. Close the
connection through a public method instead.
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