This version is still in development and is not considered stable yet. For the latest stable version, please use Spring AI 2.0.1!

JDBC Repository

spring-ai-session-jdbc stores sessions in a relational database. It supports PostgreSQL, MySQL, MariaDB and H2 out of the box.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-session-jdbc</artifactId>
</dependency>

Tables

AI_SESSION          session metadata: id, created_at, expires_at, metadata JSON, event_version
AI_SESSION_INDEX    the indexed metadata attributes: session_id, attr_name, attr_value (FK → AI_SESSION)
AI_SESSION_EVENT    the event log, ordered by a database-assigned seq (FK → AI_SESSION, ON DELETE CASCADE)

The log is append-only: a compaction appends a COMPACTION row that names the active view by event id, and never updates or deletes a row.

Schema

The schema scripts are on the classpath. Each supported database has a dialect, detected from the database metadata, and a script:

Database Dialect Script

PostgreSQL

PostgresJdbcSessionRepositoryDialect

org/springframework/ai/session/jdbc/schema-postgresql.sql

MySQL, MariaDB

MysqlJdbcSessionRepositoryDialect

org/springframework/ai/session/jdbc/schema-mysql.sql

H2

H2JdbcSessionRepositoryDialect

org/springframework/ai/session/jdbc/schema-h2.sql

Another database is rejected at startup with an IllegalStateException; to use one, implement JdbcSessionRepositoryDialect and pass it to the builder.

With the auto-configuration, initialize-schema applies the script on startup. Otherwise use Spring Boot’s SQL initialization, spring.sql.init.schema-locations=classpath:org/springframework/ai/session/jdbc/schema-postgresql.sql, or copy the script into your migration tool. The scripts are safe to re-run.

Manual bean setup

@Bean
SessionRepository sessionRepository(DataSource dataSource) {
    return JdbcSessionRepository.builder()
        .dataSource(dataSource)                               // the dialect is detected
        .build();
}

@Bean
SessionService sessionService(SessionRepository sessionRepository) {
    return DefaultSessionService.builder().sessionRepository(sessionRepository).build();
}

The builder accepts optional overrides:

JdbcSessionRepository.builder()
    .dataSource(dataSource)
    .dialect(new PostgresJdbcSessionRepositoryDialect())      // an explicit dialect
    .transactionManager(transactionManager)                   // a transaction manager of your own
    .jsonMapper(jsonMapper)                                   // the JSON serialization of metadata and payloads
    .indexedAttributes(Set.of("tenant"))                      // besides the principal name
    .build();

Design notes

Event serialization. An event is stored in four columns: event_type, message_type (the MessageType of an event that carries a message, else NULL), message_content (the plain text of that message, its own column so a store can search it) and payload (JSON, never NULL). The metadata columns of a session and of an event are JSON and never NULL either; a table created from an earlier script, where they were nullable, needs the constraint added. For an event with a message the payload holds the message’s ordered Spring AI MessagePart`s and its metadata, serialized with the parts' own polymorphic JSON, so media, tool calls, tool results and provider-signed reasoning all survive a round trip. For any other event type it holds `SessionEvent.getPayload(). SessionEventCodec defines this shape, so a store of your own can keep the same thing.

Idempotent append. appendEvent looks the event id up under the session row lock before inserting. An id already stored for the same session is a retried append and a no-op; an id used by another session is rejected. Checking first, rather than relying on a unique-constraint violation, keeps a replay from marking a caller’s surrounding transaction rollback-only, or aborting it on PostgreSQL.

Optimistic concurrency. The event_version column of AI_SESSION advances on every append of a new event and every successful appendEvents. appendEvents claims the version with UPDATE … WHERE event_version = ? before inserting, so two concurrent compactions cannot both land a boundary. An appendEvent bumps the version first too, under the same row lock, so a compaction never misses an event that is being appended: the event’s seq is assigned only once the compaction has committed, and it enters the view as appended since.

A compaction and a concurrent append

The active view. A read with the view flag selects the session’s newest COMPACTION row, fetches in one query the rows it names plus every row from the boundary on, ordered by seq, and lets EventFilter.apply put them in view order, filter and window them.

Windows. A lastN window is ORDER BY seq DESC LIMIT ?. When the window lands mid-turn, one bounded extra query fetches the rows between the nearest preceding user message and the window, served by the (session_id, seq) index, so the window keeps turns whole. The newest boundary of a session, read before every view, is served by the (session_id, event_type, seq) index.

Keys compare byte for byte. On MySQL and MariaDB the id and index columns use the utf8mb4_bin collation, so findByMetadata and the id lookups are case-sensitive, as on the other databases.

Indexed attributes. On every save the repository copies the metadata entries whose keys are configured as indexed, always including SessionAttributes.PRINCIPAL_NAME, into AI_SESSION_INDEX, one row per attribute, on the first 512 characters of the value; the whole value is then compared on the sessions the query returns, so the row is a prefix index, not the match. findByMetadata(name, value) is one query over that table and an exact match on what it returns.

The synthetic column is stored on its own, not only in the metadata JSON, so excludeSynthetic is a SQL predicate rather than an in-process scan.

Auto-configuration

The starter, spring-ai-starter-model-session-jdbc, brings the two auto-configurations with Spring Boot’s JDBC starter. JdbcSessionRepositoryAutoConfiguration creates a JdbcSessionRepository when a single DataSource bean is present, and SessionServiceAutoConfiguration a DefaultSessionService on top of the single SessionRepository bean, whatever its implementation. Both use @ConditionalOnMissingBean: declare your own bean of either type and the auto-configuration backs off. The repository runs its writes in the application’s single PlatformTransactionManager when that manager is on the same DataSource, JDBC or JPA, or a JTA one; with any other manager, one for a message broker or a document store for example, it uses a DataSourceTransactionManager of its own. A SessionServiceDecorator bean wraps the auto-configured service, for a cross-cutting concern such as authorization, without post-processing every SessionService bean.

Property Default Description

spring.ai.session.time-to-live

60d

The time to live of a session the service creates without one of its own; any Duration, such as 2h or 30d.

spring.ai.session.allow-system-messages

false

Whether a SystemMessage may be stored in a session; see System Messages.

spring.ai.session.repository.jdbc.initialize-schema

embedded

When the schema script runs: embedded (an embedded database such as H2), always or never.

spring.ai.session.repository.jdbc.schema

classpath:org/springframework/ai/session/jdbc/schema-@@platform@@.sql

The schema script; @@platform@@ resolves to the detected database.

spring.ai.session.repository.jdbc.platform

detected

Overrides the platform detection used to resolve @@platform@@.

spring.ai.session.repository.jdbc.continue-on-error

true

Whether to continue startup if the schema script fails.

spring.ai.session.repository.jdbc.indexed-attributes

empty

The metadata keys indexed besides the principal name, so findByMetadata answers for them.

Implementing a repository

A store implements SessionRepository under one rule: the backend owns selection, the core owns interpretation. Filtering and windowing are the backend’s job, because their cost grows with the log; what a compaction means is decided in the core and handed to the backend as plain writes.

  • Reads push the filter down. findEvents(sessionId, filter) evaluates the types, the synthetic flag and the window in the query. EventFilter.apply(events) is the reference implementation for a log held as a list; use it for what the store cannot express. A view read fetches the rows the newest boundary names plus the rows from the boundary on, and lets EventFilter.apply project, filter and window them. A lastN window keeps turns whole, as the filter page describes.

  • Events have one persisted shape. SessionEventCodec encodes an event into its type, message type, text and JSON payload, and decodes it back.

  • Compaction is an append. appendEvents(sessionId, events, expectedVersion) appends the boundary and its summary events in one version-checked write and returns false on a stale version. Existing events never move, change or disappear; CompactionBoundary.project(log) is the reference projection.

  • findByMetadata is the one scoped lookup: every store indexes SessionAttributes.PRINCIPAL_NAME.

spring-ai-test ships AbstractSessionRepositoryContractTests, the suite the in-memory, H2, PostgreSQL, MySQL and MariaDB repositories run. Extend it and return your repository:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-test</artifactId>
    <scope>test</scope>
</dependency>
class MySessionRepositoryContractTests extends AbstractSessionRepositoryContractTests {

    @Override
    protected SessionRepository createRepository() {
        return new MySessionRepository(/* a fresh or emptied store */);
    }

}

It covers idempotent append, the version rules, saveIfAbsent under concurrent creates, expiry cleanup, every filter criterion and window, and every compaction rule: an atomic append under a stale version, rejected ids, and the view a boundary defines, across chained boundaries.