Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions core/src/sessions/base_session_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,22 +123,42 @@ export abstract class BaseSessionService {
/**
* Gets a session or creates one if it doesn't exist.
*
* Concurrent callers that ask for the same session id all get the same
* session. A caller that loses the create race gets the session the winner
* created, instead of the duplicate-id error the service reports. Only the
* call that creates the session applies the state in the request, as is
* already the case when the session exists beforehand.
*
* @param request The request to get or create a session.
* @return A promise that resolves to the session instance.
* @throws The original createSession error, when the create failed and no
* session exists for the requested id afterwards.
*/
async getOrCreateSession(request: CreateSessionRequest): Promise<Session> {
if (!request.sessionId) {
return this.createSession(request);
}
const session = await this.getSession({
const key = {
appName: request.appName,
userId: request.userId,
sessionId: request.sessionId,
});
};
const session = await this.getSession(key);
if (session) {
return session;
}
return this.createSession(request);
try {
return await this.createSession(request);
} catch (e: unknown) {
// A concurrent caller can create the session between the read above and
// this create. Each service reports that with a different error shape,
// so re-read the key rather than match on the error.
const created = await this.getSession(key);
if (!created) {
throw e;
}
return created;
}
}

/**
Expand Down
9 changes: 9 additions & 0 deletions core/src/sessions/database_session_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ export class DatabaseSessionService extends BaseSessionService {
this.initialized = true;
}

/**
* Closes the database connection opened by init().
*
* The service is unusable afterwards.
*/
async close() {
await this.orm?.close();
}

async createSession({
appName,
userId,
Expand Down
138 changes: 138 additions & 0 deletions core/test/sessions/base_session_service_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {
CreateSessionRequest,
DatabaseSessionService,
InMemorySessionService,
} from '@google/adk';
import {SqliteDriver} from '@mikro-orm/sqlite';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';

const APP_NAME = 'test-app';
const USER_ID = 'test-user';
const SESSION_ID = 'shared-session';

const request: CreateSessionRequest = {
appName: APP_NAME,
userId: USER_ID,
sessionId: SESSION_ID,
};

describe('getOrCreateSession with InMemorySessionService', () => {
let service: InMemorySessionService;

beforeEach(() => {
service = new InMemorySessionService();
});

it('resolves two concurrent calls for the same id to one session', async () => {
const [first, second] = await Promise.all([
service.getOrCreateSession(request),
service.getOrCreateSession(request),
]);

expect(first.id).toBe(SESSION_ID);
expect(second.id).toBe(SESSION_ID);

const listed = await service.listSessions({
appName: APP_NAME,
userId: USER_ID,
});
expect(listed.sessions).toHaveLength(1);
});

it('returns the existing session without creating a second one', async () => {
const created = await service.createSession(request);

const fetched = await service.getOrCreateSession(request);

expect(fetched.id).toBe(created.id);
const listed = await service.listSessions({
appName: APP_NAME,
userId: USER_ID,
});
expect(listed.sessions).toHaveLength(1);
});

it('creates a session when no session id is given', async () => {
const created = await service.getOrCreateSession({
appName: APP_NAME,
userId: USER_ID,
});

expect(created.id).not.toBe('');
});
});

describe('getOrCreateSession with DatabaseSessionService', () => {
let service: DatabaseSessionService;

beforeEach(async () => {
service = new DatabaseSessionService({
dbName: ':memory:',
driver: SqliteDriver,
allowGlobalContext: true, // simplified for tests
});
await service.init();
});

afterEach(async () => {
await service.close();
});

it('resolves two concurrent calls for the same id to one session', async () => {
const [first, second] = await Promise.all([
service.getOrCreateSession(request),
service.getOrCreateSession(request),
]);

expect(first.id).toBe(SESSION_ID);
expect(second.id).toBe(SESSION_ID);

const listed = await service.listSessions({
appName: APP_NAME,
userId: USER_ID,
});
expect(listed.totalItems).toBe(1);
});
});

describe('getOrCreateSession when createSession fails', () => {
let service: InMemorySessionService;

beforeEach(() => {
service = new InMemorySessionService();
});

it('returns the session created by the winner of the race', async () => {
const create = service.createSession.bind(service);
let creates = 0;
vi.spyOn(service, 'createSession').mockImplementation(async (req) => {
if (creates++ > 0) {
throw new Error(`Session with id ${req.sessionId} already exists.`);
}
return create(req);
});

const [first, second] = await Promise.all([
service.getOrCreateSession(request),
service.getOrCreateSession(request),
]);

expect(first.id).toBe(SESSION_ID);
expect(second.id).toBe(SESSION_ID);
});

it('rethrows the original error when the session still does not exist', async () => {
vi.spyOn(service, 'createSession').mockRejectedValue(new Error('boom'));
const getSession = vi.spyOn(service, 'getSession');

await expect(service.getOrCreateSession(request)).rejects.toThrow('boom');

expect(getSession).toHaveBeenCalledTimes(2);
});
});
Loading