diff --git a/.github/workflows/database-session-service.yml b/.github/workflows/database-session-service.yml new file mode 100644 index 000000000..3f1ddc621 --- /dev/null +++ b/.github/workflows/database-session-service.yml @@ -0,0 +1,63 @@ +# Copyright 2026 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# https://www.apache.org/licenses/LICENSE-2.0 +name: database-session-service + +on: + pull_request: + branches: [main, 'release/**'] + paths: + - 'contrib/database-session-service/**' + - 'pom.xml' + - '.github/workflows/database-session-service.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + real-databases: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - java: '17' + backends: postgresql,mysql,mariadb,postgresql18,mariadb114 + - java: '25' + backends: postgresql,mysql,mariadb + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-database-sessions-${{ matrix.java }}-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-database-sessions-${{ matrix.java }}- + - name: Build core and its shared test jar + run: ./mvnw -B -ntp -pl core -am -DskipTests install + - name: Real database and consumer contracts + run: ./mvnw -B -ntp -pl contrib/database-session-service -Pintegration-tests,consumer-tests -Dadk.test.backends=${{ matrix.backends }} verify + - name: Verify formatting stayed clean + run: git diff --exit-code + - name: Preserve reports and actual database versions + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: database-sessions-java-${{ matrix.java }} + if-no-files-found: warn + path: | + contrib/database-session-service/target/surefire-reports/ + contrib/database-session-service/target/failsafe-reports/ + contrib/database-session-service/target/invoker-reports/ + contrib/database-session-service/target/consumer-build/**/build.log diff --git a/contrib/database-session-service/README.md b/contrib/database-session-service/README.md new file mode 100644 index 000000000..b99a03a78 --- /dev/null +++ b/contrib/database-session-service/README.md @@ -0,0 +1,269 @@ +# Database Session Service for ADK Java + +A JDBC `BaseSessionService` for SQLite, PostgreSQL, MySQL, and MariaDB. It follows +Java session behavior and uses the [Python v1 schema](https://github.com/google/adk-python/blob/098eefa2466d923cf5fec10b297057e012069d89/src/google/adk/sessions/schemas/v1.py) +as its relational layout. SQL Server and Spanner are outside this first version. + +## Getting started + +### 1. Add the module + +Keep this module and ADK core on the same version. Java 17 or later. + + + +```xml + + com.google.adk + google-adk-database-session-service + 1.14.1-SNAPSHOT + +``` + +```gradle +implementation 'com.google.adk:google-adk-database-session-service:1.14.1-SNAPSHOT' +``` + + + +Add the driver for your database and, if running migrations, its Flyway plugin +on the same version as Flyway core (verified with 12.4.0). + +| Database | JDBC driver | Flyway plugin | +| --- | --- | --- | +| SQLite | `org.xerial:sqlite-jdbc` | Included in Flyway core | +| PostgreSQL | `org.postgresql:postgresql` | `org.flywaydb:flyway-database-postgresql` | +| MySQL | `com.mysql:mysql-connector-j` | `org.flywaydb:flyway-mysql` | +| MariaDB | `org.mariadb.jdbc:mariadb-java-client` | `org.flywaydb:flyway-mysql` | + +### 2. Prepare a database + +Give the service a database, or at least a schema, of its own, and its own +`DataSource`. Neither is enforced, and both are worth it: + +- The tables are named as in the Python v1 schema: `sessions`, `events`, + `app_states`, `user_states` and `adk_internal_metadata`. Four of those names + are plain enough for an application to use already, and there is no prefix + option. The migration stops when it finds tables under these names that it + did not create; it never adopts or changes them. +- The service needs connections with auto-commit on and blocks the calling + thread while it uses them. With its own pool, its settings and its load stay + apart from the application's. + +Sharing the application's `DataSource` works as long as its connections have +auto-commit on and its schema holds no tables with the names above. + +| Database | Prepare with | JDBC URL | +| --- | --- | --- | +| PostgreSQL | [`sql/postgresql-setup.sql`](sql/postgresql-setup.sql), as a superuser | `jdbc:postgresql://:5432/adk_sessions` | +| MySQL | [`sql/mysql-setup.sql`](sql/mysql-setup.sql), as an administrator | `jdbc:mysql://:3306/adk_sessions` | +| MariaDB | the same [`sql/mysql-setup.sql`](sql/mysql-setup.sql) | `jdbc:mariadb://:3306/adk_sessions` | +| SQLite | nothing; the file is created on first use | `jdbc:sqlite:/var/lib/my-app/adk-sessions.db` | + +Each script creates the database `adk_sessions` and two accounts, and no +tables. `adk_sessions` owns the database and creates the tables. +`adk_sessions_app` is optional: an account for the running application that +can read and write the rows (`SELECT`, `INSERT`, `UPDATE`, `DELETE`, which is +all the service uses at run time) and cannot change the tables. Replace the +passwords in the scripts before running them. + +To keep the tables inside a PostgreSQL database you already have, create a +schema instead of a database and name it in the URL: + +```sql +CREATE SCHEMA adk AUTHORIZATION adk_sessions; +-- Only with the optional run-time account: +GRANT USAGE ON SCHEMA adk TO adk_sessions_app; +ALTER DEFAULT PRIVILEGES FOR ROLE adk_sessions IN SCHEMA adk + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO adk_sessions_app; +``` + +``` +jdbc:postgresql://:5432/?currentSchema=adk +``` + +In MySQL and MariaDB a schema is a database, so the dedicated database is the +way to separate the tables. For SQLite use a file; a per-connection `:memory:` +database does not persist across borrows. + +MySQL needs 8.0.17 or later, for the `utf8mb4_0900_bin` collation of the key +columns. The versions the module is tested with are listed under +[Verification](#verification). + +### 3. Create the tables + +Either let the module do it, connected as the owner: + +```java +DatabaseSessionService sessions = new DatabaseSessionService(ownerDataSource); +sessions.migrate().blockingAwait(); // One migration owner, before starting workers. +``` + +`migrate()` can be repeated; on an up-to-date schema it changes nothing. A +service built with `.autoMigrate(true)` runs it before its first operation +instead. Migrations use only the ADK migration locations and +`adk_schema_history`; they neither scan application migrations nor adopt +foreign or partial tables. Run them before concurrent workers start. + +Or run the schema script yourself, as the owner. There is one per database, in +this repository under +[`src/main/resources/com/google/adk/sessions/migration`](src/main/resources/com/google/adk/sessions/migration) +and at the same path in the module's jar: + +```shell +unzip -p google-adk-database-session-service-.jar \ + com/google/adk/sessions/migration/postgresql/V1__create_session_schema.sql > schema.sql + +psql -h -U adk_sessions -d adk_sessions -v ON_ERROR_STOP=1 -f schema.sql +mysql -h -u adk_sessions -p adk_sessions < schema.sql # from migration/mysql +mariadb -h -u adk_sessions -p adk_sessions < schema.sql # from migration/mariadb +sqlite3 /var/lib/my-app/adk-sessions.db < schema.sql # from migration/sqlite +``` + +The script is the same SQL that `migrate()` runs, but it leaves no Flyway +history. Choose one of the two ways and stay with it: on tables created by +hand, keep auto-migration off and do not call `migrate()`, which reports them +as a partial schema. Apply the scripts of later versions by hand as well. + +### 4. Connect and use it + +The service takes any `DataSource`. The module reads no environment variables, +system properties or configuration files; where the URL and the credentials +come from is up to the application. With HikariCP, for example: + +```java +HikariConfig config = new HikariConfig(); +config.setJdbcUrl(System.getenv("ADK_SESSIONS_JDBC_URL")); // the application's own names +config.setUsername(System.getenv("ADK_SESSIONS_DB_USER")); +config.setPassword(System.getenv("ADK_SESSIONS_DB_PASSWORD")); +config.setMaximumPoolSize(10); +DataSource dataSource = new HikariDataSource(config); + +DatabaseSessionService sessions = new DatabaseSessionService(dataSource); + +Runner runner = + Runner.builder().appName("my-app").agent(yourAgent).sessionService(sessions).build(); +``` + +Your application owns the `DataSource`, pool, and timeouts, and closes them. +Construction performs no I/O. Operations are cold and JDBC runs on the +subscribing thread; use an application-owned scheduler if needed. + +Use independently borrowed auto-commit connections, writable for writes. A +connection that is already inside a transaction, such as one from a pool +configured with auto-commit off, is refused before anything runs on it. The +service runs its own short transactions and does not join one of the caller's. + +The builder has the optional settings: + +| Setting | Default | Purpose | +| --- | --- | --- | +| `dialect(Dialect)` | detected from JDBC metadata on first use | Skip detection, or name the dialect of a compatible database. | +| `autoMigrate(boolean)` | `false` | Migrate before the first operation instead of calling `migrate()`. | +| `statementTimeout(Duration)` | 15 seconds | Limit for each statement, and for SQLite's wait on a competing writer. Not an end-to-end deadline: one operation runs several statements. Connection and network timeouts belong to the `DataSource`. | +| `instantSource(InstantSource)` | system clock | Creation time of a session. | +| `uuidProvider(UuidProvider)` | random UUID | ID of a session created without one. | + +```java +DatabaseSessionService sessions = + DatabaseSessionService.builder(dataSource) + .dialect(DatabaseSessionService.Dialect.POSTGRESQL) + .statementTimeout(Duration.ofSeconds(30)) + .build(); +``` + +### Spring Boot + +Declare the service as a bean over a `DataSource`: + +```java +@Configuration +class SessionServiceConfiguration { + @Bean + DatabaseSessionService sessionService(DataSource dataSource) { + DatabaseSessionService sessions = new DatabaseSessionService(dataSource); + sessions.migrate().blockingAwait(); + return sessions; + } +} +``` + +- The module's migration and the application's own Flyway migrations do not + touch each other: they use different script locations and history tables. +- Leave `spring.datasource.hikari.auto-commit` at its default, `true`. +- For a database of its own, give the service a `DataSource` other than the + application's primary one. + +## Behavior + +| Operation | JDBC behavior and comparison | +| --- | --- | +| Create | As in InMemory, a blank session ID asks for a generated one; any other ID is kept exactly as given. A duplicate ID is rejected with `SESSION_ALREADY_EXISTS`. Initial `app:`/`user:` keys go to the shared state, as in Python and unlike InMemory. | +| Append | Like InMemory, partial events are no-ops and history follows append order, even when timestamps tie or go backwards. An event ID must be unique within its session, as in Python v1 and unlike InMemory. | +| Get | As in InMemory, take the last N appends, then apply the inclusive time cursor. N=0 returns no history; a missing session returns an empty `Maybe`. | +| List | Returns state/metadata without loading events, ordered by update time then ID. | +| Delete / list events | A missing session throws `SessionNotFoundException` per the Java interface Javadocs. Deleting a session cascades its events but preserves shared state. | +| State | App/user state is shared, local state is per session, and `temp:` is never persisted (including event deltas). A committed append applies only its delta to the supplied session; as in InMemory that includes its `temp:` keys, so later steps of the same invocation can read them, and they are gone on reload. Other sessions' shared changes are visible on reload. | + +**Append needs the current tail.** The transaction compares the supplied last +event ID with the stored tail, or requires both histories to be empty. Reload +before appending a stale or filtered snapshot that lacks the tail. Detached +copies with the tail work without any extra core fields. Do not reuse snapshots +after delete/recreate: empty histories and reused tail IDs cannot distinguish +the old session from the new one. + +Only after commit and connection cleanup succeed does the service update the +supplied session. Do not mutate the submitted event/delta while it is being +persisted. If commit acknowledgement is lost, or cleanup fails after commit, +reload before retrying. Errors distinguish these outcomes without exposing raw +driver messages, credentials, or payloads. + +When the database itself gives up on a transaction because of a concurrent one +(a deadlock victim, a serialization failure, or a lock wait that timed out), +the error says that nothing was written and the same call can simply be +repeated. Every failure is logged at WARN with its operation, phase, SQLSTATE +and vendor code. The driver's own exception, whose message may quote SQL or +data, is logged only at DEBUG for +`com.google.adk.sessions.DatabaseDiagnostics`. + +## Storage + +The five Python v1 tables, scoped primary keys, and +`idx_events_app_user_session_ts_id` are retained, under their Python names. +Java adds a per-session `event_sequence` for append order, its storage-format +marker, explicit key collations and validation checks, and Flyway history. + +- Zone-free UTC SQL times have six fractional digits (fixed-width text on + SQLite), with a common year range of 1000–9999. Session creation time is + truncated to microseconds; event timestamps retain Java milliseconds. + App/user update times come from the database UTC clock. +- Identifiers use exact `VARCHAR(128)` comparisons (C/BINARY or a MySQL/MariaDB + binary NO PAD collation). Case, accents, and trailing spaces are significant. + MySQL/MariaDB require InnoDB pages of at least 16 KiB for the composite keys. +- The complete Java event is stored as JSON: PostgreSQL `json`, MySQL/MariaDB + validated LONGTEXT, and SQLite validated text. The `invocation_id` column is + only a copy (empty when absent, at most 256 code points). Java always writes + `event_data`; a NULL payload cannot be decoded. + +**Do not share the database with Python or another ADK SDK.** Java requires both +`schema_version=1` and `adk_java_storage_format=jdbc-v1` and rejects a +Python-created schema. Python may still accept Java's numeric version and read +Java event milliseconds as seconds. No conversion from the former MySQL-only +contribution is included. + +## Verification + +From the repository root, with Docker running for the integration profile: + +```shell +./mvnw -pl core -am -DskipTests install +./mvnw -pl contrib/database-session-service test +./mvnw -pl contrib/database-session-service '-Pintegration-tests,consumer-tests' '-Dadk.test.backends=postgresql,mysql,mariadb,postgresql18,mariadb114' verify +``` + +The same contract runs on SQLite default/WAL and real PostgreSQL 17.10/18.4, +MySQL 8.4.11, and MariaDB 11.8.7/11.4.12 containers. Its part that describes +behavior shared with `InMemorySessionService` also runs against that reference +in core. Docker is required, not silently skipped. Consumer tests also cover a +standalone Spring Boot/Flyway application with separate migrations. Reports are +in the module's `target`. diff --git a/contrib/database-session-service/pom.xml b/contrib/database-session-service/pom.xml new file mode 100644 index 000000000..3368a1b38 --- /dev/null +++ b/contrib/database-session-service/pom.xml @@ -0,0 +1,258 @@ + + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.14.1-SNAPSHOT + ../../pom.xml + + + google-adk-database-session-service + Agent Development Kit - Database Session Management + JDBC session persistence for Agent Development Kit + + + 3.54.1.0 + 2.0.5 + 3.7.1 + 42.7.14 + 26.10.0 + 3.5.11 + 7.1.0 + postgresql,mysql,mariadb + "-javaagent:${org.mockito:mockito-core:jar}" + + + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + warn + + + + + com.spotify.fmt + fmt-maven-plugin + + + ${project.basedir}/src/test/consumers + + + + + + + + + com.google.adk + google-adk + ${project.version} + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + true + + + org.flywaydb + flyway-mysql + true + + + org.xerial + sqlite-jdbc + ${sqlite-jdbc.version} + test + + + com.google.truth + truth + test + + + com.google.adk + google-adk + ${project.version} + test-jar + test + + + com.zaxxer + HikariCP + ${hikaricp.version} + test + + + org.postgresql + postgresql + ${postgresql-jdbc.version} + test + + + com.mysql + mysql-connector-j + ${mysql-jdbc.version} + test + + + org.mariadb.jdbc + mariadb-java-client + ${mariadb-jdbc.version} + test + + + org.testcontainers + testcontainers-postgresql + ${testcontainers.version} + test + + + org.testcontainers + testcontainers-mysql + ${testcontainers.version} + test + + + org.testcontainers + testcontainers-mariadb + ${testcontainers.version} + test + + + org.mockito + mockito-core + test + + + org.slf4j + slf4j-simple + 2.0.17 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + consumer-tests + + + + org.apache.maven.plugins + maven-invoker-plugin + 3.10.1 + + ${project.basedir}/src/test/consumers + ${project.build.directory}/consumer-build + ${project.build.directory}/invoker-reports + true + verify + + ${project.version} + ${project.build.directory}/${project.build.finalName}.jar + + + + run + + + + + + + integration-tests + + + + com.github.docker-java + docker-java-api + ${testcontainers-docker-java.version} + test + + + com.github.docker-java + docker-java-transport + ${testcontainers-docker-java.version} + test + + + com.github.docker-java + docker-java-transport-zerodep + ${testcontainers-docker-java.version} + test + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.2 + + ${surefire.argLine} + 1800 + **/*IT.java + + ${adk.test.backends} + warn + + + + + integration-testverify + + + + + + + + diff --git a/contrib/database-session-service/sql/mysql-setup.sql b/contrib/database-session-service/sql/mysql-setup.sql new file mode 100644 index 000000000..98babb95b --- /dev/null +++ b/contrib/database-session-service/sql/mysql-setup.sql @@ -0,0 +1,36 @@ +-- Copyright 2026 Google LLC +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software distributed under +-- the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +-- ANY KIND, either express or implied. See the License for the specific language +-- governing permissions and limitations under the License. + +-- Prepares MySQL or MariaDB for the ADK Java database session service: a database of its own +-- and the users that use it. The statements are the same for both engines. Run it once, as an +-- administrator: +-- +-- mysql -h -u root -p < mysql-setup.sql +-- mariadb -h -u root -p < mysql-setup.sql +-- +-- It creates no tables. Those come from DatabaseSessionService.migrate(), or from running the +-- module's mysql/ or mariadb/V1__create_session_schema.sql as adk_sessions; the README has +-- both ways. +-- +-- Replace both passwords before use, and narrow '%' to the hosts the application connects from. + +-- A database of its own keeps the tables, whose names are as plain as "sessions" and "events", +-- away from the application's. The tables set their own character set and collation; this +-- default only applies to anything else created here. +CREATE DATABASE adk_sessions CHARACTER SET utf8mb4; + +-- The owner: creates the tables, so it is the user for migrate() and for running the schema +-- script by hand. It may also be the only user, used by the application as well. +CREATE USER 'adk_sessions'@'%' IDENTIFIED BY 'change-me'; +GRANT ALL PRIVILEGES ON adk_sessions.* TO 'adk_sessions'@'%'; + +-- Optional: a second user for the application at run time. It reads and writes the rows and +-- cannot create, alter or drop a table. Leave this part out to use adk_sessions for everything. +CREATE USER 'adk_sessions_app'@'%' IDENTIFIED BY 'change-me-too'; +GRANT SELECT, INSERT, UPDATE, DELETE ON adk_sessions.* TO 'adk_sessions_app'@'%'; diff --git a/contrib/database-session-service/sql/postgresql-setup.sql b/contrib/database-session-service/sql/postgresql-setup.sql new file mode 100644 index 000000000..15ff0cc31 --- /dev/null +++ b/contrib/database-session-service/sql/postgresql-setup.sql @@ -0,0 +1,38 @@ +-- Copyright 2026 Google LLC +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software distributed under +-- the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +-- ANY KIND, either express or implied. See the License for the specific language +-- governing permissions and limitations under the License. + +-- Prepares PostgreSQL for the ADK Java database session service: a database of its own and +-- the roles that use it. Run it once, as a superuser, with psql: +-- +-- psql -h -U postgres -v ON_ERROR_STOP=1 -f postgresql-setup.sql +-- +-- It creates no tables. Those come from DatabaseSessionService.migrate(), or from running the +-- module's postgresql/V1__create_session_schema.sql as adk_sessions; the README has both ways. +-- +-- Replace both passwords before use. + +-- The owner: creates the tables, so it is the role for migrate() and for running the schema +-- script by hand. It may also be the only role, used by the application as well. +CREATE ROLE adk_sessions LOGIN PASSWORD 'change-me'; + +-- A database of its own keeps the tables, whose names are as plain as "sessions" and "events", +-- away from the application's. template0 lets the encoding be UTF8 whatever the cluster default. +CREATE DATABASE adk_sessions OWNER adk_sessions ENCODING 'UTF8' TEMPLATE template0; + +-- Optional: a second role for the application at run time. It reads and writes the rows and +-- cannot create, alter or drop a table. Leave this part out to use adk_sessions for everything. +CREATE ROLE adk_sessions_app LOGIN PASSWORD 'change-me-too'; + +\connect adk_sessions + +-- Applies to the tables adk_sessions creates from now on, so run this script before the tables +-- exist. For tables that are already there, grant the same four rights with +-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO adk_sessions_app; +ALTER DEFAULT PRIVILEGES FOR ROLE adk_sessions IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO adk_sessions_app; diff --git a/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseDiagnostics.java b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseDiagnostics.java new file mode 100644 index 000000000..54c578b4a --- /dev/null +++ b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseDiagnostics.java @@ -0,0 +1,191 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.Connection; +import java.sql.SQLException; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Reports stable diagnostic fields without passing a driver exception to a caller. The driver's own + * exception, whose message can quote SQL or data, is logged only at DEBUG, which an operator turns + * on deliberately. + * + *

A database failure therefore reaches two audiences in two forms. The caller gets a {@link + * SessionException} with a message from this module and no cause. The operator gets one WARN line + * with the operation, the phase it failed in, the dialect, the exception's class, its SQLSTATE and + * its vendor code, which is what is needed to look the failure up. + */ +final class DatabaseDiagnostics { + private static final Logger logger = LoggerFactory.getLogger(DatabaseDiagnostics.class); + + private DatabaseDiagnostics() {} + + /** The step of an operation in which a failure happened, in the order the steps run. */ + enum Phase { + /** Borrowing a connection from the data source. */ + CONNECTION, + /** Checking the borrowed connection, setting isolation, and beginning the transaction. */ + SETUP, + /** Checking that the schema is the one this version reads and writes. */ + SCHEMA, + /** Running the statements of the operation. */ + STATEMENT, + /** Committing. */ + COMMIT, + /** Restoring the connection's settings and returning it. */ + CLEANUP, + /** Running the schema migration, which is not part of an operation. */ + MIGRATION + } + + /** + * Requires a connection this module can run its own transaction on. + * + *

A connection with auto-commit off is, as far as can be told, in the middle of the caller's + * transaction: committing on it would commit the caller's work, and rolling back would discard + * it. It is refused before anything is executed. + * + * @param write whether the operation changes data, which a read-only connection cannot do + * @throws Configuration with a message naming what the data source has to provide + */ + static void requireConnection(Connection connection, boolean write) throws SQLException { + if (!connection.getAutoCommit()) { + throw new Configuration("ADK requires a borrowed connection with auto-commit enabled."); + } + if (write && connection.isReadOnly()) { + throw new Configuration("ADK writes require a writable database connection."); + } + } + + /** + * Logs a failure and returns the exception to throw in its place. + * + * @param message the text for the caller; written by this module, without data from {@code + * failure} + * @param failure what actually went wrong; reported to the log, and deliberately not attached as + * the cause + */ + static SessionException failure( + String operation, Phase phase, @Nullable Dialect dialect, String message, Throwable failure) { + report(operation, phase, dialect, failure); + return new SessionException(message); + } + + /** + * Logs a failure: its coded fields at WARN, and the exception itself with its stack trace at + * DEBUG. + * + * @param dialect {@code null} while the dialect is still being detected + */ + static void report(String operation, Phase phase, @Nullable Dialect dialect, Throwable failure) { + SqlDiagnostic code = sqlDiagnostic(failure); + logger.warn( + "ADK JDBC failure: operation={} phase={} dialect={} exceptionType={} sqlState={} vendorCode={}", + operation, + phase, + dialect, + failure.getClass().getName(), + code.state(), + code.vendorCode()); + if (logger.isDebugEnabled()) { + logger.debug("ADK JDBC failure detail: operation={} phase={}", operation, phase, failure); + } + } + + /** + * The two coded fields of a JDBC failure. + * + * @param state the five-character SQLSTATE, or {@code "unavailable"} + * @param vendorCode the engine's own error number, or 0 + */ + record SqlDiagnostic(String state, int vendorCode) {} + + /** + * Extracts the coded fields of the first {@link SQLException} in the cause chain. Messages are + * never read. + */ + static SqlDiagnostic sqlDiagnostic(Throwable failure) { + SQLException sql = sqlException(failure); + if (sql == null) { + return new SqlDiagnostic("unavailable", 0); + } + String state = sql.getSQLState(); + return new SqlDiagnostic(isSqlState(state) ? state : "unavailable", sql.getErrorCode()); + } + + /** + * Whether the database itself gave up on the transaction because another one held conflicting + * locks: a deadlock victim, a serialization failure, or a lock wait that timed out. + */ + static boolean concurrencyConflict(Throwable failure, Dialect dialect) { + SQLException sql = sqlException(failure); + if (sql == null) { + return false; + } + String state = sql.getSQLState(); + // Class 40 is the standard "transaction rollback" class on every engine. + if (state != null && state.startsWith("40")) { + return true; + } + int code = sql.getErrorCode(); + return switch (dialect) { + case POSTGRESQL -> "55P03".equals(state); // lock_not_available, including lock_timeout + case MYSQL, MARIADB -> code == 1205 || code == 1213; // lock wait timeout, deadlock + // SQLITE_BUSY and SQLITE_LOCKED; the low byte also matches their extended codes. + case SQLITE -> (code & 0xFF) == 5 || (code & 0xFF) == 6; + }; + } + + /** Finds the first {@link SQLException} among a failure and its causes, a few levels deep. */ + private static @Nullable SQLException sqlException(Throwable failure) { + // Flyway may wrap the SQLException. Bound traversal and never read exception messages. + Throwable cause = failure; + for (int depth = 0; cause != null && depth < 8; depth++, cause = cause.getCause()) { + if (cause instanceof SQLException sql) { + return sql; + } + } + return null; + } + + /** SQLSTATE is five upper-case letters or digits; anything else is not a code to repeat. */ + private static boolean isSqlState(@Nullable String state) { + if (state == null || state.length() != 5) { + return false; + } + for (int i = 0; i < 5; i++) { + char c = state.charAt(i); + if (!(c >= '0' && c <= '9') && !(c >= 'A' && c <= 'Z')) { + return false; + } + } + return true; + } + + /** + * A known configuration error whose message is defined by this module. Because the text is the + * module's own, it is the one failure whose message is handed to the caller unchanged. + */ + static final class Configuration extends RuntimeException { + Configuration(String message) { + super(message); + } + } +} diff --git a/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseDialect.java b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseDialect.java new file mode 100644 index 000000000..9e88c19cf --- /dev/null +++ b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseDialect.java @@ -0,0 +1,149 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import com.google.adk.sessions.DatabaseDiagnostics.Phase; +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Locale; +import javax.sql.DataSource; + +/** + * The places where the four engines need different SQL or a different JDBC call. Everything else in + * {@link DatabaseSessionStore} is the same statement for all of them. + * + *

The table definitions differ too; those are in the per-dialect migration scripts under {@code + * com/google/adk/sessions/migration}. + */ +final class DatabaseDialect { + private DatabaseDialect() {} + + /** + * The statement that reads the two rows identifying the schema: the layout version, which the + * Python v1 schema defines, and the marker saying that this module's Java format is stored in it. + * {@code key} is a reserved word in MySQL and MariaDB and has to be quoted there. + */ + static String metadataQuery(Dialect dialect) { + String key = dialect == Dialect.MYSQL || dialect == Dialect.MARIADB ? "`key`" : "key"; + return "SELECT " + + key + + ",value FROM adk_internal_metadata WHERE " + + key + + " IN ('schema_version','adk_java_storage_format')"; + } + + /** + * Binds JSON text to a state or event column. PostgreSQL's {@code json} column does not accept a + * parameter typed as text, so the value is sent untyped and the server takes it as JSON. The + * other engines keep JSON in text columns. + */ + static void bindJson(PreparedStatement statement, int index, String json, Dialect dialect) + throws SQLException { + if (dialect == Dialect.POSTGRESQL) { + statement.setObject(index, json, Types.OTHER); + } else { + statement.setString(index, json); + } + } + + /** + * The clause that makes a {@code SELECT} lock the rows it reads until the transaction ends. + * SQLite has no such clause and needs none: a write transaction there is the only writer. + */ + static String lock(Dialect dialect) { + return dialect == Dialect.SQLITE ? "" : " FOR UPDATE"; + } + + /** + * The statement that inserts an empty shared-state row unless the row is already there. + * + *

Parameters: the app name, the user ID if {@code user}, then the JSON document. The update + * time is the database's clock. + * + *

MySQL and MariaDB have no {@code ON CONFLICT} clause. Their counterpart is {@code ON + * DUPLICATE KEY UPDATE} with an assignment that changes nothing. On an existing row it takes an + * exclusive lock right away, where a plain {@code INSERT} that hits a duplicate takes a shared + * lock first, which is how two concurrent inserters of the same key come to deadlock. + * + * @param table {@code app_states} or {@code user_states} + * @param user whether the row is per user + */ + static String insertShared(String table, boolean user, Dialect dialect) { + String columns = user ? "app_name,user_id,state,update_time" : "app_name,state,update_time"; + String values = (user ? "?,?,?," : "?,?,") + utcNow(dialect); + String conflict = + dialect == Dialect.MYSQL || dialect == Dialect.MARIADB + ? " ON DUPLICATE KEY UPDATE app_name=app_name" + : " ON CONFLICT DO NOTHING"; + return "INSERT INTO " + table + " (" + columns + ") VALUES (" + values + ")" + conflict; + } + + /** + * An expression for the database's current time as a UTC value without a zone, at the microsecond + * precision of the time columns. It does not depend on the time zone of the session, the server + * or the JVM. + */ + static String utcNow(Dialect dialect) { + return switch (dialect) { + case POSTGRESQL -> "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')"; + case MYSQL, MARIADB -> "UTC_TIMESTAMP(6)"; + // SQLite's %f has three fractional digits; use the same six-digit format as JDBC writes. + case SQLITE -> "strftime('%Y-%m-%d %H:%M:%f','now') || '000'"; + }; + } + + /** + * Detects the dialect from the product name the JDBC driver reports, borrowing one connection. + * The JDBC URL is never looked at: a pool or proxy may not expose it, and it can hold + * credentials. + * + * @throws SessionException if no connection could be borrowed or the product is not one of the + * four supported ones; the message does not repeat the product name + */ + static Dialect detect(DataSource dataSource) { + String productName; + try (Connection connection = dataSource.getConnection()) { + productName = connection.getMetaData().getDatabaseProductName(); + } catch (SQLException | RuntimeException driverFailure) { + throw DatabaseDiagnostics.failure( + "detectDialect", + Phase.CONNECTION, + null, + "Unable to determine the database dialect from JDBC metadata.", + driverFailure); + } + + if (productName != null) { + switch (productName.strip().toLowerCase(Locale.ROOT)) { + case "sqlite": + return Dialect.SQLITE; + case "postgresql": + return Dialect.POSTGRESQL; + case "mysql": + return Dialect.MYSQL; + case "mariadb": + return Dialect.MARIADB; + default: + break; + } + } + throw new SessionException( + "Unsupported database product; select a supported dialect explicitly."); + } +} diff --git a/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSchemaMigrator.java b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSchemaMigrator.java new file mode 100644 index 000000000..6647c502c --- /dev/null +++ b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSchemaMigrator.java @@ -0,0 +1,217 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import com.google.adk.sessions.DatabaseDiagnostics.Phase; +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import javax.sql.DataSource; +import org.flywaydb.core.api.configuration.FluentConfiguration; + +/** + * Isolated, explicit Flyway bootstrap. Never adopts tables merely because their names match. + * + *

Flyway is used as an embedded library over the application's data source, kept apart from any + * Flyway the application runs for itself: + * + *

    + *
  • The scripts are in {@code com/google/adk/sessions/migration/}, not in Flyway's + * default {@code db/migration}, so an application's own Flyway does not pick them up. + *
  • The history is kept in {@code adk_schema_history}, not in the application's {@code + * flyway_schema_history}. + *
  • A schema that already holds other tables is baselined at version 0, so that version 1 of + * this module still runs in it. + *
+ * + *

The table names are those of the Python v1 schema and are common words. Before and after + * migrating, the tables found under those names are therefore checked to be this module's own; see + * {@link #validateOwnership}. + * + *

This is the only class that refers to Flyway. A deployment that creates the tables from the + * SQL scripts itself, and never calls {@code migrate()}, does not load it. + */ +final class DatabaseSchemaMigrator { + /** Value of {@code schema_version}: the layout version, shared with the Python v1 schema. */ + static final String SCHEMA_VERSION = "1"; + + /** + * Value of {@code adk_java_storage_format}: says that the events and times in these tables were + * written by this module. A schema created by ADK Python has the same {@link #SCHEMA_VERSION} but + * stores events in another JSON form, and lacks this row. + */ + static final String STORAGE_FORMAT = "jdbc-v1"; + + /** The tables of the session schema together with this module's Flyway history table. */ + private static final Set TABLES = + Set.of( + "adk_internal_metadata", + "sessions", + "events", + "app_states", + "user_states", + "adk_schema_history"); + + private DatabaseSchemaMigrator() {} + + /** + * Brings the session schema to the current version, creating it in an empty or foreign schema and + * doing nothing when it is up to date. + * + * @throws SessionException if tables of the same names exist that this module did not create, the + * schema is only partly there, or the migration itself fails + */ + static void migrate(DataSource dataSource, Dialect dialect) { + Phase phase = Phase.SCHEMA; + try { + // First, so that a foreign "sessions" or "events" table stops the migration before Flyway + // creates anything next to it. + validateOwnership(dataSource, dialect); + phase = Phase.MIGRATION; + // The class loader of this module, so that the scripts are found in its jar whatever the + // thread's context class loader is. + new FluentConfiguration(DatabaseSchemaMigrator.class.getClassLoader()) + .dataSource(dataSource) + .locations( + "classpath:com/google/adk/sessions/migration/" + + dialect.name().toLowerCase(Locale.ROOT)) + .table("adk_schema_history") + // Without a baseline Flyway refuses a schema that has tables but no history table, + // which is what an application's own database looks like on the first run. + .baselineOnMigrate(true) + .baselineVersion("0") + // This module must never be able to drop the tables of the schema it shares. + .cleanDisabled(true) + .failOnMissingLocations(true) + .validateMigrationNaming(true) + // An empty list of loggers keeps Flyway quiet; its own log lines include the JDBC URL. + .loggers(new String[0]) + .load() + .migrate(); + phase = Phase.SCHEMA; + // On a new connection, as the service will use: a data source that hands out private + // in-memory databases would have migrated one that nobody can reach afterwards. + if (!validateOwnership(dataSource, dialect)) { + throw new DatabaseDiagnostics.Configuration( + "The migrated ADK schema is not visible on a new database connection."); + } + } catch (DatabaseDiagnostics.Configuration configuration) { + throw new SessionException(configuration.getMessage()); + } catch (SQLException | RuntimeException e) { + throw DatabaseDiagnostics.failure( + "migrate", + phase, + dialect, + phase == Phase.SCHEMA + ? "Unable to verify ADK schema ownership before or after migration." + : "Unable to migrate the ADK database schema; verify database and Flyway configuration.", + e); + } + } + + /** + * Looks at the tables that carry one of this module's names in the schema the data source + * connects to, and decides whose they are. + * + *

    + *
  • None of them exists: nothing to own yet. + *
  • All of them exist and the metadata rows are this module's: the schema is ours. + *
  • Anything in between is refused: tables without the metadata table belong to someone else, + * metadata without the Java marker is a Python schema, and an incomplete set is a migration + * that stopped half way or a table that was dropped. + *
+ * + * @return whether the complete schema of this module is present + * @throws DatabaseDiagnostics.Configuration if tables are present that are not this module's + * complete schema + */ + private static boolean validateOwnership(DataSource dataSource, Dialect dialect) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseDiagnostics.requireConnection(connection, true); + // Only the catalog and schema of this connection: tables of the same names elsewhere on + // the server are none of this module's business. Names are compared in lower case because + // engines differ in how they report the case of an unquoted name. + Set existing = new HashSet<>(); + try (ResultSet tables = + connection + .getMetaData() + .getTables( + connection.getCatalog(), + connection.getSchema(), + null, + new String[] {"TABLE", "VIEW"})) { + while (tables.next()) { + String name = tables.getString("TABLE_NAME").toLowerCase(Locale.ROOT); + if (TABLES.contains(name)) { + existing.add(name); + } + } + } + if (existing.isEmpty()) { + return false; + } + if (!existing.contains("adk_internal_metadata")) { + throw new DatabaseDiagnostics.Configuration( + "The database session schema was not created by ADK Java."); + } + try (Statement statement = connection.createStatement(); + ResultSet metadata = statement.executeQuery(DatabaseDialect.metadataQuery(dialect))) { + // A Python V1 schema has the same version but no Java storage-format marker. + requireMetadata(metadata); + } + if (!existing.equals(TABLES)) { + throw new DatabaseDiagnostics.Configuration( + "ADK migration found a partial or conflicting schema."); + } + return true; + } + } + + /** + * Requires both identifying rows of {@code adk_internal_metadata} to have the values this version + * writes. The store calls it at the start of every transaction, with the rows read inside that + * transaction. + * + * @param metadata key and value columns, as selected by {@link DatabaseDialect#metadataQuery} + * @throws DatabaseDiagnostics.Configuration if the Java marker is missing or different, or the + * layout version is not the supported one + */ + static void requireMetadata(ResultSet metadata) throws SQLException { + String format = null; + String version = null; + while (metadata.next()) { + switch (metadata.getString(1)) { + case "adk_java_storage_format" -> format = metadata.getString(2); + case "schema_version" -> version = metadata.getString(2); + default -> {} + } + } + if (!STORAGE_FORMAT.equals(format)) { + throw new DatabaseDiagnostics.Configuration( + "The database session schema was not created by ADK Java."); + } + if (!SCHEMA_VERSION.equals(version)) { + throw new DatabaseDiagnostics.Configuration( + "The ADK database schema version is missing or unsupported."); + } + } +} diff --git a/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSessionService.java b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSessionService.java new file mode 100644 index 000000000..e791984f5 --- /dev/null +++ b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSessionService.java @@ -0,0 +1,652 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import com.google.adk.events.Event; +import com.google.adk.platform.UuidProvider; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentMap; +import javax.sql.DataSource; +import org.jspecify.annotations.Nullable; + +/** + * JDBC session persistence for SQLite, PostgreSQL, MySQL and MariaDB using an application-owned + * {@link DataSource}. + * + *

Construction never borrows a connection, runs migrations, or creates a connection pool. The + * application remains responsible for the lifetime of its data source and its providers. + * + *

{@code
+ * DatabaseSessionService sessions = new DatabaseSessionService(dataSource);
+ * sessions.migrate().blockingAwait(); // once, before workers start
+ *
+ * Runner runner =
+ *     Runner.builder().appName("my-app").agent(agent).sessionService(sessions).build();
+ * }
+ * + *

Execution. Every operation is cold: nothing happens until the returned {@code Single}, + * {@code Maybe} or {@code Completable} is subscribed, and each subscription runs the operation + * again. The JDBC calls block the subscribing thread; move them with {@code subscribeOn} if that + * thread must not block. + * + *

State. Keys with the {@link State#APP_PREFIX} and {@link State#USER_PREFIX} prefixes + * are shared by all sessions of the app, or of the app and user; other keys belong to one session. + * Keys with {@link State#TEMP_PREFIX} are never stored. + * + *

Errors. Operations fail only with {@link SessionException} or {@link + * SessionNotFoundException}. Their messages are written by this module: they never quote a driver + * message, an identifier, or a payload, and they carry no cause. What the driver reported is in the + * log. + * + *

Instances are safe to share between threads. One instance per data source is enough. + */ +public final class DatabaseSessionService implements BaseSessionService { + + /** Database engines with built-in SQL dialects. */ + public enum Dialect { + /** SQLite through a file-backed database; one writer at a time. */ + SQLITE, + /** PostgreSQL. */ + POSTGRESQL, + /** MySQL with InnoDB. */ + MYSQL, + /** MariaDB with InnoDB. Its SQL differs from MySQL's in the collation of the keys. */ + MARIADB + } + + private static final int DEFAULT_STATEMENT_TIMEOUT_SECONDS = 15; + // SQLite takes its busy timeout in milliseconds as an int. + private static final int MAX_STATEMENT_TIMEOUT_SECONDS = Integer.MAX_VALUE / 1000; + + private final DataSource dataSource; + private final InstantSource instantSource; + private final UuidProvider uuidProvider; + private final boolean autoMigrate; + private final int statementTimeoutSeconds; + + // The three fields below are resolved on first use, not at construction, and are guarded by + // this instance's monitor. + private @Nullable Dialect resolvedDialect; + private boolean initialized; + private @Nullable DatabaseSessionStore store; + + /** + * Configures a service with migrations disabled and lazy JDBC metadata dialect detection. + * + *

The schema must already exist when the first operation runs: call {@link #migrate()} once, + * or create the tables with the SQL shipped in this module. + * + * @throws SessionException if the data source is null + */ + public DatabaseSessionService(DataSource dataSource) { + this(builder(dataSource)); + } + + private DatabaseSessionService(Builder builder) { + this.dataSource = builder.dataSource; + this.instantSource = builder.instantSource; + this.uuidProvider = builder.uuidProvider; + this.autoMigrate = builder.autoMigrate; + this.statementTimeoutSeconds = builder.statementTimeoutSeconds; + this.resolvedDialect = builder.dialect; + } + + /** Returns a builder without accessing the supplied data source. */ + public static Builder builder(DataSource dataSource) { + return new Builder(dataSource); + } + + /** + * Migrates only this module's schema when subscribed. Run once before starting concurrent + * workers. Does not run application migrations or take ownership of the data source. + * + *

The migration creates the five session tables and its own history table, {@code + * adk_schema_history}, in the schema the data source connects to. Repeating it on an up-to-date + * schema changes nothing. It refuses to touch tables of the same names that it did not create. + * + *

It needs Flyway on the classpath, together with Flyway's module for the database in use. The + * user of the data source needs the right to create tables. + * + * @return a cold {@code Completable} that fails with {@link SessionException} if the schema could + * not be verified or migrated + */ + public Completable migrate() { + return Completable.fromAction(this::migrateNow); + } + + /** Runs the migration now and remembers that this instance needs no automatic one. */ + private synchronized void migrateNow() { + try { + DatabaseSchemaMigrator.migrate(dataSource, resolveDialect()); + } catch (LinkageError missingMigrationLibrary) { + // The migrator is the only class of this module that touches Flyway, so an application + // that provisions the schema itself gets this far only if it calls migrate() after all. + throw new SessionException( + "Unable to load the database migration library; verify the consumer dependencies."); + } + initialized = true; + } + + /** + * Runs the opted-in migration before the first database operation of this instance. A failed + * attempt is not remembered, so the next operation tries again. + */ + synchronized void initialize() { + if (!initialized) { + if (autoMigrate) { + migrateNow(); + } + initialized = true; + } + } + + /** + * Returns the store, creating it on first use. This is where a lazily detected dialect costs its + * one borrowed connection. + */ + private synchronized DatabaseSessionStore store() { + initialize(); + if (store == null) { + store = new DatabaseSessionStore(dataSource, resolveDialect(), statementTimeoutSeconds); + } + return store; + } + + /** + * {@inheritDoc} + * + * @deprecated Use {@link #createSession(String, String, Map, String)}, which this delegates to. + */ + @Override + @Deprecated + public Single createSession( + String appName, + String userId, + @Nullable ConcurrentMap state, + @Nullable String sessionId) { + return createSession(appName, userId, (Map) state, sessionId); + } + + /** + * Creates a session and stores its initial state. + * + *

{@code app:} and {@code user:} keys of {@code state} are written to the shared state, where + * every other session of the app or user sees them. {@code temp:} keys are returned on the + * created session and not stored. A key whose value is {@code null} or {@link State#REMOVED} is + * not stored, and removes an {@code app:} or {@code user:} key that was shared before. + * + * @param appName at most 128 code points; compared exactly, including case and trailing spaces + * @param userId at most 128 code points, compared exactly + * @param state the initial state, or {@code null} for none; it is not modified + * @param sessionId the ID to use, at most 128 code points; {@code null} or blank for a generated + * one + * @return a cold {@code Single} of the created session, whose state includes what the app and + * user already shared + * @throws SessionException signaled through the returned {@code Single}: with {@link + * SessionException#SESSION_ALREADY_EXISTS} if the ID is taken for this app and user, with + * another message if an argument is invalid or the database fails + */ + @Override + public Single createSession( + String appName, + String userId, + @Nullable Map state, + @Nullable String sessionId) { + return Single.fromCallable( + () -> { + // As in InMemorySessionService, a blank ID asks for a generated one. An ID with any + // other content is stored exactly as given, including its surrounding spaces. + String id = sessionId == null || sessionId.trim().isEmpty() ? newIdentifier() : sessionId; + SessionKey key = key(appName, userId, id); + Instant now; + try { + now = instantSource.instant(); + } catch (RuntimeException failure) { + throw new SessionException("Unable to obtain the session creation time."); + } + // temp: values go straight from the argument to the returned session. They are kept + // by reference and never encoded, so they need not be something JSON can express. + Map temporary = new HashMap<>(); + try { + if (state != null) + state.forEach( + (name, value) -> { + if (name.startsWith(State.TEMP_PREFIX) + && value != null + && value != State.REMOVED) { + temporary.put(name, value); + } + }); + } catch (RuntimeException invalidState) { + throw new SessionException("Unable to snapshot initial session state."); + } + now = DatabaseTime.forStorage(now); + Session created = store().create(key, state == null ? Map.of() : state, now); + created.state().putAll(temporary); + return created; + }); + } + + /** + * Loads a session with its state and history from one consistent snapshot of the database. + * + *

The state merges the session's own keys with the shared {@code app:} and {@code user:} keys + * as they are now. The history is in the order the events were appended. With a {@code config}, + * the last {@code numRecentEvents} appended are taken first, and of those the events at or after + * {@code afterTimestamp} are returned; {@code numRecentEvents(0)} returns the session without + * history. + * + *

A session loaded with a filter that leaves out its last event cannot be appended to; see + * {@link #appendEvent(Session, Event)}. + * + * @return a cold {@code Maybe} of a session that shares nothing with other loaded copies; empty + * if there is no such session + * @throws SessionException signaled through the returned {@code Maybe} if an argument is invalid + * or the database fails + */ + @Override + public Maybe getSession( + String appName, String userId, String sessionId, Optional config) { + return Maybe.fromCallable( + () -> { + SessionKey key = key(appName, userId, sessionId); + if (config == null) { + throw new SessionException("Session configuration cannot be null."); + } + return store().get(key, config.orElseGet(() -> GetSessionConfig.builder().build())); + }); + } + + /** + * Lists the sessions of one user of one app, with their state and without their events. + * + * @return a cold {@code Single} of the sessions ordered by last update, oldest first, then by ID; + * an empty list if the user has none + * @throws SessionException signaled through the returned {@code Single} if an argument is invalid + * or the database fails + */ + @Override + public Single listSessions(String appName, String userId) { + return Single.fromCallable( + () -> { + validateKey(appName); + validateKey(userId); + return ListSessionsResponse.builder().sessions(store().list(appName, userId)).build(); + }); + } + + /** + * Deletes a session and its events. The shared {@code app:} and {@code user:} state is kept, + * since other sessions use it. + * + * @return a cold {@code Completable} + * @throws SessionNotFoundException signaled through the returned {@code Completable} if there is + * no such session + * @throws SessionException signaled the same way if an argument is invalid or the database fails + */ + @Override + public Completable deleteSession(String appName, String userId, String sessionId) { + return Completable.fromAction( + () -> { + SessionKey key = key(appName, userId, sessionId); + store().delete(key); + }); + } + + /** + * Lists every event of a session in the order they were appended. The response carries no page + * token: the whole history is returned at once. + * + * @return a cold {@code Single} of the events + * @throws SessionNotFoundException signaled through the returned {@code Single} if there is no + * such session + * @throws SessionException signaled the same way if an argument is invalid or the database fails + */ + @Override + public Single listEvents(String appName, String userId, String sessionId) { + return Single.fromCallable( + () -> { + SessionKey key = key(appName, userId, sessionId); + return ListEventsResponse.builder().events(store().listEvents(key)).build(); + }); + } + + /** + * Persists atomically, then updates the supplied session. The supplied history must contain the + * last stored event (or both histories must be empty); reload a stale or filtered snapshot. + * + *

In one transaction the event is added to the history, its state delta is applied to the + * session's own state and to the shared {@code app:} and {@code user:} state, and the session's + * update time becomes the event's timestamp. Only after the commit is {@code session} changed: + * the event is added to it and the delta applied to its state. If the call fails, {@code session} + * is exactly as it was. + * + *

{@link State#TEMP_PREFIX} keys of the event's delta are applied to the supplied session but + * never stored, so they are gone when the session is loaded again. + * + *

A partial event is returned as it is and nothing is stored. An event ID can be used once per + * session. Do not change the event or its delta while the call is running. + * + *

When a failure message says the write may have been committed, load the session again before + * deciding whether to repeat the call. When it says nothing was written, the same call can be + * repeated with the same session. + * + * @param session a session of this service, as created or loaded; it is locked for the call + * @param event the event to append; its ID is required, at most 128 code points + * @return a cold {@code Single} of {@code event} + * @throws SessionNotFoundException signaled through the returned {@code Single} if the session no + * longer exists + * @throws SessionException signaled the same way if the supplied history is not current, the + * event ID is already used, an argument is invalid, or the database fails + */ + @CanIgnoreReturnValue + @Override + public Single appendEvent(Session session, Event event) { + return Single.fromCallable( + () -> { + if (session == null || event == null) { + throw new SessionException("Session and event cannot be null."); + } + if (event.partial().orElse(false)) { + return event; + } + // One append at a time per Session object: the second of two concurrent calls has to + // see the event the first one added, or it would be rejected as stale. + synchronized (session) { + SessionKey key = key(session.appName(), session.userId(), session.id()); + validateKey(event.id()); + // The invocation_id column is NOT NULL in the Python v1 layout; an event without one + // is stored with an empty string there, and without the field in its JSON. + String invocationId = Objects.requireNonNullElse(event.invocationId(), ""); + if (!validText(invocationId, 256)) { + throw new SessionException( + "Invocation IDs must be valid Unicode without NUL and at most 256 code points."); + } + Instant timestamp = DatabaseTime.forStorage(Instant.ofEpochMilli(event.timestamp())); + // Copy the delta once. What is stored and what is then applied to the session must be + // the same entries, even if the caller's map changes in the meantime. + Map delta = snapshotDelta(event); + store().append(key, lastEventId(session), event, delta, timestamp, invocationId); + // Reached only after a confirmed commit. + // temp: values are never stored, but as in InMemorySessionService they reach the + // supplied session, where later steps of the same invocation read them. + delta.forEach( + (name, value) -> { + if (value == null || value == State.REMOVED) { + session.state().remove(name); + } else { + session.state().put(name, value); + } + }); + session.lastUpdateTime(timestamp); + session.addEvent(event); + } + return event; + }); + } + + /** + * Returns the ID of the last event the caller's session holds, or {@code null} if it holds none. + * The store compares it with the last stored event to tell a current copy from a stale one; no + * extra field on {@link Session} is needed for that. + */ + @SuppressWarnings("deprecation") // Use Session's list monitor without copying the whole history. + private static @Nullable String lastEventId(Session session) { + List events = session.events(); + synchronized (events) { + if (events.isEmpty()) { + return null; + } + Event tail = events.get(events.size() - 1); + if (tail == null || tail.id() == null) { + throw new SessionException("Session history is invalid; reload before appending."); + } + return tail.id(); + } + } + + /** Copies the entries of the event's state delta; empty if the event has none. */ + private static Map snapshotDelta(Event event) { + try { + Map delta = event.actions() == null ? null : event.actions().stateDelta(); + return delta == null ? Map.of() : new HashMap<>(delta); + } catch (RuntimeException invalidDelta) { + throw new SessionException("Unable to snapshot session event state."); + } + } + + /** Asks the configured provider for a session ID and holds it to the rules for any other ID. */ + private String newIdentifier() { + try { + String value = uuidProvider.newUuid(); + validateKey(value); + return value; + } catch (RuntimeException failure) { + throw new SessionException("Unable to obtain a valid session identifier."); + } + } + + /** Validates the three parts of a session's identity and combines them. */ + private static SessionKey key(String appName, String userId, String id) { + validateKey(appName); + validateKey(userId); + validateKey(id); + return new SessionKey(appName, userId, id); + } + + /** + * Requires an identifier that fits a key column, {@code VARCHAR(128)} on every engine. The + * message names the rule and never the value, which may be private. + */ + private static void validateKey(@Nullable String value) { + if (!validText(value, 128)) { + throw new SessionException( + "Identifiers must be valid Unicode without NUL and at most 128 code points."); + } + } + + /** + * Whether every supported engine stores the text and gives the same text back. Checked here so + * that all four behave alike, instead of each rejecting, truncating or altering in its own way. + * + *

    + *
  • Length is counted in code points, as the engines count {@code VARCHAR} length, not in + * Java's UTF-16 units. + *
  • NUL is refused: PostgreSQL cannot store it in text. + *
  • A surrogate without its pair is refused: it is not valid Unicode and cannot be encoded as + * UTF-8. + *
+ */ + private static boolean validText(@Nullable String value, int maxCodePoints) { + if (value == null + || value.codePointCount(0, value.length()) > maxCodePoints + || value.indexOf('\0') >= 0) { + return false; + } + // A high surrogate must be followed by a low one, and a low one must follow a high one. + for (int i = 0; i < value.length(); i++) { + char current = value.charAt(i); + if (Character.isHighSurrogate(current)) { + if (++i >= value.length() || !Character.isLowSurrogate(value.charAt(i))) { + return false; + } + } else if (Character.isLowSurrogate(current)) { + return false; + } + } + return true; + } + + /** + * Configures immutable service settings; a builder itself is not thread-safe. + * + *
{@code
+   * DatabaseSessionService sessions =
+   *     DatabaseSessionService.builder(dataSource)
+   *         .dialect(DatabaseSessionService.Dialect.POSTGRESQL)
+   *         .statementTimeout(Duration.ofSeconds(30))
+   *         .build();
+   * }
+ * + *

Each setter rejects {@code null} with a {@link SessionException}. A builder can be reused; a + * service keeps the settings it was built with. + */ + public static final class Builder { + private final DataSource dataSource; + private InstantSource instantSource = InstantSource.system(); + private UuidProvider uuidProvider = UuidProvider.SYSTEM; + private boolean autoMigrate; + private int statementTimeoutSeconds = DEFAULT_STATEMENT_TIMEOUT_SECONDS; + private @Nullable Dialect dialect; + + private Builder(DataSource dataSource) { + this.dataSource = requireComponent(dataSource, "dataSource"); + } + + /** + * Selects a dialect explicitly, without inspecting JDBC URLs or metadata. + * + *

Without this, the dialect is detected on first use from the database product name that the + * driver reports, which costs one borrowed connection. Set it when the product name is not one + * of the four that are recognized, for example behind a proxy or a compatible engine. + */ + @CanIgnoreReturnValue + public Builder dialect(Dialect dialect) { + this.dialect = requireComponent(dialect, "dialect"); + return this; + } + + /** + * Selects the source used when creating a session; this does not read the clock. + * + *

It supplies the creation time of a session, which is also its first update time. Later + * update times come from the timestamps of the appended events, and the times of the shared + * state come from the database's own clock. + */ + @CanIgnoreReturnValue + public Builder instantSource(InstantSource instantSource) { + this.instantSource = requireComponent(instantSource, "instantSource"); + return this; + } + + /** + * Selects a thread-safe provider of distinct identifiers without invoking it. + * + *

It is asked for a session ID whenever a session is created without one. An ID it returns + * has to meet the rules for any session ID, at most 128 code points. + */ + @CanIgnoreReturnValue + public Builder uuidProvider(UuidProvider uuidProvider) { + this.uuidProvider = requireComponent(uuidProvider, "uuidProvider"); + return this; + } + + /** + * Enables schema migration before the first database operation; disabled by default. + * + *

The builder and constructor remain side-effect free even when this is enabled. Arrange for + * one migration owner before starting workers. This flag does not coordinate bootstrap across + * services or processes, particularly with SQLite or non-transactional DDL. + * + *

Leave it off when the schema is created by {@link DatabaseSessionService#migrate()} at + * deployment time, or by running the module's SQL yourself. + */ + @CanIgnoreReturnValue + public Builder autoMigrate(boolean autoMigrate) { + this.autoMigrate = autoMigrate; + return this; + } + + /** + * Limits each SQL statement of an operation, and how long SQLite waits for a competing writer; + * 15 seconds by default. + * + *

This is not an end-to-end deadline: one operation runs several statements, and connection + * and network timeouts stay with the application's data source. + * + * @throws SessionException unless the timeout is a whole number of seconds from 1 to 2147483 + */ + @CanIgnoreReturnValue + public Builder statementTimeout(Duration statementTimeout) { + requireComponent(statementTimeout, "statementTimeout"); + long seconds = statementTimeout.getSeconds(); + if (statementTimeout.getNano() != 0 + || seconds < 1 + || seconds > MAX_STATEMENT_TIMEOUT_SECONDS) { + throw new SessionException( + "statementTimeout must be a whole number of seconds from 1 to " + + MAX_STATEMENT_TIMEOUT_SECONDS + + "."); + } + this.statementTimeoutSeconds = (int) seconds; + return this; + } + + /** Takes a snapshot of these settings without accessing the database or providers. */ + public DatabaseSessionService build() { + return new DatabaseSessionService(this); + } + } + + // The settings this instance was built with, readable by the module's own tests. + + DataSource dataSource() { + return dataSource; + } + + InstantSource instantSource() { + return instantSource; + } + + UuidProvider uuidProvider() { + return uuidProvider; + } + + boolean autoMigrate() { + return autoMigrate; + } + + Duration statementTimeout() { + return Duration.ofSeconds(statementTimeoutSeconds); + } + + /** Resolves once per instance. A failed attempt is not cached, allowing a later retry. */ + synchronized Dialect resolveDialect() { + if (resolvedDialect == null) { + resolvedDialect = DatabaseDialect.detect(dataSource); + } + return resolvedDialect; + } + + /** Rejects a missing setting with the one exception type this service uses. */ + private static T requireComponent(@Nullable T value, String name) { + if (value == null) { + throw new SessionException(name + " cannot be null."); + } + return value; + } +} diff --git a/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSessionStore.java b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSessionStore.java new file mode 100644 index 000000000..44a1d1ce8 --- /dev/null +++ b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseSessionStore.java @@ -0,0 +1,944 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import com.google.adk.events.Event; +import com.google.adk.sessions.DatabaseDiagnostics.Phase; +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import javax.sql.DataSource; +import org.jspecify.annotations.Nullable; + +/** + * The SQL of the session service: one short transaction per operation, and the mapping between rows + * and {@link Session}s. No pool, executor, or ORM. + * + *

Tables. A session's state is kept in three JSON documents, as in the Python v1 schema: + * {@code app_states} (one row per app, the {@code app:} keys without their prefix), {@code + * user_states} (one row per app and user, the {@code user:} keys) and {@code sessions.state} (the + * unprefixed keys). {@code events} holds the history, one row per event, with the whole event as + * JSON in {@code event_data}. + * + *

Transactions. Every operation borrows one connection, runs inside {@link #transaction}, + * and returns the connection in the state it was borrowed in. Reads see one snapshot; writes lock + * exactly the rows they change. + * + *

Lock order. Writers take row locks in the order app, user, session, and skip the scopes + * they do not change. Keeping to one order is what keeps two writers from deadlocking, so a new + * statement that locks a row has to respect it. SQLite has no row locks; there a write transaction + * is the only writer of the file ({@code BEGIN IMMEDIATE}). + * + *

Errors. Callers get a {@link SessionException} whose message this class wrote. A + * rejection that the caller can act on (duplicate, missing, stale) travels as {@link Rejected}; + * anything from the driver is reported by {@link DatabaseDiagnostics} and never passed on. + * + *

Instances are immutable and safe to share between threads. + */ +final class DatabaseSessionStore { + /** Primary key of {@code sessions}; bound by {@link #bindScope}. */ + private static final String SCOPE = "app_name=? AND user_id=? AND id=?"; + + /** The same key as it appears in {@code events}, where the session's ID is a foreign key. */ + private static final String EVENT_SCOPE = "app_name=? AND user_id=? AND session_id=?"; + + /** + * One session together with the two shared documents it is merged with. The joins are outer so + * that a missing shared row shows up as a NULL document, which fails to decode, instead of making + * the session itself look absent. A {@code WHERE} clause on {@code s} follows. + */ + private static final String VIEW = + "SELECT s.id,s.state AS local_state,s.update_time," + + "a.state AS app_state,u.state AS user_state FROM sessions s " + + "LEFT JOIN app_states a ON a.app_name=s.app_name " + + "LEFT JOIN user_states u ON u.app_name=s.app_name AND u.user_id=s.user_id "; + + /** Message of a failure that left nothing behind and can be answered by calling again. */ + static final String CONFLICT = + "The database rolled the operation back because of a concurrent transaction;" + + " nothing was written, so it can be retried."; + + /** + * Both shared documents of one app and user in a single row, for a listing: joining them to every + * session, as {@link #VIEW} does for one, would send the same two documents once per session. A + * scalar subquery yields NULL for a missing row. + */ + private static final String SHARED = + "SELECT (SELECT state FROM app_states WHERE app_name=?)," + + "(SELECT state FROM user_states WHERE app_name=? AND user_id=?)"; + + private final DataSource source; + private final Dialect dialect; + private final int timeoutSeconds; + private final SessionDataCodec codec = new SessionDataCodec(); + + /** + * @param source the application's data source; a connection is borrowed per operation and never + * kept + * @param dialect the engine behind {@code source} + * @param timeoutSeconds limit for each statement, and for SQLite's wait on a competing writer + */ + DatabaseSessionStore(DataSource source, Dialect dialect, int timeoutSeconds) { + this.source = source; + this.dialect = dialect; + this.timeoutSeconds = timeoutSeconds; + } + + /** + * Inserts a session and writes the {@code app:} and {@code user:} keys of its initial state to + * the shared documents, creating their rows if this is the first session of the app or user. + * + * @param state the initial state with prefixed keys; {@code temp:} keys are ignored + * @param now creation time, already at storage precision + * @return the session as stored, with the shared state merged in + * @throws SessionException with {@link SessionException#SESSION_ALREADY_EXISTS} if the key is + * taken, in which case the shared documents are left untouched as well + */ + Session create(SessionKey key, Map state, Instant now) { + SessionDataCodec.ScopedState split = codec.splitState(state); + return transaction( + "create", + true, + connection -> { + // A fast rejection is safe here; the insert still enforces uniqueness under races. + if (sessionExists(connection, key)) { + throw new Rejected(Reason.DUPLICATE); + } + // App before user, the lock order of this class. Both rows are created here, once, so + // that an append can lock a row that is certain to exist instead of racing to insert it. + ensureShared(connection, key, false); + updateShared(connection, key, false, split.app()); + ensureShared(connection, key, true); + updateShared(connection, key, true, split.user()); + try (PreparedStatement insert = + prepare( + connection, + "INSERT INTO sessions (app_name,user_id,id,state,create_time,update_time) VALUES (?,?,?,?,?,?)")) { + bindScope(insert, key); + DatabaseDialect.bindJson(insert, 4, codec.writeState(split.local()), dialect); + DatabaseTime.bind(insert, 5, now, dialect); + DatabaseTime.bind(insert, 6, now, dialect); + insert.executeUpdate(); + } catch (SQLException collision) { + // Two creators of the same key can both pass the check above. The loser fails on the + // primary key: unique_violation on PostgreSQL, ER_DUP_ENTRY on MySQL and MariaDB. + // SQLite cannot get here, since its write transaction excludes the other creator. + if ((dialect == Dialect.POSTGRESQL && "23505".equals(collision.getSQLState())) + || ((dialect == Dialect.MYSQL || dialect == Dialect.MARIADB) + && collision.getErrorCode() == 1062)) { + throw new Rejected(Reason.DUPLICATE); + } + throw collision; + } + // Read the row back rather than echo the arguments: the caller gets what the database + // holds, including shared keys that other sessions wrote earlier. + return load(connection, key); + }); + } + + /** + * Reads a session with its merged state and the part of its history that {@code config} asks for, + * all from one snapshot. + * + * @return the session, or {@code null} if there is none under this key + */ + @Nullable Session get(SessionKey key, GetSessionConfig config) { + return transaction( + "get", + false, + connection -> { + Session session = load(connection, key); + if (session != null) { + session.addEvents(events(connection, key, config)); + } + return session; + }); + } + + /** + * Lists the sessions of one user with their merged state and without their history, the least + * recently updated first and by ID within the same update time. + * + *

Two statements whatever the number of sessions: the session rows, then the shared state + * once. + */ + List list(String app, String user) { + return transaction( + "list", + false, + connection -> { + // The rows are held until the shared state is known; a session cannot be built without. + record Row(String id, String state, Instant updateTime) {} + List rows = new ArrayList<>(); + try (PreparedStatement query = + prepare( + connection, + "SELECT id,state,update_time FROM sessions WHERE app_name=? AND user_id=?" + + " ORDER BY update_time,id")) { + query.setString(1, app); + query.setString(2, user); + try (ResultSet found = query.executeQuery()) { + while (found.next()) { + rows.add( + new Row( + found.getString(1), + found.getString(2), + DatabaseTime.read(found, "update_time", dialect))); + } + } + } + List sessions = new ArrayList<>(rows.size()); + if (rows.isEmpty()) { + // A user without sessions may have no shared rows at all; do not go looking for them. + return sessions; + } + // The read transaction gives both statements the same snapshot. + String appState; + String userState; + try (PreparedStatement query = prepare(connection, SHARED)) { + query.setString(1, app); + query.setString(2, app); + query.setString(3, user); + try (ResultSet shared = query.executeQuery()) { + if (!shared.next()) { + throw new SQLException(); + } + appState = shared.getString(1); + userState = shared.getString(2); + } + } + for (Row row : rows) { + sessions.add( + session(row.id(), app, user, row.updateTime(), appState, userState, row.state())); + } + return sessions; + }); + } + + /** + * Deletes a session and, through the foreign key's {@code ON DELETE CASCADE}, its events. The + * shared app and user documents stay: other sessions read them. + * + * @throws SessionNotFoundException if there is no session under this key + */ + void delete(SessionKey key) { + transaction( + "delete", + true, + connection -> { + try (PreparedStatement delete = + prepare(connection, "DELETE FROM sessions WHERE " + SCOPE)) { + bindScope(delete, key); + if (delete.executeUpdate() == 0) { + throw new Rejected(Reason.MISSING); + } + } + return null; + }); + } + + /** + * Reads the whole history of a session in append order. + * + * @throws SessionNotFoundException if there is no session under this key, which an empty history + * alone could not tell apart from a session without events + */ + List listEvents(SessionKey key) { + return transaction( + "listEvents", + false, + connection -> { + if (!sessionExists(connection, key)) { + throw new Rejected(Reason.MISSING); + } + return events(connection, key, GetSessionConfig.builder().build()); + }); + } + + /** + * Appends one event and applies its state delta, atomically: the event row, the session's {@code + * update_time} and state, and the shared documents its delta touches are committed together or + * not at all. + * + *

The writer has to have seen the current end of the history. Its last known event is compared + * with the stored one while the session row is locked, so of two writers that loaded the same + * history the second is rejected rather than appended after an event it never saw. + * + * @param expectedTailId the ID of the last event in the caller's copy of the session, or {@code + * null} if that copy has no events + * @param liveDelta the event's state delta as it was when the call started, with prefixed keys + * @param timestamp the event's time at storage precision; it becomes the session's {@code + * update_time} + * @param invocationId the value of the {@code invocation_id} column, empty if the event has none + * @throws SessionNotFoundException if the session does not exist + * @throws SessionException if the caller's history is stale, or the session already holds an + * event with this ID + */ + void append( + SessionKey key, + @Nullable String expectedTailId, + Event event, + Map liveDelta, + Instant timestamp, + String invocationId) { + // Encode before borrowing a connection: an event that cannot be written should not cost a + // transaction, and no lock is held while JSON is produced. + String json = codec.writeEvent(event); + SessionDataCodec.ScopedState delta = codec.splitState(liveDelta); + transaction( + "append", + true, + connection -> { + // Global lock order is app -> user -> session, matching create. Unchanged scopes are not + // locked, ensured, or updated. In particular a local event never locks the app-wide row. + updateShared(connection, key, false, delta.app()); + updateShared(connection, key, true, delta.user()); + // Most events carry no local delta. Lock the session either way, but leave its state + // document unread and unwritten then: rewriting it would cost a full decode, encode and + // row/TOAST write per event, growing with the size of the session state. + boolean localChanged = !delta.local().isEmpty(); + Map local = Map.of(); + try (PreparedStatement query = + prepare( + connection, + "SELECT " + + (localChanged ? "state" : "1") + + " FROM sessions WHERE " + + SCOPE + + DatabaseDialect.lock(dialect))) { + bindScope(query, key); + try (ResultSet row = query.executeQuery()) { + if (!row.next()) { + throw new Rejected(Reason.MISSING); + } + if (localChanged) { + local = codec.readState(row.getString(1)); + } + } + } + // The locked session (or SQLite's write transaction) serializes tail reads and inserts. + long next = nextSequence(connection, key, expectedTailId); + try (PreparedStatement update = + prepare( + connection, + "UPDATE sessions SET " + + (localChanged ? "state=?," : "") + + "update_time=? WHERE " + + SCOPE)) { + int index = 1; + if (localChanged) { + DatabaseDialect.bindJson( + update, + index++, + codec.writeState(codec.applyState(local, delta.local())), + dialect); + } + DatabaseTime.bind(update, index++, timestamp, dialect); + bindScope(update, index, key); + // The session exists and is locked; a no-op update can report zero changed rows. + update.executeUpdate(); + } + // An event ID may be used once per session (the primary key of events). PostgreSQL and + // SQLite are asked to skip a duplicate, which shows as zero inserted rows; on PostgreSQL + // a failed statement would otherwise abort the whole transaction. MySQL and MariaDB have + // no ON CONFLICT clause and report ER_DUP_ENTRY, which is caught below. + boolean mysqlFamily = dialect == Dialect.MYSQL || dialect == Dialect.MARIADB; + String conflict = + mysqlFamily ? "" : " ON CONFLICT (id,app_name,user_id,session_id) DO NOTHING"; + try (PreparedStatement insert = + prepare( + connection, + "INSERT INTO events (app_name,user_id,session_id,event_sequence,id,invocation_id,timestamp,event_data) VALUES (?,?,?,?,?,?,?,?)" + + conflict)) { + bindScope(insert, key); + insert.setLong(4, next); + insert.setString(5, event.id()); + insert.setString(6, invocationId); + DatabaseTime.bind(insert, 7, timestamp, dialect); + DatabaseDialect.bindJson(insert, 8, json, dialect); + if (insert.executeUpdate() == 0) { + throw new Rejected(Reason.DUPLICATE_EVENT); + } + } catch (SQLException collision) { + if (mysqlFamily && collision.getErrorCode() == 1062) { + throw new Rejected(Reason.DUPLICATE_EVENT); + } + throw collision; + } + return null; + }); + } + + /** Reads one session with its merged state and no events; {@code null} if it does not exist. */ + private @Nullable Session load(Connection connection, SessionKey key) throws SQLException { + try (PreparedStatement query = + prepare(connection, VIEW + "WHERE s.app_name=? AND s.user_id=? AND s.id=?")) { + bindScope(query, key); + try (ResultSet rows = query.executeQuery()) { + return rows.next() + ? session( + rows.getString("id"), + key.appName(), + key.userId(), + DatabaseTime.read(rows, "update_time", dialect), + rows.getString("app_state"), + rows.getString("user_state"), + rows.getString("local_state")) + : null; + } + } + } + + /** + * Builds a session from its three stored documents. They are decoded for this session alone, so + * that no two sessions share a value, and a NULL document (a missing shared row) fails to decode + * instead of being read as empty state. + */ + private Session session( + String id, + String app, + String user, + Instant updateTime, + @Nullable String appState, + @Nullable String userState, + @Nullable String localState) { + return Session.builder(id) + .appName(app) + .userId(user) + .lastUpdateTime(updateTime) + .state( + codec.mergeState( + codec.readState(appState), codec.readState(userState), codec.readState(localState))) + .build(); + } + + /** + * Reads a session's events in append order, filtered the way {@code InMemorySessionService} + * filters them: first keep the last {@code numRecentEvents} appended, then drop those older than + * {@code afterTimestamp}. An event at exactly that time is kept. + * + *

The statement has one of two shapes, each with or without the time predicate in brackets: + * + *

{@code
+   * SELECT event_data FROM events WHERE scope [AND timestamp>=?] ORDER BY event_sequence
+   *
+   * SELECT event_data FROM (
+   *     SELECT ... FROM events WHERE scope ORDER BY event_sequence DESC LIMIT ?) recent
+   *   [WHERE timestamp>=?] ORDER BY event_sequence
+   * }
+ * + *

Order and limit use {@code event_sequence}, never {@code timestamp}: event times have + * millisecond resolution, tie often, and may run backwards. + */ + private List events(Connection connection, SessionKey key, GetSessionConfig config) + throws SQLException { + List events = new ArrayList<>(); + if (config.numRecentEvents().orElse(-1) == 0) { + // Asked for the session without history; there is nothing to query. + return events; + } + Instant lowerBound = null; + if (config.afterTimestamp().isPresent()) { + Instant cursor = config.afterTimestamp().orElseThrow(); + // A cursor outside the range a column can hold cannot be bound. Past the range no stored + // event can match; before it every stored event does, so no predicate is needed. + if (cursor.isAfter(DatabaseTime.MAX)) { + return events; + } + if (cursor.isAfter(DatabaseTime.MIN)) { + // Columns hold microseconds. Rounding the cursor up keeps ">=" exact for a cursor with + // nanoseconds: an event in the same microsecond but before the cursor is left out. + lowerBound = DatabaseTime.ceilToMicros(cursor); + } + } + boolean limited = config.numRecentEvents().isPresent(); + // The subquery limits append positions BEFORE timestamp filtering; pushing the predicate into + // that subquery changes the Java contract for out-of-order timestamps. + String sql = + limited + ? "SELECT event_data FROM (SELECT event_sequence,timestamp,event_data FROM events WHERE " + + EVENT_SCOPE + + " ORDER BY event_sequence DESC LIMIT ?) recent" + : "SELECT event_data FROM events WHERE " + EVENT_SCOPE; + if (lowerBound != null) { + sql += (limited ? " WHERE " : " AND ") + "timestamp>=?"; + } + sql += " ORDER BY event_sequence"; + try (PreparedStatement query = prepare(connection, sql)) { + // Parameters in the order they appear in either shape: the scope, the limit, the cursor. + bindScope(query, key); + int index = 4; + if (limited) { + query.setInt(index++, config.numRecentEvents().orElseThrow()); + } + if (lowerBound != null) { + DatabaseTime.bind(query, index, lowerBound, dialect); + } + try (ResultSet rows = query.executeQuery()) { + while (rows.next()) { + events.add(codec.readEvent(rows.getString(1))); + } + } + } + return events; + } + + /** Whether a session row exists, without locking it. */ + private boolean sessionExists(Connection connection, SessionKey key) throws SQLException { + try (PreparedStatement query = prepare(connection, "SELECT 1 FROM sessions WHERE " + SCOPE)) { + bindScope(query, key); + try (ResultSet rows = query.executeQuery()) { + return rows.next(); + } + } + } + + /** + * Checks that the caller's last event is the stored last event, and returns the position for the + * next one. Positions start at 1 and are dense within a session. + * + *

Call it only while the session row is locked (on SQLite, inside the write transaction): that + * is what keeps a second writer from reading the same tail and taking the same position. The + * unique constraint on the position is a second line of defense, not the mechanism. + * + * @param expectedTailId the caller's last event ID, or {@code null} for an empty history + */ + private long nextSequence(Connection connection, SessionKey key, @Nullable String expectedTailId) + throws SQLException { + try (PreparedStatement query = + prepare( + connection, + "SELECT id,event_sequence FROM events WHERE " + + EVENT_SCOPE + + " ORDER BY event_sequence DESC LIMIT 1")) { + bindScope(query, key); + try (ResultSet rows = query.executeQuery()) { + boolean hasTail = rows.next(); + String actual = hasTail ? rows.getString("id") : null; + // Both empty, or the same last event. Anything else means the caller's copy is behind + // the stored history, or was loaded with a filter that cut its end off. + if (!Objects.equals(actual, expectedTailId)) { + throw new Rejected(Reason.STALE); + } + long sequence = hasTail ? rows.getLong("event_sequence") : 0; + if (sequence == Long.MAX_VALUE) { + throw new Rejected(Reason.SEQUENCE_EXHAUSTED); + } + return sequence + 1; + } + } + } + + /** + * Makes sure the app-wide or per-user state row exists, inserting an empty document if it does + * not. Two sessions created at the same moment may both try; the insert is written so that the + * second one waits for the first and then does nothing. + * + * @param user {@code true} for the user's row in {@code user_states}, {@code false} for the app's + * row in {@code app_states} + */ + private void ensureShared(Connection connection, SessionKey key, boolean user) + throws SQLException { + String table = user ? "user_states" : "app_states"; + if (dialect == Dialect.MYSQL || dialect == Dialect.MARIADB) { + // InnoDB takes an exclusive lock even for a no-op duplicate-key update. Existing shared + // rows are never deleted by the service, so skip the upsert on a successful consistent read. + String scope = "app_name=?" + (user ? " AND user_id=?" : ""); + try (PreparedStatement query = + prepare(connection, "SELECT 1 FROM " + table + " WHERE " + scope)) { + bindShared(query, 1, key, user); + try (ResultSet rows = query.executeQuery()) { + if (rows.next()) { + return; + } + } + } + } + try (PreparedStatement insert = + prepare(connection, DatabaseDialect.insertShared(table, user, dialect))) { + insert.setString(1, key.appName()); + int stateIndex = 2; + if (user) { + insert.setString(2, key.userId()); + stateIndex++; + } + DatabaseDialect.bindJson(insert, stateIndex, "{}", dialect); + insert.executeUpdate(); + } + } + + /** + * Applies a delta to the app-wide or per-user document: lock the row, read the document, change + * the keys of the delta, write the document back. The lock is what keeps two sessions that change + * different keys of the same document from overwriting each other. + * + *

An empty delta does nothing at all. That is the common case, and the reason an ordinary + * event never waits on the one row that every session of an app shares. + * + * @param user {@code true} for {@code user_states}, {@code false} for {@code app_states} + * @param delta keys without their scope prefix; a {@link State#REMOVED} value deletes the key + */ + private void updateShared( + Connection connection, SessionKey key, boolean user, Map delta) + throws SQLException { + if (delta.isEmpty()) { + return; + } + String table = user ? "user_states" : "app_states"; + String scope = "app_name=?" + (user ? " AND user_id=?" : ""); + Map current; + try (PreparedStatement query = + prepare( + connection, + "SELECT state FROM " + table + " WHERE " + scope + DatabaseDialect.lock(dialect))) { + bindShared(query, 1, key, user); + try (ResultSet rows = query.executeQuery()) { + if (!rows.next()) { + // createSession made this row and nothing in the service deletes it, so its absence + // means the tables were changed from outside. Fail rather than quietly recreate it. + throw new SQLException(); + } + current = codec.readState(rows.getString(1)); + } + } + // update_time is the database's own UTC clock, not the event's time: it records when the + // shared document last changed, whichever session and whichever application host wrote it. + try (PreparedStatement update = + prepare( + connection, + "UPDATE " + + table + + " SET state=?,update_time=" + + DatabaseDialect.utcNow(dialect) + + " WHERE " + + scope)) { + DatabaseDialect.bindJson( + update, 1, codec.writeState(codec.applyState(current, delta)), dialect); + bindShared(update, 2, key, user); + update.executeUpdate(); + } + } + + /** + * Binds the key of a shared row from {@code index} on: the app, then the user if there is one. + */ + private void bindShared(PreparedStatement statement, int index, SessionKey key, boolean user) + throws SQLException { + statement.setString(index, key.appName()); + if (user) { + statement.setString(index + 1, key.userId()); + } + } + + /** Binds the three parts of a session key as the first three parameters. */ + private void bindScope(PreparedStatement statement, SessionKey key) throws SQLException { + bindScope(statement, 1, key); + } + + /** Binds app, user and session ID, in the order of {@link #SCOPE}, from {@code index} on. */ + private void bindScope(PreparedStatement statement, int index, SessionKey key) + throws SQLException { + statement.setString(index, key.appName()); + statement.setString(index + 1, key.userId()); + statement.setString(index + 2, key.id()); + } + + /** + * Prepares a statement with the configured timeout. Every statement of this class goes through + * here; SQL text is built only from constants of this module, and all values are bound. + */ + private PreparedStatement prepare(Connection connection, String sql) throws SQLException { + PreparedStatement statement = connection.prepareStatement(sql); + try { + statement.setQueryTimeout(timeoutSeconds); + return statement; + } catch (SQLException | RuntimeException failure) { + // The caller's try-with-resources never received the statement, so close it here. + statement.close(); + throw failure; + } + } + + /** + * Runs {@code work} in one transaction on a borrowed connection and maps every outcome to what a + * caller may see. + * + *

In order: borrow; require a connection that is not inside someone else's transaction; set + * the isolation level and begin; check that the schema is the one this version writes; run the + * work; commit; put the connection's settings back; close. + * + *

    + *
  • Isolation. A read uses REPEATABLE READ so that its statements see one snapshot. A + * write uses READ COMMITTED and explicit row locks. With a snapshot taken at its start, a + * writer that had to wait for a row lock would afterwards still read the history as it was + * before the wait (MySQL, MariaDB) or be refused outright (PostgreSQL). SQLite gets its + * consistency from being a single writer. + *
  • SQLite. The transaction is opened with an explicit {@code BEGIN}, immediate for a + * write so that the file's write lock is taken before anything is read. Foreign keys are + * switched on, because the cascade from a session to its events depends on a per-connection + * setting that is off by default. + *
  • Schema check. The metadata row is read inside the transaction, every time, so that + * a process still running after the schema has been migrated to a newer version stops + * instead of writing the old format into it. + *
  • Commit. Whether the commit was not attempted, sent, or acknowledged decides what + * the caller is told: nothing written, possibly written, or written. + *
  • Cleanup. Auto-commit, isolation and the SQLite settings are restored to what they + * were at borrow time, so the pool gets back the connection it handed out. If that fails + * the connection is aborted rather than returned in an unknown state. + *
+ * + * @param operation name for diagnostics, such as {@code "append"} + * @param write whether the work changes data; decides the isolation level and the lock taken on + * SQLite, and whether a failed commit is ambiguous + * @throws SessionNotFoundException if the work rejected a missing session + * @throws SessionException for every other failure, with a message written by this class + */ + private T transaction(String operation, boolean write, SqlWork work) { + Phase phase = Phase.CONNECTION; + CommitOutcome commitOutcome = CommitOutcome.NOT_ATTEMPTED; + // phase and commitOutcome are read by the catch blocks at the end: they say how far the + // transaction got when it failed, which is what the message to the caller depends on. + try (Connection connection = source.getConnection()) { + phase = Phase.SETUP; + DatabaseDiagnostics.requireConnection(connection, write); + int isolation = connection.getTransactionIsolation(); + int required = + dialect == Dialect.SQLITE + ? Connection.TRANSACTION_SERIALIZABLE + : write + ? Connection.TRANSACTION_READ_COMMITTED + : Connection.TRANSACTION_REPEATABLE_READ; + // A server connection already at the required level needs neither the change nor the + // restore, each a round trip of its own. SQLite's level is driver-side state that is cheap + // to assert, so it is always set. + boolean changeIsolation = dialect == Dialect.SQLITE || isolation != required; + // The SQLite settings as borrowed, to restore afterwards; -1 until they have been read. + int foreignKeys = -1; + int busyTimeout = -1; + boolean begun = false; + @Nullable Throwable operationFailure = null; + try { + if (changeIsolation) { + connection.setTransactionIsolation(required); + } + if (dialect == Dialect.SQLITE) { + foreignKeys = pragma(connection, "foreign_keys"); + busyTimeout = pragma(connection, "busy_timeout"); + control(connection, "PRAGMA foreign_keys=ON"); + control(connection, "PRAGMA busy_timeout=" + timeoutSeconds * 1000); + // SQLite accepts this PRAGMA silently when it cannot honor it, for instance inside an + // open transaction or in a build without foreign keys. Deleting a session would then + // leave its events behind, so read the setting back. + if (pragma(connection, "foreign_keys") != 1) { + throw new SQLException(); + } + // Do not combine manual BEGIN with setAutoCommit(false): Xerial would issue nested BEGIN. + control(connection, write ? "BEGIN IMMEDIATE" : "BEGIN"); + } else { + connection.setAutoCommit(false); + } + begun = true; + phase = Phase.SCHEMA; + try (PreparedStatement query = prepare(connection, DatabaseDialect.metadataQuery(dialect)); + ResultSet metadata = query.executeQuery()) { + DatabaseSchemaMigrator.requireMetadata(metadata); + } + phase = Phase.STATEMENT; + T result = work.run(connection); + phase = Phase.COMMIT; + // UNCONFIRMED is set before the commit is sent and CONFIRMED only once it returns. A + // failure in between leaves UNCONFIRMED: the server may have committed and the reply + // been lost, so the caller must not be told that nothing was written. + if (dialect == Dialect.SQLITE) { + try (Statement commit = connection.createStatement()) { + commitOutcome = CommitOutcome.UNCONFIRMED; + commit.execute("COMMIT"); + // Closing the statement is cleanup, not part of the commit acknowledgement. + commitOutcome = CommitOutcome.CONFIRMED; + phase = Phase.CLEANUP; + } + } else { + commitOutcome = CommitOutcome.UNCONFIRMED; + connection.commit(); + commitOutcome = CommitOutcome.CONFIRMED; + phase = Phase.CLEANUP; + } + return result; + } catch (SQLException | RuntimeException failure) { + operationFailure = failure; + throw failure; + } finally { + // Runs on success and on failure alike: undo what was not committed, then give the + // connection back the settings it was borrowed with. + try { + if (begun && commitOutcome != CommitOutcome.CONFIRMED) { + if (dialect == Dialect.SQLITE) { + control(connection, "ROLLBACK"); + } else { + connection.rollback(); + } + } + if (dialect == Dialect.SQLITE) { + if (foreignKeys >= 0) { + control(connection, "PRAGMA foreign_keys=" + foreignKeys); + } + if (busyTimeout >= 0) { + control(connection, "PRAGMA busy_timeout=" + busyTimeout); + } + } else { + connection.setAutoCommit(true); + } + if (changeIsolation) { + connection.setTransactionIsolation(isolation); + } + } catch (SQLException | RuntimeException cleanupFailure) { + // Never enable auto-commit after a failed rollback: that could commit a partial write. + try { + connection.abort(Runnable::run); + } catch (SQLException | RuntimeException ignored) { + // Closing the borrowed connection is still attempted by try-with-resources. + } + // With no failure so far, or only a rejection the caller could have acted on, the + // cleanup failure is the one to report: the connection is broken and the caller should + // know. Otherwise the earlier failure stays the reported one and this is only logged. + if (operationFailure == null + || operationFailure instanceof Rejected + || operationFailure instanceof DatabaseDiagnostics.Configuration) { + phase = Phase.CLEANUP; + throw cleanupFailure; + } + // Report cleanup separately without masking the original statement/commit failure. + DatabaseDiagnostics.report(operation, Phase.CLEANUP, dialect, cleanupFailure); + } + } + } catch (Rejected rejection) { + // Rejections are decisions of this class, not database failures: nothing is logged, and + // the messages are fixed texts without any of the caller's data. + throw switch (rejection.reason) { + case DUPLICATE -> new SessionException(SessionException.SESSION_ALREADY_EXISTS); + case DUPLICATE_EVENT -> + new SessionException("An event with this ID already exists in the session."); + case MISSING -> new SessionNotFoundException("Session not found."); + case STALE -> + new SessionException( + "Session history is stale or filtered; reload the session before appending."); + case SEQUENCE_EXHAUSTED -> new SessionException("Session event sequence is exhausted."); + }; + } catch (DatabaseDiagnostics.Configuration configuration) { + throw new SessionException(configuration.getMessage()); + } catch (SessionDataCodec.StorageFailure failure) { + // A resource close may have attached an unsafe suppressed driver exception. + throw DatabaseDiagnostics.failure(operation, phase, dialect, failure.getMessage(), failure); + } catch (SQLException | RuntimeException failure) { + // What the caller needs to know first is whether the write happened. Only when it certainly + // did not is the phase that failed worth naming. + String message; + if (write && commitOutcome == CommitOutcome.CONFIRMED) { + message = "The database write committed, but cleanup failed; reload the session."; + } else if (write && commitOutcome == CommitOutcome.UNCONFIRMED) { + message = "The database write may have committed; reload the session before retrying."; + } else if (commitOutcome == CommitOutcome.NOT_ATTEMPTED + && DatabaseDiagnostics.concurrencyConflict(failure, dialect)) { + // Nothing was committed and the supplied session is unchanged, so the same call can be + // repeated as it is. + message = CONFLICT; + } else { + message = + switch (phase) { + case CONNECTION -> "Unable to borrow a database connection."; + case SETUP -> "Unable to configure the database session transaction."; + case SCHEMA -> + "Unable to verify the ADK schema; check the database and run migrations if needed."; + case CLEANUP -> "Unable to restore the borrowed database connection."; + default -> "Unable to complete the database session operation."; + }; + } + throw DatabaseDiagnostics.failure(operation, phase, dialect, message, failure); + } + } + + /** Reads the current integer value of a SQLite per-connection setting. */ + private static int pragma(Connection connection, String name) throws SQLException { + try (Statement query = connection.createStatement(); + ResultSet rows = query.executeQuery("PRAGMA " + name)) { + if (!rows.next()) { + throw new SQLException(); + } + return rows.getInt(1); + } + } + + /** + * Executes a SQLite transaction or settings command. These take no parameters, and their text + * comes only from this class and from integers it read from the connection. + */ + private static void control(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } + + /** How far a commit got, which is what a caller of a failed write most needs to know. */ + private enum CommitOutcome { + /** The transaction failed, or is still running, before any commit was sent. */ + NOT_ATTEMPTED, + /** The commit was sent and no answer has been seen: it may or may not have been applied. */ + UNCONFIRMED, + /** The database acknowledged the commit; a later failure can only concern cleanup. */ + CONFIRMED + } + + /** Why an operation was refused although the database itself worked. */ + private enum Reason { + /** A session with this key exists. */ + DUPLICATE, + /** The session already has an event with this ID. */ + DUPLICATE_EVENT, + /** There is no session with this key. */ + MISSING, + /** The caller's last event is not the stored last event. */ + STALE, + /** The session has used every position a {@code BIGINT} can number. */ + SEQUENCE_EXHAUSTED + } + + /** + * Thrown inside {@link SqlWork} to abandon the transaction for a {@link Reason}. It is caught in + * {@link #transaction}, after the rollback, and turned into the exception the caller sees; it + * never leaves this class. + */ + private static final class Rejected extends RuntimeException { + final Reason reason; + + Rejected(Reason reason) { + this.reason = reason; + } + } + + /** The statements of one operation, run by {@link #transaction} between begin and commit. */ + @FunctionalInterface + private interface SqlWork { + T run(Connection connection) throws SQLException; + } +} diff --git a/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseTime.java b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseTime.java new file mode 100644 index 000000000..87b72a344 --- /dev/null +++ b/contrib/database-session-service/src/main/java/com/google/adk/sessions/DatabaseTime.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.ResolverStyle; +import java.time.temporal.ChronoUnit; +import java.util.Locale; +import org.jspecify.annotations.Nullable; + +/** + * UTC wall times in the common SQL range, without the JVM or connection time zone. + * + *

The time columns have no time zone, as in the Python v1 schema, and hold UTC by convention. An + * {@link Instant} is therefore written as the date and time it has in UTC, through {@link + * LocalDateTime}. A {@code java.sql.Timestamp} would not do: drivers convert it using the time zone + * of the JVM or of the connection, and the stored value would then depend on where the application + * runs. + * + *

Precision is microseconds, the finest that all four engines keep. + */ +final class DatabaseTime { + /** Earliest time every engine stores; MySQL's {@code DATETIME} starts at the year 1000. */ + static final Instant MIN = Instant.parse("1000-01-01T00:00:00Z"); + + /** Latest time every engine stores, at the last microsecond of the year 9999. */ + static final Instant MAX = Instant.parse("9999-12-31T23:59:59.999999Z"); + + /** + * SQLite has no time type, so times are text. All six fraction digits are always written: text of + * one fixed width sorts and compares in the order of the times it stands for, which the {@code + * ORDER BY} and {@code >=} of the store rely on. It is also the form in which SQLAlchemy, and + * with it the Python service, writes its SQLite times. + */ + private static final DateTimeFormatter SQLITE_FORMAT = + DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSSSSS", Locale.ROOT) + .withResolverStyle(ResolverStyle.STRICT); + + private DatabaseTime() {} + + /** + * Returns the time as it will be stored, cut to microseconds, so that a caller holds the same + * value that a later read returns. + * + * @throws SessionException if the time is {@code null} or outside {@link #MIN} to {@link #MAX} + */ + static Instant forStorage(@Nullable Instant time) { + if (time == null || time.isBefore(MIN) || time.truncatedTo(ChronoUnit.MICROS).isAfter(MAX)) { + throw new SessionException("Database session times must be in UTC years 1000 through 9999."); + } + return time.truncatedTo(ChronoUnit.MICROS); + } + + /** + * Rounds a time up to the next microsecond unless it is already on one. + * + *

For a lower bound of a query. Stored times are whole microseconds, so "at or after the + * cursor" is the same set of rows as "at or after the cursor rounded up", while rounding down + * would let in a row from just before the cursor. + */ + static Instant ceilToMicros(Instant cursor) { + Instant floor = cursor.truncatedTo(ChronoUnit.MICROS); + return floor.equals(cursor) ? floor : floor.plus(1, ChronoUnit.MICROS); + } + + /** Binds a time as its UTC date and time: as text for SQLite, as a zone-free value otherwise. */ + static void bind(PreparedStatement statement, int index, Instant time, Dialect dialect) + throws SQLException { + LocalDateTime utc = LocalDateTime.ofInstant(time, ZoneOffset.UTC); + if (dialect == Dialect.SQLITE) { + statement.setString(index, SQLITE_FORMAT.format(utc)); + } else { + statement.setObject(index, utc); + } + } + + /** Reads a time column, taking the stored date and time to be UTC. */ + static Instant read(ResultSet rows, String column, Dialect dialect) throws SQLException { + LocalDateTime utc = + dialect == Dialect.SQLITE + ? LocalDateTime.parse(rows.getString(column), SQLITE_FORMAT) + : rows.getObject(column, LocalDateTime.class); + return utc.toInstant(ZoneOffset.UTC); + } +} diff --git a/contrib/database-session-service/src/main/java/com/google/adk/sessions/SessionDataCodec.java b/contrib/database-session-service/src/main/java/com/google/adk/sessions/SessionDataCodec.java new file mode 100644 index 000000000..7b17a76de --- /dev/null +++ b/contrib/database-session-service/src/main/java/com/google/adk/sessions/SessionDataCodec.java @@ -0,0 +1,419 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonStreamContext; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.adk.JsonBaseModel; +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.Nullable; + +/** + * JSON boundary and scope rules shared by all JDBC dialects. Never mutates caller-owned data. + * + *

Two things are stored as JSON text: state documents and events. + * + *

    + *
  • A state document is a JSON object for one scope: an app, a user, or a session. Its + * keys carry no scope prefix; the table it is in says which scope it is. A key is either + * present with a value or absent, so a top-level {@code null} is never written and, if found, + * is read as absent. Inside a value, {@code null} is ordinary data. + *
  • An event is the whole Java {@link Event}, written with ADK's own mapper so that it + * reads back equal. One part is written differently, its state delta: {@code temp:} keys are + * left out, and a removed key is written as {@code null}. + *
+ * + *

The methods come in the order data flows: a caller's map is split into scoped snapshots, + * snapshots are applied to stored documents and written, stored documents are read and merged into + * the map a caller sees. + * + *

Failures carry a fixed message and no cause: a Jackson message can quote the value it failed + * on. Instances are immutable and safe to share between threads. + */ +final class SessionDataCodec { + /** + * Name of the per-call attribute that holds the event being written. The delta serializer + * compares it with the object it is nested in, to act on that event's own delta only. + */ + private static final String EVENT_ROOT = "com.google.adk.sessions.persistedEvent"; + + /** A JSON object as a map that keeps the order of its keys. */ + private static final TypeReference> STATE_TYPE = + new TypeReference<>() {}; + + // Configure a private copy once: changing ADK's shared mapper would affect unrelated services. + private final ObjectMapper mapper = JsonBaseModel.getMapper().copy(); + // Event-only rules must not change how arbitrary model objects inside state are encoded. + private final ObjectWriter eventWriter = + mapper.copy().addMixIn(EventActions.class, PersistedActions.class).writer(); + // Text after the document, such as a second object, means the column does not hold what was + // written; fail instead of reading only its beginning. + private final ObjectReader treeReader = + mapper.readerFor(JsonNode.class).with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + private final ObjectReader eventReader = + mapper.readerFor(Event.class).with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + /** + * Scoped delta snapshots. Only the outermost prefix is a scope; suffixes are literal keys. + * + *

So {@code app:temp:x} is the app's key {@code temp:x}. Each map is unmodifiable, holds + * values in their JSON shape (maps, lists, strings, numbers, booleans), and marks a key to remove + * with {@link State#REMOVED}. + * + * @param app the {@code app:} keys without the prefix + * @param user the {@code user:} keys without the prefix + * @param local the keys without a scope prefix + */ + record ScopedState( + Map app, Map user, Map local) {} + + /** + * A safe codec message from a storage operation, not an arbitrary driver SessionException. + * + *

The store passes the message of this type on to the caller as it is. Any other {@link + * SessionException} that surfaces during a transaction, for instance one thrown by the + * application's data source, is not trusted that way and gets a message of the store's own. + */ + static final class StorageFailure extends SessionException { + private StorageFailure(String message) { + super(message); + } + } + + /** + * Splits a combined state or an event's state delta by scope, and takes a snapshot of it. + * + *

The values are put through JSON once, here. That detaches them from the caller's objects, + * which may change while a transaction runs, gives them the shape they will have when read back, + * and fails now, before any connection is borrowed, if a value cannot be written. {@code temp:} + * entries are dropped before that, so their values are never looked at. + * + * @param state keys with scope prefixes; {@code null} is the same as empty + * @throws SessionException if a value cannot be written as JSON + */ + ScopedState splitState(@Nullable Map state) { + try { + Map app = new LinkedHashMap<>(); + Map user = new LinkedHashMap<>(); + Map local = new LinkedHashMap<>(); + if (state != null) { + state.forEach( + (key, value) -> { + if (key.startsWith(State.APP_PREFIX)) { + app.put(key.substring(State.APP_PREFIX.length()), value); + } else if (key.startsWith(State.USER_PREFIX)) { + user.put(key.substring(State.USER_PREFIX.length()), value); + } else if (!key.startsWith(State.TEMP_PREFIX)) { + local.put(key, value); + } + }); + } + return new ScopedState(snapshotDelta(app), snapshotDelta(user), snapshotDelta(local)); + } catch (JsonProcessingException | RuntimeException e) { + throw new SessionException("Unable to split session state."); + } + } + + /** + * Writes an already scoped document, not a combined prefixed state or an event delta. + * + *

Entries whose value is {@code null} or {@link State#REMOVED} are left out: in a stored + * document a key that is not there is how a removed key looks. + */ + String writeState(Map state) { + try { + return mapper.writeValueAsString(activeValues(state)); + } catch (JsonProcessingException | RuntimeException e) { + throw new StorageFailure("Unable to encode session state."); + } + } + + /** + * Reads an active document. Only top-level nulls mean absence; nested nulls are user data. + * + * @param json the text of a state column + * @return a new, mutable map that nothing else refers to + * @throws StorageFailure if the text is {@code null}, not a single JSON object, or not JSON; a + * NULL column therefore fails rather than reading as an empty state + */ + Map readState(@Nullable String json) { + try { + return activeValues(readObject(json)); + } catch (JsonProcessingException | RuntimeException e) { + throw new StorageFailure("Unable to decode session state."); + } + } + + /** + * Applies a scoped delta as replacement/deletion, not recursive map merging. + * + *

Both arguments are this codec's own snapshots, {@code current} from {@link #readState} and + * {@code delta} from {@link #splitState}, so their values already have their JSON shape. The + * result is a new map over those same values, meant only for {@link #writeState}; encoding and + * decoding it once more here would double the cost of every state write. + */ + Map applyState(Map current, @Nullable Map delta) { + try { + Map updated = activeValues(current); + if (delta != null) { + delta.forEach( + (key, value) -> { + if (value == null || value == State.REMOVED) { + updated.remove(key); + } else { + updated.put(key, value); + } + }); + } + return updated; + } catch (RuntimeException e) { + throw new StorageFailure("Unable to apply session state."); + } + } + + /** + * Builds a session view; shared documents are authoritative for their own scopes. + * + *

The view keeps the values of the three documents instead of copying them through JSON a + * second time. Pass documents decoded by {@link #readState} for this one view, so that no two + * sessions end up sharing a nested value. + */ + ConcurrentHashMap mergeState( + Map app, Map user, Map local) { + try { + ConcurrentHashMap combined = new ConcurrentHashMap<>(); + // The session's own document should hold no prefixed keys. If one is there all the same, + // it must not hide or resurrect a shared key, so it is skipped and the shared documents, + // added next, decide what the prefixed keys are. + activeValues(local) + .forEach( + (key, value) -> { + if (!key.startsWith(State.APP_PREFIX) + && !key.startsWith(State.USER_PREFIX) + && !key.startsWith(State.TEMP_PREFIX)) { + combined.put(key, value); + } + }); + activeValues(app).forEach((key, value) -> combined.put(State.APP_PREFIX + key, value)); + activeValues(user).forEach((key, value) -> combined.put(State.USER_PREFIX + key, value)); + return combined; + } catch (RuntimeException e) { + throw new StorageFailure("Unable to merge session state."); + } + } + + /** + * Stores the full Java event, with a narrowly scoped state-delta serialization override. + * + *

The result is what {@code event.toJson()} produces, except inside the event's own state + * delta, where {@code temp:} keys are left out and removals are written as {@code null}; see + * {@link PersistedDeltaSerializer}. The event itself is not changed. + * + * @throws SessionException if the event has no ID, without which it could not be read back, or + * holds a value that cannot be written as JSON + */ + String writeEvent(Event event) { + try { + if (event.id() == null) { + throw new IllegalArgumentException(); + } + // The attribute tells the delta serializer which object is the event being stored. + return eventWriter.withAttribute(EVENT_ROOT, event).writeValueAsString(event); + } catch (JsonProcessingException | RuntimeException e) { + throw new SessionException("Unable to encode session event."); + } + } + + /** + * Reads an event written by {@link #writeEvent}. + * + * @param json the text of an {@code event_data} column + * @throws StorageFailure if the text is not one JSON object that holds an event with an ID and a + * timestamp; a NULL column, which the Python v1 layout allows, is such a case + */ + Event readEvent(@Nullable String json) { + try { + requireEventIdentity(json); + // EventActions.Builder restores top-level null delta values as State.REMOVED. A literal + // sentinel-looking string is deliberately never interpreted as a deletion. + // Decode the original bytes, not an intermediate tree: a floating-point JsonNode can lose + // nanoseconds from typed metadata Instants before their native deserializer sees the value. + Event restored = eventReader.readValue(json); + // writeEvent never stores a temp: key. Should a row hold one anyway, written by something + // else, it still must not come back as part of a loaded session. + if (restored.actions() != null && restored.actions().stateDelta() != null) { + restored.actions().stateDelta().keySet().removeIf(key -> key.startsWith(State.TEMP_PREFIX)); + } + return restored; + } catch (IOException | RuntimeException e) { + throw new StorageFailure("Unable to decode session event."); + } + } + + /** + * Requires a JSON object whose {@code id} is text and whose {@code timestamp} fits a long. The + * native builder supplies the current wall clock when timestamp is missing; a damaged record must + * fail, not silently become a different event when read from storage. + * + *

History is the largest payload of a session read, so this scans tokens instead of building a + * second, full tree of every event. As in a tree, the last duplicate of a field decides. + */ + private void requireEventIdentity(@Nullable String json) throws IOException { + boolean id = false; + boolean timestamp = false; + try (JsonParser parser = mapper.getFactory().createParser(json)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + throw new IllegalArgumentException(); + } + // Walk the fields of the root object only. Each turn of the loop is at a field name, + // looks at the first token of its value, and then steps over the rest of that value. + while (parser.nextToken() == JsonToken.FIELD_NAME) { + String field = parser.currentName(); + JsonToken value = parser.nextToken(); + if (field.equals("id")) { + id = value == JsonToken.VALUE_STRING; + } else if (field.equals("timestamp")) { + // An integer that does not fit a long is reported as BIG_INTEGER. + timestamp = + value == JsonToken.VALUE_NUMBER_INT + && parser.getNumberType() != JsonParser.NumberType.BIG_INTEGER; + } + // Does nothing after a scalar; after the start of an object or array it moves to its + // end, so that an "id" nested inside some other field is never taken for the event's. + parser.skipChildren(); + } + // The object must close, and nothing may follow it. + if (parser.currentToken() != JsonToken.END_OBJECT || parser.nextToken() != null) { + throw new IllegalArgumentException(); + } + } + if (!id || !timestamp) { + throw new IllegalArgumentException(); + } + } + + /** + * Copies one scope of a delta through JSON and back. Removals take a detour: the sentinel {@link + * State#REMOVED} is not something JSON can carry, so it is written as {@code null} and restored + * afterwards, which also turns a plain {@code null} value into a removal. + */ + private Map snapshotDelta(Map delta) + throws JsonProcessingException { + Map normalized = new LinkedHashMap<>(); + delta.forEach((key, value) -> normalized.put(key, value == State.REMOVED ? null : value)); + Map snapshot = readObject(mapper.writeValueAsString(normalized)); + snapshot.replaceAll((key, value) -> value == null ? State.REMOVED : value); + return Collections.unmodifiableMap(snapshot); + } + + /** Copies the entries that have a value, dropping those that are {@code null} or removed. */ + private static Map activeValues(Map state) { + Map active = new LinkedHashMap<>(); + state.forEach( + (key, value) -> { + if (value != null && value != State.REMOVED) { + active.put(key, value); + } + }); + return active; + } + + /** Parses one JSON object into maps, lists and scalar values. */ + private Map readObject(@Nullable String json) throws JsonProcessingException { + return mapper.convertValue(readObjectNode(json), STATE_TYPE); + } + + /** Parses text that must be exactly one JSON object: not an array, a scalar, or {@code null}. */ + private ObjectNode readObjectNode(@Nullable String json) throws JsonProcessingException { + JsonNode node = treeReader.readValue(json); + if (!(node instanceof ObjectNode object)) { + throw new IllegalArgumentException(); + } + return object; + } + + /** + * Jackson mix-in that gives {@link EventActions#stateDelta()} its own serializer in the event + * writer, without touching the class in core or the mapper other code uses. + */ + private abstract static class PersistedActions { + @JsonProperty("stateDelta") + @JsonSerialize(using = PersistedDeltaSerializer.class) + abstract Map stateDelta(); + } + + /** + * Writes the state delta of the event being stored: {@code temp:} entries are skipped without + * looking at their values, and a removed key is written as {@code null}, which the builder of + * {@link EventActions} turns back into {@link State#REMOVED} on reading. + * + *

It applies only to the delta of the stored event itself. An {@link EventActions} can also + * turn up as plain data, inside state or a tool's output, and is then written as usual. + */ + private static final class PersistedDeltaSerializer extends JsonSerializer> { + @Override + public void serialize( + Map delta, JsonGenerator generator, SerializerProvider provider) + throws IOException { + // The delta's own object is not open yet, so the current context is the EventActions + // around it and the parent is whatever holds that. For the stored event's delta the + // parent is the event itself, writing its "actions" field. + JsonStreamContext parent = generator.getOutputContext().getParent(); + if (parent == null + || parent.getCurrentValue() != provider.getAttribute(EVENT_ROOT) + || !"actions".equals(parent.getCurrentName())) { + // An EventActions model used as arbitrary tool/output data is not the root event delta. + provider.defaultSerializeValue(delta, generator); + return; + } + generator.writeStartObject(); + for (Map.Entry entry : delta.entrySet()) { + // Filter before inspecting/serializing the value: temp state need not be JSON-serializable. + if (entry.getKey().startsWith(State.TEMP_PREFIX)) { + continue; + } + generator.writeFieldName(entry.getKey()); + Object value = entry.getValue(); + if (value == null || value == State.REMOVED) { + generator.writeNull(); + } else { + provider.defaultSerializeValue(value, generator); + } + } + generator.writeEndObject(); + } + } +} diff --git a/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/mariadb/V1__create_session_schema.sql b/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/mariadb/V1__create_session_schema.sql new file mode 100644 index 000000000..85628ba8b --- /dev/null +++ b/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/mariadb/V1__create_session_schema.sql @@ -0,0 +1,55 @@ +-- Copyright 2026 Google LLC +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software distributed under +-- the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +-- ANY KIND, either express or implied. See the License for the specific language +-- governing permissions and limitations under the License. + +-- UTF-8 bytes avoid case/accent folding and PAD SPACE key comparison. +-- MariaDB JSON is validated LONGTEXT, not MySQL binary JSON. + +CREATE TABLE adk_internal_metadata ( + `key` VARCHAR(128) NOT NULL PRIMARY KEY, + value VARCHAR(256) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_nopad_bin; +INSERT INTO adk_internal_metadata (`key`, value) VALUES + ('schema_version', '1'), ('adk_java_storage_format', 'jdbc-v1'); + +CREATE TABLE app_states ( + app_name VARCHAR(128) NOT NULL PRIMARY KEY, + state LONGTEXT NOT NULL CHECK (JSON_VALID(state) AND JSON_TYPE(state) = 'OBJECT'), + update_time DATETIME(6) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_nopad_bin; +CREATE TABLE user_states ( + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + state LONGTEXT NOT NULL CHECK (JSON_VALID(state) AND JSON_TYPE(state) = 'OBJECT'), + update_time DATETIME(6) NOT NULL, + PRIMARY KEY (app_name, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_nopad_bin; +CREATE TABLE sessions ( + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + id VARCHAR(128) NOT NULL, + state LONGTEXT NOT NULL CHECK (JSON_VALID(state) AND JSON_TYPE(state) = 'OBJECT'), + create_time DATETIME(6) NOT NULL, + update_time DATETIME(6) NOT NULL, + PRIMARY KEY (app_name, user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_nopad_bin; +CREATE TABLE events ( + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + session_id VARCHAR(128) NOT NULL, + event_sequence BIGINT NOT NULL CHECK (event_sequence > 0), + id VARCHAR(128) NOT NULL, + invocation_id VARCHAR(256) NOT NULL, + timestamp DATETIME(6) NOT NULL, + event_data LONGTEXT CHECK (event_data IS NULL OR (JSON_VALID(event_data) AND JSON_TYPE(event_data) = 'OBJECT')), + PRIMARY KEY (id, app_name, user_id, session_id), + UNIQUE (app_name, user_id, session_id, event_sequence), + FOREIGN KEY (app_name, user_id, session_id) REFERENCES sessions (app_name, user_id, id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_nopad_bin; +CREATE INDEX idx_events_app_user_session_ts_id + ON events (app_name, user_id, session_id, timestamp DESC, id DESC); diff --git a/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/mysql/V1__create_session_schema.sql b/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/mysql/V1__create_session_schema.sql new file mode 100644 index 000000000..46c39b378 --- /dev/null +++ b/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/mysql/V1__create_session_schema.sql @@ -0,0 +1,55 @@ +-- Copyright 2026 Google LLC +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software distributed under +-- the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +-- ANY KIND, either express or implied. See the License for the specific language +-- governing permissions and limitations under the License. + +-- UTF-8 bytes avoid case/accent folding and PAD SPACE key comparison. +-- MySQL binary JSON rounds integers beyond uint64 to double. Validated text preserves ADK payloads. + +CREATE TABLE adk_internal_metadata ( + `key` VARCHAR(128) NOT NULL PRIMARY KEY, + value VARCHAR(256) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin; +INSERT INTO adk_internal_metadata (`key`, value) VALUES + ('schema_version', '1'), ('adk_java_storage_format', 'jdbc-v1'); + +CREATE TABLE app_states ( + app_name VARCHAR(128) NOT NULL PRIMARY KEY, + state LONGTEXT NOT NULL CHECK (JSON_VALID(state) AND JSON_TYPE(state) = 'OBJECT'), + update_time DATETIME(6) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin; +CREATE TABLE user_states ( + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + state LONGTEXT NOT NULL CHECK (JSON_VALID(state) AND JSON_TYPE(state) = 'OBJECT'), + update_time DATETIME(6) NOT NULL, + PRIMARY KEY (app_name, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin; +CREATE TABLE sessions ( + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + id VARCHAR(128) NOT NULL, + state LONGTEXT NOT NULL CHECK (JSON_VALID(state) AND JSON_TYPE(state) = 'OBJECT'), + create_time DATETIME(6) NOT NULL, + update_time DATETIME(6) NOT NULL, + PRIMARY KEY (app_name, user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin; +CREATE TABLE events ( + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + session_id VARCHAR(128) NOT NULL, + event_sequence BIGINT NOT NULL CHECK (event_sequence > 0), + id VARCHAR(128) NOT NULL, + invocation_id VARCHAR(256) NOT NULL, + timestamp DATETIME(6) NOT NULL, + event_data LONGTEXT CHECK (event_data IS NULL OR (JSON_VALID(event_data) AND JSON_TYPE(event_data) = 'OBJECT')), + PRIMARY KEY (id, app_name, user_id, session_id), + UNIQUE (app_name, user_id, session_id, event_sequence), + FOREIGN KEY (app_name, user_id, session_id) REFERENCES sessions (app_name, user_id, id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin; +CREATE INDEX idx_events_app_user_session_ts_id + ON events (app_name, user_id, session_id, timestamp DESC, id DESC); diff --git a/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/postgresql/V1__create_session_schema.sql b/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/postgresql/V1__create_session_schema.sql new file mode 100644 index 000000000..ce5f2e7a3 --- /dev/null +++ b/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/postgresql/V1__create_session_schema.sql @@ -0,0 +1,55 @@ +-- Copyright 2026 Google LLC +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software distributed under +-- the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +-- ANY KIND, either express or implied. See the License for the specific language +-- governing permissions and limitations under the License. + +-- C collation preserves exact Java key identity. +-- JSON preserves the submitted document instead of normalizing numbers or rejecting escaped NUL. + +CREATE TABLE adk_internal_metadata ( + key VARCHAR(128) NOT NULL PRIMARY KEY, + value VARCHAR(256) NOT NULL +); +INSERT INTO adk_internal_metadata (key, value) VALUES + ('schema_version', '1'), ('adk_java_storage_format', 'jdbc-v1'); + +CREATE TABLE app_states ( + app_name VARCHAR(128) COLLATE "C" NOT NULL PRIMARY KEY, + state JSON NOT NULL CHECK (json_typeof(state) = 'object'), + update_time TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL +); +CREATE TABLE user_states ( + app_name VARCHAR(128) COLLATE "C" NOT NULL, + user_id VARCHAR(128) COLLATE "C" NOT NULL, + state JSON NOT NULL CHECK (json_typeof(state) = 'object'), + update_time TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY (app_name, user_id) +); +CREATE TABLE sessions ( + app_name VARCHAR(128) COLLATE "C" NOT NULL, + user_id VARCHAR(128) COLLATE "C" NOT NULL, + id VARCHAR(128) COLLATE "C" NOT NULL, + state JSON NOT NULL CHECK (json_typeof(state) = 'object'), + create_time TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL, + update_time TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY (app_name, user_id, id) +); +CREATE TABLE events ( + app_name VARCHAR(128) COLLATE "C" NOT NULL, + user_id VARCHAR(128) COLLATE "C" NOT NULL, + session_id VARCHAR(128) COLLATE "C" NOT NULL, + event_sequence BIGINT NOT NULL CHECK (event_sequence > 0), + id VARCHAR(128) COLLATE "C" NOT NULL, + invocation_id VARCHAR(256) NOT NULL, + timestamp TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL, + event_data JSON CHECK (event_data IS NULL OR (json_typeof(event_data) = 'object')), + PRIMARY KEY (id, app_name, user_id, session_id), + UNIQUE (app_name, user_id, session_id, event_sequence), + FOREIGN KEY (app_name, user_id, session_id) REFERENCES sessions (app_name, user_id, id) ON DELETE CASCADE +); +CREATE INDEX idx_events_app_user_session_ts_id + ON events (app_name, user_id, session_id, timestamp DESC, id DESC); diff --git a/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/sqlite/V1__create_session_schema.sql b/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/sqlite/V1__create_session_schema.sql new file mode 100644 index 000000000..f03816b69 --- /dev/null +++ b/contrib/database-session-service/src/main/resources/com/google/adk/sessions/migration/sqlite/V1__create_session_schema.sql @@ -0,0 +1,53 @@ +-- Copyright 2026 Google LLC +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 +-- Unless required by applicable law or agreed to in writing, software distributed under +-- the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +-- ANY KIND, either express or implied. See the License for the specific language +-- governing permissions and limitations under the License. + +CREATE TABLE adk_internal_metadata ( + key VARCHAR(128) NOT NULL PRIMARY KEY, + value VARCHAR(256) NOT NULL +); +INSERT INTO adk_internal_metadata (key, value) VALUES + ('schema_version', '1'), ('adk_java_storage_format', 'jdbc-v1'); + +CREATE TABLE app_states ( + app_name VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(app_name) <= 128), + state TEXT NOT NULL CHECK (json_valid(state) AND json_type(state) = 'object'), + update_time DATETIME NOT NULL, + PRIMARY KEY (app_name) +); +CREATE TABLE user_states ( + app_name VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(app_name) <= 128), + user_id VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(user_id) <= 128), + state TEXT NOT NULL CHECK (json_valid(state) AND json_type(state) = 'object'), + update_time DATETIME NOT NULL, + PRIMARY KEY (app_name, user_id) +); +CREATE TABLE sessions ( + app_name VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(app_name) <= 128), + user_id VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(user_id) <= 128), + id VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(id) <= 128), + state TEXT NOT NULL CHECK (json_valid(state) AND json_type(state) = 'object'), + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + PRIMARY KEY (app_name, user_id, id) +); +CREATE TABLE events ( + app_name VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(app_name) <= 128), + user_id VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(user_id) <= 128), + session_id VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(session_id) <= 128), + event_sequence BIGINT NOT NULL CHECK (event_sequence > 0), + id VARCHAR(128) COLLATE BINARY NOT NULL CHECK (length(id) <= 128), + invocation_id VARCHAR(256) NOT NULL CHECK (length(invocation_id) <= 256), + timestamp DATETIME NOT NULL, + event_data TEXT CHECK (event_data IS NULL OR (json_valid(event_data) AND json_type(event_data) = 'object')), + PRIMARY KEY (id, app_name, user_id, session_id), + UNIQUE (app_name, user_id, session_id, event_sequence), + FOREIGN KEY (app_name, user_id, session_id) REFERENCES sessions (app_name, user_id, id) ON DELETE CASCADE +); +CREATE INDEX idx_events_app_user_session_ts_id + ON events (app_name, user_id, session_id, timestamp DESC, id DESC); diff --git a/contrib/database-session-service/src/test/consumers/spring-boot41/invoker.properties b/contrib/database-session-service/src/test/consumers/spring-boot41/invoker.properties new file mode 100644 index 000000000..3d01515b3 --- /dev/null +++ b/contrib/database-session-service/src/test/consumers/spring-boot41/invoker.properties @@ -0,0 +1,2 @@ +invoker.buildResult=success +invoker.timeoutInSeconds=300 diff --git a/contrib/database-session-service/src/test/consumers/spring-boot41/pom.xml b/contrib/database-session-service/src/test/consumers/spring-boot41/pom.xml new file mode 100644 index 000000000..febb5b492 --- /dev/null +++ b/contrib/database-session-service/src/test/consumers/spring-boot41/pom.xml @@ -0,0 +1,58 @@ + + + + 4.0.0 + com.google.adk.testing + database-session-consumer + 1.0.0 + + 17 + UTF-8 + + + + + org.springframework.boot + spring-boot-dependencies + 4.1.2 + pom + import + + + + + + + com.google.adk + google-adk-database-session-service + ${adk.version} + system + ${adk.module.jar} + + + com.google.adk + google-adk + ${adk.version} + + + org.flywaydbflyway-core + org.xerialsqlite-jdbc3.54.1.0 + + + + org.apache.maven.pluginsmaven-compiler-plugin3.13.0 + + org.codehaus.mojoexec-maven-plugin3.6.4 + verifyjava + + consumer.BootBomConsumer + true + test + ${project.build.directory}/consumer.db + + + + + diff --git a/contrib/database-session-service/src/test/consumers/spring-boot41/src/main/java/consumer/BootBomConsumer.java b/contrib/database-session-service/src/test/consumers/spring-boot41/src/main/java/consumer/BootBomConsumer.java new file mode 100644 index 000000000..b28bd4d33 --- /dev/null +++ b/contrib/database-session-service/src/test/consumers/spring-boot41/src/main/java/consumer/BootBomConsumer.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package consumer; + +import com.google.adk.events.Event; +import com.google.adk.sessions.DatabaseSessionService; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.sqlite.SQLiteDataSource; + +/** Packaged ADK contrib under Boot 4.1 dependency management, with no Spring runtime required. */ +public final class BootBomConsumer { + public static void main(String[] arguments) throws Exception { + Properties artifact = new Properties(); + try (var input = + Flyway.class.getResourceAsStream( + "/META-INF/maven/org.flywaydb/flyway-core/pom.properties")) { + artifact.load(input); + } + if (!"12.4.0".equals(artifact.getProperty("version"))) { + throw new IllegalStateException("Boot consumer must actually resolve Flyway 12.4.0."); + } + Path file = Path.of(arguments[0]).toAbsolutePath(); + Files.createDirectories(file.getParent()); + SQLiteDataSource source = new SQLiteDataSource(); + source.setUrl("jdbc:sqlite:" + file); + Flyway application = + new FluentConfiguration() + .dataSource(source) + .locations("classpath:application") + .loggers(new String[0]) + .load(); + application.migrate(); + DatabaseSessionService service = new DatabaseSessionService(source); + service.migrate().blockingAwait(); + application.migrate(); + var session = service.createSession("boot", "user").blockingGet(); + service.appendEvent(session, Event.builder().id("event").timestamp(123).build()).blockingGet(); + var restored = + new DatabaseSessionService(source).getSession(session.sessionKey(), null).blockingGet(); + if (restored == null || restored.immutableEvents().size() != 1) { + throw new IllegalStateException("Consumer persistence failed."); + } + System.out.println("ADK_BOOT_BOM_CONSUMER_OK boot=4.1.2 flyway=12.4.0"); + } +} diff --git a/contrib/database-session-service/src/test/consumers/spring-boot41/src/main/resources/application/V1__application.sql b/contrib/database-session-service/src/test/consumers/spring-boot41/src/main/resources/application/V1__application.sql new file mode 100644 index 000000000..b028a6809 --- /dev/null +++ b/contrib/database-session-service/src/test/consumers/spring-boot41/src/main/resources/application/V1__application.sql @@ -0,0 +1,2 @@ +-- Consumer application's migration, isolated from ADK's own V1 and history. +CREATE TABLE application_data (id INTEGER NOT NULL PRIMARY KEY); diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/ConsumerClasspath.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/ConsumerClasspath.java new file mode 100644 index 000000000..78c980c17 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/ConsumerClasspath.java @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import com.google.adk.sessions.consumer.ConsumerProbe; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** Real consumer process classpaths, not mocks of optional dependency behavior. */ +final class ConsumerClasspath { + private ConsumerClasspath() {} + + static void verify(String url, String username, String password, Dialect dialect) + throws Exception { + for (boolean migrations : new boolean[] {true, false}) { + String original = + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + String classpath = + Arrays.stream(original.split(java.util.regex.Pattern.quote(File.pathSeparator))) + .filter(entry -> include(entry, dialect, migrations)) + .collect(Collectors.joining(File.pathSeparator)); + String executable = + Path.of( + System.getProperty("java.home"), + "bin", + System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java") + .toString(); + ProcessBuilder builder = + new ProcessBuilder( + executable, + "-cp", + classpath, + ConsumerProbe.class.getName(), + dialect.name(), + Boolean.toString(migrations)) + .redirectErrorStream(true); + builder.environment().put("ADK_TEST_JDBC_URL", url); + builder.environment().put("ADK_TEST_USERNAME", username); + builder.environment().put("ADK_TEST_PASSWORD", password); + // Embedded ADK migrations must not adopt unrelated process-wide Flyway configuration. + builder.environment().put("FLYWAY_URL", "jdbc:invalid:consumer-isolation-probe"); + builder.environment().put("FLYWAY_LOCATIONS", "classpath:not_an_adk_location"); + builder.environment().put("FLYWAY_CALLBACKS", "missing_callback_for_isolation_probe"); + Process process = builder.start(); + try { + assertThat(process.waitFor(60, TimeUnit.SECONDS)).isTrue(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertWithMessage(output).that(process.exitValue()).isEqualTo(0); + assertThat(output).contains("ADK_CONSUMER_OK"); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + } + } + + private static boolean include(String entry, Dialect dialect, boolean migrations) { + String name = Path.of(entry).getFileName().toString().toLowerCase(Locale.ROOT); + for (String excluded : + List.of( + "mockito", + "junit", + "testcontainers", + "hikaricp", + "byte-buddy", + "objenesis", + "surefire", + "truth-", + "slf4j-simple")) { + if (name.contains(excluded)) return false; + } + if (name.endsWith("-tests.jar")) return false; + if (name.startsWith("sqlite-jdbc") && dialect != Dialect.SQLITE) return false; + if (name.startsWith("postgresql-") && dialect != Dialect.POSTGRESQL) return false; + if (name.startsWith("mysql-connector-j-") && dialect != Dialect.MYSQL) return false; + if (name.startsWith("mariadb-java-client-") && dialect != Dialect.MARIADB) return false; + if (name.startsWith("flyway-") && !migrations) return false; + if (name.startsWith("flyway-database-postgresql") && dialect != Dialect.POSTGRESQL) + return false; + return !name.startsWith("flyway-mysql") + || dialect == Dialect.MYSQL + || dialect == Dialect.MARIADB; + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/ConsumerClasspathTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/ConsumerClasspathTest.java new file mode 100644 index 000000000..1ad750f0c --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/ConsumerClasspathTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class ConsumerClasspathTest { + @TempDir Path directory; + + @Test + void sqliteConsumerNeedsNeitherServerDriversNorPluginsAndCanOmitFlyway() throws Exception { + var source = DatabaseSchemaMigratorTest.source(directory.resolve("consumer.db")); + new DatabaseSessionService(source).migrate().blockingAwait(); + ConsumerClasspath.verify(source.getUrl(), "", "", DatabaseSessionService.Dialect.SQLITE); + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseDiagnosticsTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseDiagnosticsTest.java new file mode 100644 index 000000000..bc72bc856 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseDiagnosticsTest.java @@ -0,0 +1,146 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.adk.sessions.DatabaseDiagnostics.Phase; +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; + +final class DatabaseDiagnosticsTest { + @Test + void extractsOnlySqlStateAndNumericVendorCodeFromWrappedFailure() { + SQLException sql = new SQLException("private-jdbc-url-and-payload", "08006", 77); + sql.addSuppressed(new SQLException("private-cleanup")); + RuntimeException driverWrapper = new RuntimeException("private-wrapper", sql); + assertThat(DatabaseDiagnostics.sqlDiagnostic(driverWrapper)) + .isEqualTo(new DatabaseDiagnostics.SqlDiagnostic("08006", 77)); + SessionException safe = + DatabaseDiagnostics.failure( + "test", Phase.CONNECTION, Dialect.SQLITE, "Safe.", driverWrapper); + assertThat(safe).hasMessageThat().isEqualTo("Safe."); + assertThat(safe).hasCauseThat().isNull(); + assertThat(safe.getSuppressed()).isEmpty(); + } + + @Test + void anySqlStateCodeIsReportedButOtherTextInItsPlaceIsNot() { + // Engines define their own classes, such as PostgreSQL's 53300 (too many connections). + assertThat(DatabaseDiagnostics.sqlDiagnostic(new SQLException("private-value", "53300", 12))) + .isEqualTo(new DatabaseDiagnostics.SqlDiagnostic("53300", 12)); + for (String notACode : new String[] {null, "", "5330", "533000", "token", "53 00", "53_00"}) { + assertThat(DatabaseDiagnostics.sqlDiagnostic(new SQLException("private-value", notACode, 12))) + .isEqualTo(new DatabaseDiagnostics.SqlDiagnostic("unavailable", 12)); + } + } + + @Test + void onlyLockAndSerializationFailuresCountAsConcurrencyConflicts() { + for (Dialect dialect : Dialect.values()) { + // The standard transaction-rollback class, also when a library wraps the driver's exception. + assertThat(conflict(new SQLException("private", "40001", 0), dialect)).isTrue(); + assertThat(conflict(new RuntimeException(new SQLException("private", "40P01", 0)), dialect)) + .isTrue(); + assertThat(conflict(new SQLException("private", "23505", 0), dialect)).isFalse(); + assertThat(conflict(new SQLException("private", "08006", 0), dialect)).isFalse(); + assertThat(conflict(new SQLException("private"), dialect)).isFalse(); + assertThat(conflict(new IllegalStateException("private"), dialect)).isFalse(); + } + assertThat(conflict(new SQLException("private", "55P03", 0), Dialect.POSTGRESQL)).isTrue(); + assertThat(conflict(new SQLException("private", "HY000", 1205), Dialect.MYSQL)).isTrue(); + assertThat(conflict(new SQLException("private", "HY000", 1213), Dialect.MARIADB)).isTrue(); + assertThat(conflict(new SQLException("private", null, 5), Dialect.SQLITE)).isTrue(); + assertThat(conflict(new SQLException("private", null, 517), Dialect.SQLITE)).isTrue(); + assertThat(conflict(new SQLException("private", null, 262), Dialect.SQLITE)).isTrue(); + // The same vendor numbers mean something else on another engine. + assertThat(conflict(new SQLException("private", "HY000", 1205), Dialect.POSTGRESQL)).isFalse(); + assertThat(conflict(new SQLException("private", null, 5), Dialect.MYSQL)).isFalse(); + assertThat(conflict(new SQLException("private", null, 19), Dialect.SQLITE)).isFalse(); + } + + private static boolean conflict(Throwable failure, Dialect dialect) { + return DatabaseDiagnostics.concurrencyConflict(failure, dialect); + } + + @Test + void aCallerDataSourceSessionExceptionIsNotTrustedAsASafeCodecFailure() throws Exception { + DataSource source = mock(DataSource.class); + SessionException original = + new SessionException("private-data-source", new SQLException("private-driver-cause")); + when(source.getConnection()).thenThrow(original); + SessionException failure = + assertThrows( + SessionException.class, + () -> service(source).getSession("app", "user", "id", Optional.empty()).blockingGet()); + assertThat(failure).isNotSameInstanceAs(original); + assertThat(failure).hasMessageThat().isEqualTo("Unable to borrow a database connection."); + assertThat(failure).hasCauseThat().isNull(); + assertThat(failure.getSuppressed()).isEmpty(); + } + + @Test + void unexpectedRuntimeFailureIsMappedWithoutItsOriginalMessage() throws Exception { + DataSource source = mock(DataSource.class); + when(source.getConnection()).thenThrow(new NullPointerException("private-npe")); + SessionException failure = + assertThrows( + SessionException.class, + () -> service(source).getSession("app", "user", "id", Optional.empty()).blockingGet()); + assertThat(failure).hasMessageThat().isEqualTo("Unable to borrow a database connection."); + assertThat(failure).hasCauseThat().isNull(); + } + + @Test + void callerTransactionsHaveASpecificSafeDiagnostic() throws Exception { + Connection connection = mock(Connection.class); + DataSource source = mock(DataSource.class); + when(source.getConnection()).thenReturn(connection); + SessionException failure = + assertThrows( + SessionException.class, + () -> service(source).getSession("app", "user", "id", Optional.empty()).blockingGet()); + assertThat(failure).hasMessageThat().contains("auto-commit"); + verify(connection).close(); + } + + @Test + void readOnlyConnectionRejectionIsDistinctFromSchemaOrSqlErrors() throws Exception { + Connection connection = mock(Connection.class); + DataSource source = mock(DataSource.class); + when(source.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(true); + when(connection.isReadOnly()).thenReturn(true); + SessionException failure = + assertThrows( + SessionException.class, + () -> service(source).createSession("app", "user").blockingGet()); + assertThat(failure).hasMessageThat().contains("writable"); + verify(connection).close(); + } + + private static DatabaseSessionService service(DataSource source) { + return DatabaseSessionService.builder(source).dialect(Dialect.SQLITE).build(); + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseDialectTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseDialectTest.java new file mode 100644 index 000000000..ef20c5a99 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseDialectTest.java @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Map; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; + +/** Tests metadata-only selection, resource cleanup and safe JDBC error boundaries. */ +final class DatabaseDialectTest { + private static final String PRIVATE_DETAILS = "jdbc:private://caller:secret@host/database"; + + @Test + void recognizesAllFourProductNamesWithoutReadingUrlsOrCredentials() throws Exception { + for (Map.Entry product : + Map.of( + " SQLite ", Dialect.SQLITE, + "PostgreSQL", Dialect.POSTGRESQL, + "MYSQL", Dialect.MYSQL, + "mArIaDb", Dialect.MARIADB) + .entrySet()) { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()).thenReturn(product.getKey()); + + assertThat(DatabaseDialect.detect(dataSource)).isEqualTo(product.getValue()); + + verify(dataSource).getConnection(); + verify(connection).getMetaData(); + verify(metadata).getDatabaseProductName(); + verify(connection).close(); + verifyNoMoreInteractions(dataSource, connection, metadata); + } + } + + @Test + void unsupportedProductDoesNotLeakItsNameAndStillClosesTheConnection() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()).thenReturn(PRIVATE_DETAILS); + + SessionException error = + assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource)); + + assertThat(error).hasMessageThat().startsWith("Unsupported database product"); + assertSafe(error); + verify(connection).close(); + } + + @Test + void nullProductIsNotTreatedAsASupportedDatabase() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + + assertSafe(assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource))); + verify(connection).close(); + } + + @Test + void connectionFailureIsMappedWithoutExposingTheDriverCause() throws Exception { + DataSource dataSource = mock(DataSource.class); + when(dataSource.getConnection()).thenThrow(new SQLException(PRIVATE_DETAILS)); + + assertSafe(assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource))); + } + + @Test + void nullConnectionFromABrokenDataSourceIsAlsoMapped() throws Exception { + DataSource dataSource = mock(DataSource.class); + + assertSafe(assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource))); + verify(dataSource).getConnection(); + verifyNoMoreInteractions(dataSource); + } + + @Test + void nullMetadataFromABrokenDriverStillClosesTheConnection() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + + assertSafe(assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource))); + verify(connection).close(); + } + + @Test + void metadataFailureClosesTheBorrowedConnection() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenThrow(new SQLException(PRIVATE_DETAILS)); + + assertSafe(assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource))); + verify(connection).close(); + } + + @Test + void productReadFailureClosesTheBorrowedConnection() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()).thenThrow(new SQLException(PRIVATE_DETAILS)); + + assertSafe(assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource))); + verify(connection).close(); + } + + @Test + void cleanupFailureIsNotMistakenForSuccessfulDetection() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()).thenReturn("MySQL"); + doThrow(new SQLException(PRIVATE_DETAILS)).when(connection).close(); + + assertSafe(assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource))); + verify(connection).close(); + } + + @Test + void uncheckedDriverFailureIsAlsoMappedAndClosesTheConnection() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenThrow(new IllegalStateException(PRIVATE_DETAILS)); + + assertSafe(assertThrows(SessionException.class, () -> DatabaseDialect.detect(dataSource))); + verify(connection).close(); + } + + private static void assertSafe(SessionException error) { + assertThat(error).hasMessageThat().doesNotContain(PRIVATE_DETAILS); + assertThat(error).hasCauseThat().isNull(); + assertThat(error.getSuppressed()).isEmpty(); + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSchemaMigratorTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSchemaMigratorTest.java new file mode 100644 index 000000000..9f4989b2c --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSchemaMigratorTest.java @@ -0,0 +1,318 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import org.sqlite.SQLiteDataSource; + +/** Actual file-backed SQLite migrations, not H2 or mocked JDBC. */ +@Timeout(30) +final class DatabaseSchemaMigratorTest { + @TempDir Path directory; + + @Test + void migrationIsColdAndRepeatableAndConstructionDoesNotCreateTheFile() throws Exception { + Path file = directory.resolve("sessions.db"); + SQLiteDataSource source = source(file); + DatabaseSessionService service = + DatabaseSessionService.builder(source).autoMigrate(true).build(); + var migration = service.migrate(); + assertThat(Files.exists(file)).isFalse(); + + migration.blockingAwait(); + migration.blockingAwait(); + + assertThat(tables(source)) + .containsAtLeast( + "sessions", + "events", + "app_states", + "user_states", + "adk_internal_metadata", + "adk_schema_history"); + assertThat(scalar(source, "SELECT value FROM adk_internal_metadata WHERE key='schema_version'")) + .isEqualTo(DatabaseSchemaMigrator.SCHEMA_VERSION); + assertThat( + scalar( + source, + "SELECT value FROM adk_internal_metadata WHERE key='adk_java_storage_format'")) + .isEqualTo(DatabaseSchemaMigrator.STORAGE_FORMAT); + assertThat(scalar(source, "SELECT COUNT(*) FROM adk_schema_history WHERE success=1")) + .isEqualTo("1"); + } + + @Test + void optInInitializationMigratesButDefaultInitializationDoesNot() throws Exception { + SQLiteDataSource defaultSource = source(directory.resolve("default.db")); + DatabaseSessionService defaultService = new DatabaseSessionService(defaultSource); + defaultService.initialize(); + assertThat(tables(defaultSource)).isEmpty(); + + SQLiteDataSource optedIn = source(directory.resolve("opted-in.db")); + DatabaseSessionService service = + DatabaseSessionService.builder(optedIn).autoMigrate(true).build(); + service.initialize(); + service.initialize(); + assertThat(scalar(optedIn, "SELECT COUNT(*) FROM adk_schema_history")).isEqualTo("1"); + } + + @Test + void collidingForeignTableIsNotAdoptedOrDeleted() throws Exception { + SQLiteDataSource source = source(directory.resolve("foreign.db")); + execute(source, "CREATE TABLE sessions (private_value TEXT)"); + execute(source, "INSERT INTO sessions VALUES ('private-value')"); + + SessionException error = + assertThrows( + SessionException.class, + () -> new DatabaseSessionService(source).migrate().blockingAwait()); + + assertThat(error).hasCauseThat().isNull(); + assertThat(error).hasMessageThat().doesNotContain("private-value"); + assertThat(tables(source)).containsExactly("sessions"); + assertThat(scalar(source, "SELECT private_value FROM sessions")).isEqualTo("private-value"); + } + + @Test + void applicationFlywayAndDefaultLocationsRemainIndependent() throws Exception { + SQLiteDataSource source = source(directory.resolve("application.db")); + new FluentConfiguration().dataSource(source).loggers(new String[0]).load().migrate(); + + new DatabaseSessionService(source).migrate().blockingAwait(); + new FluentConfiguration().dataSource(source).loggers(new String[0]).load().migrate(); + + assertThat(tables(source)) + .containsAtLeast( + "application_data", "flyway_schema_history", "adk_schema_history", "sessions"); + assertThat(scalar(source, "SELECT COUNT(*) FROM flyway_schema_history")).isEqualTo("1"); + // ADK baselines the nonempty application schema at 0 and then applies its own V1. + assertThat(scalar(source, "SELECT COUNT(*) FROM adk_schema_history")).isEqualTo("2"); + } + + @Test + void unknownMetadataVersionFailsBeforeFurtherMigration() throws Exception { + SQLiteDataSource source = migrated(); + execute( + source, + "UPDATE adk_internal_metadata SET value='future-private-version' WHERE key='schema_version'"); + + SessionException error = + assertThrows( + SessionException.class, + () -> new DatabaseSessionService(source).migrate().blockingAwait()); + + assertThat(error).hasCauseThat().isNull(); + assertThat(error).hasMessageThat().doesNotContain("future-private-version"); + assertThat(scalar(source, "SELECT value FROM adk_internal_metadata WHERE key='schema_version'")) + .isEqualTo("future-private-version"); + } + + @Test + void pythonStyleVersionAloneIsNotAnOwnershipMarker() throws Exception { + SQLiteDataSource source = source(directory.resolve("python-v1.db")); + execute( + source, + "CREATE TABLE adk_internal_metadata (key VARCHAR(128) PRIMARY KEY, value VARCHAR(256))"); + execute(source, "INSERT INTO adk_internal_metadata VALUES ('schema_version','1')"); + for (String table : List.of("sessions", "events", "app_states", "user_states")) { + execute(source, "CREATE TABLE " + table + " (id VARCHAR(128))"); + } + execute(source, "INSERT INTO sessions VALUES ('untouched')"); + DatabaseSessionService service = new DatabaseSessionService(source); + + SessionException migrate = + assertThrows(SessionException.class, () -> service.migrate().blockingAwait()); + SessionException crud = + assertThrows( + SessionException.class, () -> service.createSession("app", "user").blockingGet()); + assertThat(migrate).hasMessageThat().contains("not created by ADK Java"); + assertThat(crud).hasMessageThat().contains("not created by ADK Java"); + assertThat(tables(source)) + .containsExactly( + "adk_internal_metadata", "sessions", "events", "app_states", "user_states"); + assertThat(scalar(source, "SELECT COUNT(*) FROM adk_internal_metadata")).isEqualTo("1"); + assertThat(scalar(source, "SELECT id FROM sessions")).isEqualTo("untouched"); + } + + @Test + void unknownStorageFormatIsRejectedEvenWithCorrectVersion() throws Exception { + SQLiteDataSource source = migrated(); + execute( + source, + "UPDATE adk_internal_metadata SET value='other' WHERE key='adk_java_storage_format'"); + DatabaseSessionService service = new DatabaseSessionService(source); + SessionException failure = + assertThrows( + SessionException.class, () -> service.listSessions("app", "user").blockingGet()); + assertThat(failure).hasMessageThat().contains("not created by ADK Java"); + } + + @Test + void incompleteSchemaIsNotSilentlyRecreated() throws Exception { + SQLiteDataSource source = migrated(); + execute(source, "DROP TABLE events"); + + assertThrows( + SessionException.class, () -> new DatabaseSessionService(source).migrate().blockingAwait()); + assertThat(tables(source)).doesNotContain("events"); + } + + @Test + void realSchemaEnforcesKeysJsonAndForeignKeyCascade() throws Exception { + SQLiteDataSource source = migrated(); + String time = "'1970-01-01 00:00:00.000000'"; + String times = time + "," + time; + try (Connection connection = source.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("PRAGMA foreign_keys=ON"); + assertThrows( + SQLException.class, + () -> + statement.executeUpdate( + "INSERT INTO sessions VALUES ('a','u','s','[]'," + times + ")")); + assertThrows( + SQLException.class, + () -> statement.executeUpdate("INSERT INTO app_states VALUES ('a','[]'," + time + ")")); + assertThrows( + SQLException.class, + () -> + statement.executeUpdate( + "INSERT INTO app_states VALUES ('" + "x".repeat(129) + "','{}'," + time + ")")); + String insertSession = "INSERT INTO sessions VALUES ('a','u','s','{}'," + times + ")"; + statement.executeUpdate(insertSession); + assertThrows(SQLException.class, () -> statement.executeUpdate(insertSession)); + String eventSuffix = ",''," + time + ",'{}')"; + statement.executeUpdate("INSERT INTO events VALUES ('a','u','s',1,'id'" + eventSuffix); + assertThrows( + SQLException.class, + () -> + statement.executeUpdate( + "INSERT INTO events VALUES ('a','u','s',2,'id'" + eventSuffix)); + assertThrows( + SQLException.class, + () -> + statement.executeUpdate( + "INSERT INTO events VALUES ('a','u','s',1,'other'" + eventSuffix)); + statement.executeUpdate("INSERT INTO events VALUES ('a','u','s',2,'other'" + eventSuffix); + assertThrows( + SQLException.class, + () -> + statement.executeUpdate( + "INSERT INTO events VALUES ('a','u','missing',1,'id'" + eventSuffix)); + statement.executeUpdate("DELETE FROM sessions WHERE app_name='a'"); + try (ResultSet events = statement.executeQuery("SELECT COUNT(*) FROM events")) { + assertThat(events.next()).isTrue(); + assertThat(events.getInt(1)).isEqualTo(0); + } + } + } + + @Test + void schemaIndexesEventTimestampsWithoutAnUnusedSessionTimeIndex() throws Exception { + SQLiteDataSource source = migrated(); + assertThat( + scalar( + source, + "SELECT group_concat(column_order, ',') FROM (SELECT name || ':' || \"desc\" AS column_order FROM pragma_index_xinfo('idx_events_app_user_session_ts_id') WHERE \"key\"=1 ORDER BY seqno)")) + .isEqualTo("app_name:0,user_id:0,session_id:0,timestamp:1,id:1"); + assertThat( + scalar( + source, + "SELECT COUNT(*) FROM sqlite_master WHERE name IN ('idx_sessions_app_user_updated','idx_events_app_user_session_ts') AND type='index'")) + .isEqualTo("0"); + } + + @Test + void connectionLocalMemoryCannotPretendToHaveMigratedADurableSource() { + SQLiteDataSource source = new SQLiteDataSource(); + source.setUrl("jdbc:sqlite::memory:"); + SessionException failure = + assertThrows( + SessionException.class, + () -> new DatabaseSessionService(source).migrate().blockingAwait()); + assertThat(failure).hasCauseThat().isNull(); + } + + @Test + void namedSharedMemoryWorksWithAnApplicationOwnedKeeperConnection() throws Exception { + SQLiteDataSource source = new SQLiteDataSource(); + source.setUrl( + "jdbc:sqlite:file:adk-" + java.util.UUID.randomUUID() + "?mode=memory&cache=shared"); + try (Connection keeper = source.getConnection()) { + DatabaseSessionService service = new DatabaseSessionService(source); + service.migrate().blockingAwait(); + Session session = service.createSession("app", "user").blockingGet(); + assertThat(service.getSession(session.sessionKey(), null).blockingGet()).isNotNull(); + assertThat(keeper.isClosed()).isFalse(); + } + } + + private SQLiteDataSource migrated() { + SQLiteDataSource source = source(directory.resolve("schema.db")); + new DatabaseSessionService(source).migrate().blockingAwait(); + return source; + } + + static SQLiteDataSource source(Path file) { + SQLiteDataSource source = new SQLiteDataSource(); + source.setUrl("jdbc:sqlite:" + file.toAbsolutePath()); + return source; + } + + private static Set tables(SQLiteDataSource source) throws Exception { + Set names = new HashSet<>(); + try (Connection connection = source.getConnection(); + Statement statement = connection.createStatement(); + ResultSet result = + statement.executeQuery("SELECT name FROM sqlite_master WHERE type='table'")) { + while (result.next()) { + names.add(result.getString(1)); + } + } + return names; + } + + private static String scalar(SQLiteDataSource source, String query) throws Exception { + try (Connection connection = source.getConnection(); + Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery(query)) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } + + private static void execute(SQLiteDataSource source, String query) throws Exception { + try (Connection connection = source.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(query); + } + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionContract.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionContract.java new file mode 100644 index 000000000..ea49b40d9 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionContract.java @@ -0,0 +1,924 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import com.google.adk.JsonBaseModel; +import com.google.adk.agents.LlmAgent; +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.adk.models.LlmResponse; +import com.google.adk.runner.Runner; +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import com.google.adk.testing.TestLlm; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.math.BigInteger; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.Instant; +import java.time.InstantSource; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import javax.sql.DataSource; +import org.junit.jupiter.api.DynamicTest; + +/** One backend-parameterized contract; no copies of individual engine test methods. */ +final class DatabaseSessionContract { + private static final GetSessionConfig ALL = GetSessionConfig.builder().build(); + private final DataSource source; + private final Dialect dialect; + + DatabaseSessionContract(DataSource source, Dialect dialect) { + this.source = source; + this.dialect = dialect; + } + + DatabaseSessionService service() { + return DatabaseSessionService.builder(source) + .dialect(dialect) + .instantSource(InstantSource.fixed(Instant.ofEpochMilli(123).plusNanos(456_789))) + .build(); + } + + Stream tests() { + // What this database shares with the in-memory reference, then what is specific to JDBC. + return Stream.concat(new SessionServiceContract(this::service).tests(), databaseTests()); + } + + private Stream databaseTests() { + return Stream.of( + dynamicTest("lifecycle and precise injected time", this::lifecycle), + dynamicTest("duplicate create rolls back shared state", this::duplicate), + dynamicTest("full scope and shared state isolation", this::scope), + dynamicTest("listing orders by update time then ID", this::listOrder), + dynamicTest("shared state update times use the database UTC clock", this::sharedTimes), + dynamicTest("global removal cannot be resurrected", this::sharedRemoval), + dynamicTest("append preserves unrelated and typed live state", this::liveState), + dynamicTest("append order with equal and backwards time", this::order), + dynamicTest("duplicate event ID rolls back all scopes", this::duplicateEvent), + dynamicTest("limit before precise inclusive cursor", this::filters), + dynamicTest("real deletion vs literal sentinel", this::removal), + dynamicTest("temp values reach the supplied session but are never persisted", this::temp), + dynamicTest("entire Java event payload", this::fullEvent), + dynamicTest("invocation IDs and nullable payload column", this::eventColumns), + dynamicTest("partial is a complete no-op", this::partial), + dynamicTest("stale metadata-only snapshots", this::stale), + dynamicTest("filtered snapshots must contain the persisted tail", this::filteredAppend), + dynamicTest("delete and recreate can have an indistinguishable tail", this::recreatedTail), + dynamicTest("append from detached JSON and Java builder copies", this::detachedAppend), + dynamicTest("case accents unicode trailing spaces", this::keys), + dynamicTest("uniform key limits and invalid Unicode", this::invalidKeys), + dynamicTest("large JSON documents", this::largeJson), + dynamicTest( + "JSON values retain signed zero, floating exponent and escaped NUL", this::jsonValues), + dynamicTest("UTC timestamp range and precision", this::timeRange), + dynamicTest("Runner restart persists conversation without cloud LLM", this::runner), + dynamicTest("same-session concurrent append rejects one writer", this::concurrentStale), + dynamicTest("independent shared updates are not lost", this::concurrentShared), + dynamicTest("concurrent first create preserves all shared keys", this::concurrentCreate), + dynamicTest("concurrent duplicate create uses standard error", this::concurrentDuplicate), + dynamicTest("delete/append race has no orphan history", this::concurrentDelete)); + } + + private void lifecycle() { + DatabaseSessionService first = service(); + String app = app(); + Session session = first.createSession(app, "user").blockingGet(); + assertThat(session.id()).isNotEmpty(); + Instant created = Instant.ofEpochMilli(123).plusNanos(456_000); + assertThat(session.lastUpdateTime()).isEqualTo(created); + assertThat(load(service(), session).lastUpdateTime()).isEqualTo(created); + assertThat(load(service(), session).state()).isEmpty(); + assertThat(first.listSessions(app, "user").blockingGet().sessionIds()) + .containsExactly(session.id()); + first.deleteSession(session.sessionKey()).blockingAwait(); + assertThat(first.getSession(session.sessionKey(), ALL).blockingGet()).isNull(); + assertThrows( + SessionNotFoundException.class, + () -> first.deleteSession(session.sessionKey()).blockingAwait()); + assertThrows( + SessionNotFoundException.class, () -> first.listEvents(session.sessionKey()).blockingGet()); + } + + private void duplicate() { + DatabaseSessionService service = service(); + Session original = create(service, app(), "u", "id", Map.of("app:k", "original", "user:k", 2)); + service.appendEvent(original, event("e", 10, Map.of())).blockingGet(); + SessionException error = + assertThrows( + SessionException.class, + () -> + create( + service, + original.appName(), + "u", + "id", + Map.of("app:k", "wrong", "user:k", 99))); + assertThat(error).hasMessageThat().isEqualTo(SessionException.SESSION_ALREADY_EXISTS); + assertThat(error).hasCauseThat().isNull(); + Session reloaded = load(service, original); + assertThat(reloaded.state()).containsExactly("app:k", "original", "user:k", 2); + assertThat(reloaded.immutableEvents()).hasSize(1); + } + + private void scope() { + DatabaseSessionService service = service(); + String app = app(); + Session a = create(service, app, "u", "id", Map.of("app:a", 1, "user:u", 2, "local", 3)); + Session b = create(service, app, "u", "other", Map.of()); + Session c = create(service, app, "other-user", "id", Map.of()); + Session d = create(service, app(), "u", "id", Map.of()); + assertThat(load(service, b).state()).containsExactly("app:a", 1, "user:u", 2); + assertThat(load(service, c).state()).containsExactly("app:a", 1); + assertThat(load(service, d).state()).isEmpty(); + service.deleteSession(a.sessionKey()).blockingAwait(); + assertThat(load(service, b).state()).containsExactly("app:a", 1, "user:u", 2); + } + + private void sharedRemoval() { + DatabaseSessionService service = service(); + String app = app(); + Session old = create(service, app, "u", "old", Map.of("app:k", 1, "user:k", 2)); + Session other = create(service, app, "u", "other", Map.of()); + service + .appendEvent( + other, event("delete", 10, Map.of("app:k", State.REMOVED, "user:k", State.REMOVED))) + .blockingGet(); + service.appendEvent(old, event("local", 20, Map.of("local", true))).blockingGet(); + assertThat(old.state()).containsExactly("app:k", 1, "user:k", 2, "local", true); + assertThat(load(service, old).state()).containsExactly("local", true); + } + + private void listOrder() { + DatabaseSessionService service = service(); + String app = app(); + Session a = create(service, app, "u", "a", Map.of()); + create(service, app, "u", "b", Map.of()); + Session z = create(service, app, "u", "z", Map.of()); + service.appendEvent(z, event("oldest", -1, Map.of())).blockingGet(); + assertThat(service.listSessions(app, "u").blockingGet().sessionIds()) + .containsExactly("z", "a", "b") + .inOrder(); + service.appendEvent(a, event("newest", 1000, Map.of())).blockingGet(); + assertThat(service.listSessions(app, "u").blockingGet().sessionIds()) + .containsExactly("z", "b", "a") + .inOrder(); + } + + private void sharedTimes() throws Exception { + DatabaseSessionService service = service(); + Instant start = Instant.now().minusSeconds(5); + Session session = create(service, app(), "u", "id", Map.of()); + assertSharedTimes(session, start, Instant.now().plusSeconds(5)); + // Reset the shared clocks to prove that append also writes the database time. + try (Connection connection = source.getConnection()) { + for (String table : List.of("app_states", "user_states")) { + try (PreparedStatement update = + connection.prepareStatement( + "UPDATE " + table + " SET update_time=? WHERE app_name=?")) { + DatabaseTime.bind(update, 1, Instant.EPOCH, dialect); + update.setString(2, session.appName()); + assertThat(update.executeUpdate()).isEqualTo(1); + } + } + } + service + .appendEvent(session, event("historic", 1, Map.of("app:key", 1, "user:key", 2))) + .blockingGet(); + assertSharedTimes(session, start, Instant.now().plusSeconds(5)); + assertThat(load(service, session).lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(1)); + } + + private void assertSharedTimes(Session session, Instant start, Instant end) throws Exception { + try (Connection connection = source.getConnection()) { + for (String table : List.of("app_states", "user_states")) { + try (PreparedStatement query = + connection.prepareStatement("SELECT update_time FROM " + table + " WHERE app_name=?")) { + query.setString(1, session.appName()); + try (ResultSet rows = query.executeQuery()) { + assertThat(rows.next()).isTrue(); + Instant time = DatabaseTime.read(rows, "update_time", dialect); + assertThat(time).isAtLeast(start); + assertThat(time).isAtMost(end); + } + } + } + } + } + + private void order() { + DatabaseSessionService service = service(); + Session session = create(service, app(), "u", "id", Map.of()); + int index = 0; + for (long time : new long[] {3000, 1000, 1000, -1}) { + service.appendEvent(session, event("event-" + index++, time, Map.of())).blockingGet(); + } + assertThat(load(service, session).immutableEvents().stream().map(Event::timestamp).toList()) + .containsExactly(3000L, 1000L, 1000L, -1L) + .inOrder(); + assertThat(session.lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(-1)); + assertThat(load(service, session).lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(-1)); + } + + private void duplicateEvent() { + DatabaseSessionService service = service(); + Session session = + create(service, app(), "u", "id", Map.of("app:k", 1, "user:k", 2, "local", 3)); + service.appendEvent(session, event("existing", 1, Map.of())).blockingGet(); + String before = session.toJson(); + SessionException failure = + assertThrows( + SessionException.class, + () -> + service + .appendEvent( + session, + event("existing", 2, Map.of("app:k", 10, "user:k", 20, "local", 30))) + .blockingGet()); + assertThat(failure) + .hasMessageThat() + .isEqualTo("An event with this ID already exists in the session."); + assertThat(session.toJson()).isEqualTo(before); + Session restored = load(service, session); + assertThat(restored.state()).containsExactly("app:k", 1, "user:k", 2, "local", 3); + assertThat(restored.immutableEvents()).hasSize(1); + Session other = create(service, session.appName(), "u", "other", Map.of()); + service.appendEvent(other, event("existing", 3, Map.of())).blockingGet(); + assertThat(load(service, other).immutableEvents()).hasSize(1); + } + + private void liveState() { + DatabaseSessionService service = service(); + Session session = + create( + service, + app(), + "u", + "id", + Map.of("app:gone", 1, "app:pending", 2, "stable", List.of(1))); + Session other = create(service, session.appName(), "u", "other", Map.of()); + Object unchanged = session.state().get("stable"); + Map pendingDelta = EventActions.builder().build().stateDelta(); + // Like an agent Context, this view writes live state before its event is appended. + State pending = new State(session.state(), pendingDelta); + pending.put("new-key", "waiting"); + pending.put("app:pending", "waiting"); + service + .appendEvent( + other, event("other", 1, Map.of("app:gone", State.REMOVED, "app:pending", "database"))) + .blockingGet(); + byte[] bytes = {1, 2, 3}; + Long count = 42L; + service() + .appendEvent(session, event("first", 2, Map.of("bytes", bytes, "count", count))) + .blockingGet(); + service.appendEvent(session, event("unrelated", 3, Map.of())).blockingGet(); + assertThat(session.state().get("stable")).isSameInstanceAs(unchanged); + assertThat(session.state().get("bytes")).isSameInstanceAs(bytes); + assertThat(session.state().get("count")).isSameInstanceAs(count); + assertThat(session.state()).containsEntry("app:gone", 1); + assertThat(session.state()).containsEntry("app:pending", "waiting"); + assertThat(session.state()).containsEntry("new-key", "waiting"); + assertThat(load(service, session).state()).containsEntry("app:pending", "database"); + assertThat(load(service, session).state()).doesNotContainKey("new-key"); + assertThat(load(service, session).state()).doesNotContainKey("app:gone"); + assertThat(load(service, session).state()).containsEntry("bytes", "AQID"); + service.appendEvent(session, event("pending", 4, pendingDelta)).blockingGet(); + assertThat(load(service, session).state()).containsEntry("new-key", "waiting"); + assertThat(load(service, session).state()).containsEntry("app:pending", "waiting"); + } + + private void filters() { + DatabaseSessionService service = service(); + Session session = create(service, app(), "u", "id", Map.of()); + for (long time : new long[] {3000, 1000, 2000}) { + service.appendEvent(session, event("e-" + time, time, Map.of())).blockingGet(); + } + Session filtered = + service + .getSession( + session.sessionKey(), + GetSessionConfig.builder() + .numRecentEvents(2) + .afterTimestamp(Instant.ofEpochMilli(1500)) + .build()) + .blockingGet(); + assertThat(filtered.immutableEvents().stream().map(Event::timestamp).toList()) + .containsExactly(2000L); + assertThat( + service + .getSession( + session.sessionKey(), + GetSessionConfig.builder() + .afterTimestamp(Instant.ofEpochMilli(1000).plusNanos(1)) + .build()) + .blockingGet() + .immutableEvents() + .stream() + .map(Event::timestamp) + .toList()) + .containsExactly(3000L, 2000L) + .inOrder(); + assertThat( + service + .getSession( + session.sessionKey(), GetSessionConfig.builder().numRecentEvents(0).build()) + .blockingGet() + .immutableEvents()) + .isEmpty(); + assertThat( + service + .listSessions(session.appName(), session.userId()) + .blockingGet() + .sessions() + .get(0) + .immutableEvents()) + .isEmpty(); + } + + private void removal() { + DatabaseSessionService service = service(); + Session session = + create( + service, + app(), + "u", + "id", + Map.of("app:k", 1, "user:k", 2, "a.b", 3, "map", Map.of("old", 1))); + Event event = + event( + "remove", + 1, + Map.of( + "app:k", + State.REMOVED, + "user:k", + State.REMOVED, + "a.b", + State.REMOVED, + "map", + Map.of("new", 2), + "literal", + "__ADK_SENTINEL_REMOVED__")); + service.appendEvent(session, event).blockingGet(); + Session reloaded = load(service, session); + assertThat(reloaded.state()) + .containsExactly("map", Map.of("new", 2), "literal", "__ADK_SENTINEL_REMOVED__"); + assertThat(reloaded.immutableEvents().get(0).actions().stateDelta().get("app:k")) + .isSameInstanceAs(State.REMOVED); + assertThat(reloaded.immutableEvents().get(0).actions().stateDelta().get("literal")) + .isEqualTo("__ADK_SENTINEL_REMOVED__"); + } + + private void temp() { + DatabaseSessionService service = service(); + Object initial = new Object(); + Session session = create(service, app(), "u", "id", Map.of("temp:initial", initial)); + assertThat(session.state().get("temp:initial")).isSameInstanceAs(initial); + session.state().put("temp:caller", new Object()); + session.state().put("temp:dropped", new Object()); + // Values that no JSON encoder could write: they are handed over, never inspected. + Object runtime = new Object(); + Event event = + event( + "e", + 1, + Map.of("temp:runtime", runtime, "temp:dropped", State.REMOVED, "app:temp:literal", 2)); + assertThat(service.appendEvent(session, event).blockingGet()).isSameInstanceAs(event); + assertThat(session.state()).containsKey("temp:caller"); + // As with InMemorySessionService, a later step of the same invocation can read the value. + assertThat(session.state().get("temp:runtime")).isSameInstanceAs(runtime); + assertThat(session.state()).doesNotContainKey("temp:dropped"); + assertThat(event.actions().stateDelta().get("temp:runtime")).isSameInstanceAs(runtime); + Session loaded = load(service, session); + assertThat(loaded.state()).containsExactly("app:temp:literal", 2); + assertThat(loaded.immutableEvents().get(0).actions().stateDelta()) + .containsExactly("app:temp:literal", 2); + // A failed append hands over nothing, temp values included. + SessionException duplicate = + assertThrows( + SessionException.class, + () -> + service + .appendEvent(session, event("e", 2, Map.of("temp:rejected", new Object()))) + .blockingGet()); + assertThat(duplicate).hasMessageThat().contains("already exists"); + assertThat(session.state()).doesNotContainKey("temp:rejected"); + } + + private void fullEvent() throws Exception { + DatabaseSessionService service = service(); + Session session = create(service, app(), "u", "id", Map.of()); + Event original = SessionDataCodecTest.fullEvent(); + service.appendEvent(session, original).blockingGet(); + Event reloaded = service().listEvents(session.sessionKey()).blockingGet().events().get(0); + assertThat(JsonBaseModel.getMapper().readTree(reloaded.toJson())) + .isEqualTo(JsonBaseModel.getMapper().readTree(original.toJson())); + assertThat(reloaded.cacheMetadata()).isEqualTo(original.cacheMetadata()); + } + + private void partial() { + DatabaseSessionService service = service(); + Session session = create(service, app(), "u", "id", Map.of("k", 1)); + Event event = + event("partial", 999, Map.of("app:wrong", new Object(), "k", 2)).toBuilder() + .partial(true) + .build(); + assertThat(service.appendEvent(session, event).blockingGet()).isSameInstanceAs(event); + assertThat(session.lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(123).plusNanos(456_000)); + assertThat(session.state()).containsExactly("k", 1); + assertThat(load(service, session).immutableEvents()).isEmpty(); + } + + private void eventColumns() throws Exception { + DatabaseSessionService service = service(); + Session session = create(service, app(), "u", "id", Map.of()); + Event absent = Event.builder().id("absent-invocation").timestamp(1).author("user").build(); + assertThat(absent.invocationId()).isNull(); + String maxInvocation = "😀".repeat(256); + Event max = + event("max-invocation", 2, Map.of()).toBuilder().invocationId(maxInvocation).build(); + service.appendEvent(session, absent).blockingGet(); + service.appendEvent(session, max).blockingGet(); + + Event overlong = + event("overlong", 3, Map.of("app:unexpected", true)).toBuilder() + .invocationId(maxInvocation + "x") + .build(); + SessionException failure = + assertThrows( + SessionException.class, () -> service.appendEvent(session, overlong).blockingGet()); + assertThat(failure).hasMessageThat().contains("at most 256 code points"); + Session restored = load(service, session); + assertThat(restored.state()).isEmpty(); + assertThat(restored.immutableEvents()).hasSize(2); + assertThat(restored.immutableEvents().get(0).invocationId()).isNull(); + assertThat(restored.immutableEvents().get(1).invocationId()).isEqualTo(maxInvocation); + try (Connection connection = source.getConnection(); + PreparedStatement query = + connection.prepareStatement( + "SELECT invocation_id FROM events WHERE app_name=? ORDER BY event_sequence")) { + query.setString(1, session.appName()); + try (ResultSet rows = query.executeQuery()) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getString(1)).isEmpty(); + assertThat(rows.next()).isTrue(); + assertThat(rows.getString(1)).isEqualTo(maxInvocation); + assertThat(rows.next()).isFalse(); + } + try (PreparedStatement update = + connection.prepareStatement("UPDATE events SET event_data=NULL WHERE app_name=?")) { + update.setString(1, session.appName()); + assertThat(update.executeUpdate()).isEqualTo(2); + } + } + SessionException nullPayload = + assertThrows(SessionException.class, () -> load(service, session)); + assertThat(nullPayload).hasMessageThat().isEqualTo("Unable to decode session event."); + assertThat(nullPayload).hasCauseThat().isNull(); + } + + private void stale() { + DatabaseSessionService service = service(); + Session first = create(service, app(), "u", "id", Map.of()); + Session old = + service + .getSession(first.sessionKey(), GetSessionConfig.builder().numRecentEvents(0).build()) + .blockingGet(); + service.appendEvent(first, event("first", 123, Map.of())).blockingGet(); + String before = old.toJson(); + SessionException error = + assertThrows( + SessionException.class, + () -> + service() + .appendEvent(old, event("stale", 123, Map.of("app:wrong", 1))) + .blockingGet()); + assertThat(error).hasMessageThat().contains("stale"); + assertThat(old.toJson()).isEqualTo(before); + assertThat(load(service, first).state()).isEmpty(); + assertThat(load(service, first).immutableEvents()).hasSize(1); + } + + private void filteredAppend() { + DatabaseSessionService service = service(); + Session original = create(service, app(), "u", "id", Map.of()); + service.appendEvent(original, event("newer-time", 20, Map.of())).blockingGet(); + service.appendEvent(original, event("actual-tail", 10, Map.of())).blockingGet(); + Session missingTail = + service + .getSession( + original.sessionKey(), + GetSessionConfig.builder().afterTimestamp(Instant.ofEpochMilli(20)).build()) + .blockingGet(); + assertThat(missingTail.immutableEvents().stream().map(Event::id).toList()) + .containsExactly("newer-time"); + SessionException error = + assertThrows( + SessionException.class, + () -> service.appendEvent(missingTail, event("stale", 10, Map.of())).blockingGet()); + assertThat(error).hasMessageThat().contains("reload"); + Session tail = + service + .getSession( + original.sessionKey(), GetSessionConfig.builder().numRecentEvents(1).build()) + .blockingGet(); + service.appendEvent(tail, event("accepted", 10, Map.of())).blockingGet(); + assertThat(load(service, original).immutableEvents().stream().map(Event::id).toList()) + .containsExactly("newer-time", "actual-tail", "accepted") + .inOrder(); + } + + private void recreatedTail() { + DatabaseSessionService service = service(); + for (boolean reusedId : List.of(false, true)) { + Session old = create(service, app(), "u", "id", Map.of()); + if (reusedId) { + service.appendEvent(old, event("reused", 1, Map.of())).blockingGet(); + } + service.deleteSession(old.sessionKey()).blockingAwait(); + Session recreated = create(service, old.appName(), "u", "id", Map.of()); + if (reusedId) { + service.appendEvent(recreated, event("reused", 2, Map.of())).blockingGet(); + } + // A matching tail alone cannot distinguish these two incarnations. + service.appendEvent(old, event("from-old", 3, Map.of())).blockingGet(); + List times = + load(service, recreated).immutableEvents().stream().map(Event::timestamp).toList(); + assertThat(times) + .containsExactlyElementsIn(reusedId ? List.of(2L, 3L) : List.of(3L)) + .inOrder(); + } + } + + private void detachedAppend() { + DatabaseSessionService service = service(); + Session original = create(service, app(), "u", "id", Map.of()); + Session manual = Session.builder("id").appName(original.appName()).userId("u").build(); + service.appendEvent(manual, event("manual-empty", 1, Map.of())).blockingGet(); + original = load(service, original); + Session copy = Session.fromJson(original.toJson()); + service().appendEvent(copy, event("json", 2, Map.of())).blockingGet(); + // Copy public fields as the Kotlin bridge does; no JDBC-only fields are needed. + Session builderCopy = + Session.builder(copy.id()) + .appName(copy.appName()) + .userId(copy.userId()) + .state(Map.copyOf(copy.state())) + .events(copy.immutableEvents()) + .lastUpdateTime(copy.lastUpdateTime()) + .build(); + service.appendEvent(builderCopy, event("builder", 3, Map.of())).blockingGet(); + Session missingHistory = Session.builder("id").appName(copy.appName()).userId("u").build(); + SessionException error = + assertThrows( + SessionException.class, + () -> + service.appendEvent(missingHistory, event("untracked", 4, Map.of())).blockingGet()); + assertThat(error).hasMessageThat().contains("reload"); + assertThat(load(service, original).immutableEvents().stream().map(Event::id).toList()) + .containsExactly("manual-empty", "json", "builder") + .inOrder(); + } + + private void keys() { + DatabaseSessionService service = service(); + String app = app(); + List keys = List.of("a", "A", "á", "a ", "é", "e\u0301", "雪😀", "😀".repeat(128)); + for (String id : keys) { + Session session = create(service, app, "u", id, Map.of("id", id)); + assertThat(load(service, session).state()).containsExactly("id", id); + } + assertThat(service.listSessions(app, "u").blockingGet().sessionIds()) + .containsExactlyElementsIn(keys); + Session distinctEvents = create(service, app, "u", "events", Map.of()); + for (String id : keys) { + service.appendEvent(distinctEvents, event(id, 1, Map.of())).blockingGet(); + } + assertThat(load(service, distinctEvents).immutableEvents().stream().map(Event::id).toList()) + .containsExactlyElementsIn(keys) + .inOrder(); + Session maximal = + create(service, "😀".repeat(128), "😀".repeat(128), "😀".repeat(128), Map.of()); + service.appendEvent(maximal, event("😀".repeat(128), 1, Map.of())).blockingGet(); + assertThat(load(service, maximal).immutableEvents().get(0).id()).isEqualTo("😀".repeat(128)); + } + + private void invalidKeys() { + DatabaseSessionService service = service(); + for (String invalid : + new String[] {"private-value".repeat(20), "private\0value", "\ud800", "\udc00"}) { + SessionException error = + assertThrows( + SessionException.class, () -> create(service, app(), "u", invalid, Map.of())); + assertThat(error).hasCauseThat().isNull(); + assertThat(error).hasMessageThat().doesNotContain("private"); + } + } + + private void largeJson() { + DatabaseSessionService service = service(); + String payload = "雪😀".repeat(20_000); + BigInteger large = new BigInteger("123456789012345678901234567890"); + Session session = create(service, app(), "u", "id", Map.of("payload", payload, "large", large)); + service.appendEvent(session, event("large", 1, Map.of("app:payload", payload))).blockingGet(); + assertThat(load(service, session).state()) + .containsExactly("payload", payload, "app:payload", payload, "large", large); + } + + private void timeRange() { + Instant earliest = Instant.parse("1000-01-01T00:00:00Z"); + Instant latest = Instant.parse("9999-12-31T23:59:59.999999Z"); + for (Instant created : List.of(earliest, Instant.ofEpochMilli(-1), Instant.EPOCH, latest)) { + DatabaseSessionService service = + DatabaseSessionService.builder(source) + .dialect(dialect) + .instantSource(InstantSource.fixed(created)) + .build(); + Session session = create(service, app(), "u", "id", Map.of()); + assertThat(session.lastUpdateTime()).isEqualTo(created); + long time = created.toEpochMilli(); + service.appendEvent(session, event("extreme", time, Map.of())).blockingGet(); + assertThat(load(service, session).lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(time)); + assertThat(load(service, session).immutableEvents().get(0).timestamp()).isEqualTo(time); + assertThat( + service + .getSession( + session.sessionKey(), + GetSessionConfig.builder().afterTimestamp(Instant.ofEpochMilli(time)).build()) + .blockingGet() + .immutableEvents()) + .hasSize(1); + assertThat( + service + .getSession( + session.sessionKey(), + GetSessionConfig.builder() + .afterTimestamp(Instant.ofEpochMilli(time).plusNanos(1)) + .build()) + .blockingGet() + .immutableEvents()) + .isEmpty(); + } + DatabaseSessionService service = service(); + Session session = create(service, app(), "u", "id", Map.of()); + for (Instant outside : List.of(earliest.minusMillis(1), latest.plusNanos(1_000))) { + DatabaseSessionService invalidClock = + DatabaseSessionService.builder(source) + .dialect(dialect) + .instantSource(InstantSource.fixed(outside)) + .build(); + SessionException createFailure = + assertThrows( + SessionException.class, () -> create(invalidClock, app(), "u", "id", Map.of())); + assertThat(createFailure).hasMessageThat().contains("1000 through 9999"); + SessionException appendFailure = + assertThrows( + SessionException.class, + () -> + service + .appendEvent(session, event("invalid", outside.toEpochMilli(), Map.of())) + .blockingGet()); + assertThat(appendFailure).hasMessageThat().contains("1000 through 9999"); + } + assertThat(load(service, session).immutableEvents()).isEmpty(); + } + + private void jsonValues() { + DatabaseSessionService service = service(); + Map values = + Map.of("negativeZero", -0.0, "exponent", 1e23, "escapedNul", "before\0after"); + Session session = create(service, app(), "u", "id", values); + service.appendEvent(session, event("values", 1, values)).blockingGet(); + Session restored = load(service(), session); + assertThat(restored.state()).containsExactlyEntriesIn(values); + assertThat(restored.immutableEvents().get(0).actions().stateDelta()) + .containsExactlyEntriesIn(values); + } + + private void runner() { + TestLlm llm = + new TestLlm( + List.of( + LlmResponse.builder() + .content( + Content.builder() + .role("model") + .parts(Part.fromText("first answer")) + .build()) + .build(), + LlmResponse.builder() + .content( + Content.builder() + .role("model") + .parts(Part.fromText("second answer")) + .build()) + .build())); + LlmAgent agent = LlmAgent.builder().name("test_agent").model(llm).build(); + DatabaseSessionService first = service(); + Session session = create(first, app(), "u", "id", Map.of("retained", true)); + Runner.builder() + .appName(session.appName()) + .agent(agent) + .sessionService(first) + .build() + .runAsync("u", "id", Content.fromParts(Part.fromText("first question"))) + .toList() + .blockingGet(); + assertThat(load(service(), session).immutableEvents()).hasSize(2); + DatabaseSessionService restarted = service(); + Runner.builder() + .appName(session.appName()) + .agent(agent) + .sessionService(restarted) + .build() + .runAsync("u", "id", Content.fromParts(Part.fromText("second question"))) + .toList() + .blockingGet(); + assertThat(load(service(), session).immutableEvents()).hasSize(4); + assertThat(load(service(), session).state()).containsEntry("retained", true); + assertThat(llm.getRequests()).hasSize(2); + assertThat(llm.getLastRequest().contents()).hasSize(3); + } + + private void concurrentStale() throws Exception { + for (int iteration = 0; iteration < 5; iteration++) { + DatabaseSessionService service = service(); + Session first = create(service, app(), "u", "id", Map.of()); + Session second = load(service(), first); + List outcomes = + together( + () -> appendOutcome(service(), first, "a"), + () -> appendOutcome(service(), second, "b")); + assertThat(outcomes).containsExactly(true, false); + assertThat(load(service, first).immutableEvents()).hasSize(1); + assertThat(first.immutableEvents().size() + second.immutableEvents().size()).isEqualTo(1); + } + } + + private void concurrentShared() throws Exception { + for (int iteration = 0; iteration < 5; iteration++) { + DatabaseSessionService service = service(); + String app = app(); + Session a = create(service, app, "u", "a", Map.of()); + Session b = create(service, app, "u", "b", Map.of()); + together( + () -> + service() + .appendEvent(a, event("a", 1, Map.of("app:a", 1, "user:a", 2))) + .blockingGet(), + () -> + service() + .appendEvent(b, event("b", 1, Map.of("app:b", 3, "user:b", 4))) + .blockingGet()); + assertThat(load(service, a).state()) + .containsExactly("app:a", 1, "user:a", 2, "app:b", 3, "user:b", 4); + } + } + + private void concurrentCreate() throws Exception { + for (int iteration = 0; iteration < 5; iteration++) { + String app = app(); + List sessions = + together( + () -> create(service(), app, "u", "a", Map.of("app:a", 1, "user:a", 2)), + () -> create(service(), app, "u", "b", Map.of("app:b", 3, "user:b", 4))); + assertThat(load(service(), sessions.get(0)).state()) + .containsExactly("app:a", 1, "user:a", 2, "app:b", 3, "user:b", 4); + } + } + + private void concurrentDelete() throws Exception { + DatabaseSessionService service = service(); + Session session = create(service, app(), "u", "id", Map.of()); + together( + () -> { + service().deleteSession(session.sessionKey()).blockingAwait(); + return true; + }, + () -> { + try { + service().appendEvent(session, event("race", 1, Map.of())).blockingGet(); + } catch (SessionNotFoundException expected) { + assertThat(session.immutableEvents()).isEmpty(); + } + return true; + }); + assertThat(service.getSession(session.sessionKey(), ALL).blockingGet()).isNull(); + Session recreated = create(service, session.appName(), "u", "id", Map.of()); + assertThat(load(service, recreated).immutableEvents()).isEmpty(); + } + + private void concurrentDuplicate() throws Exception { + for (int iteration = 0; iteration < 5; iteration++) { + String app = app(); + List outcomes = together(() -> createOutcome(app), () -> createOutcome(app)); + assertThat(outcomes).containsExactly(true, false); + assertThat(service().listSessions(app, "u").blockingGet().sessions()).hasSize(1); + } + } + + private boolean createOutcome(String app) { + try { + create(service(), app, "u", "same-id", Map.of()); + return true; + } catch (SessionException duplicate) { + assertThat(duplicate).hasMessageThat().isEqualTo(SessionException.SESSION_ALREADY_EXISTS); + return false; + } + } + + private static boolean appendOutcome(DatabaseSessionService service, Session session, String id) { + try { + service.appendEvent(session, event(id, 123, Map.of("local", id))).blockingGet(); + return true; + } catch (SessionException stale) { + assertThat(stale).hasMessageThat().contains("stale"); + return false; + } + } + + static List together(Callable first, Callable second) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future a = + executor.submit( + () -> { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) + throw new AssertionError("Start barrier timed out"); + return first.call(); + }); + Future b = + executor.submit( + () -> { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) + throw new AssertionError("Start barrier timed out"); + return second.call(); + }); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + return List.of(a.get(30, TimeUnit.SECONDS), b.get(30, TimeUnit.SECONDS)); + } finally { + start.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + } + } + + private static String app() { + return "test-" + UUID.randomUUID(); + } + + private static Session create( + DatabaseSessionService service, + String app, + String user, + String id, + Map state) { + return service.createSession(app, user, state, id).blockingGet(); + } + + private static Session load(DatabaseSessionService service, Session session) { + return service.getSession(session.sessionKey(), ALL).blockingGet(); + } + + static Event event(String id, long timestamp, Map delta) { + return Event.builder() + .id(id) + .invocationId("invocation") + .author("agent") + .timestamp(timestamp) + .actions(EventActions.builder().stateDelta(delta).build()) + .build(); + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionJdbcContract.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionJdbcContract.java new file mode 100644 index 000000000..3083d931a --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionJdbcContract.java @@ -0,0 +1,586 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import com.google.adk.events.Event; +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import javax.sql.DataSource; +import org.junit.jupiter.api.DynamicTest; + +/** Same transactional failure and query-shape checks on every actual JDBC engine. */ +final class DatabaseSessionJdbcContract { + private final DataSource source; + private final Dialect dialect; + + DatabaseSessionJdbcContract(DataSource source, Dialect dialect) { + this.source = source; + this.dialect = dialect; + } + + Stream tests() { + Stream common = + Stream.of( + dynamicTest("cold API and injected providers", this::cold), + dynamicTest("SQL and pre-commit failure rolls back everything", this::rollback), + dynamicTest("uncertain commit is never blindly retried", this::uncertainCommit), + dynamicTest( + "confirmed commit is distinguished from cleanup failure", + this::confirmedCommitCleanup), + dynamicTest( + "publication preserves unrelated writes during commit", + this::liveWriteDuringCommit), + dynamicTest("local append takes no shared-row write lock", this::localQueries), + dynamicTest( + "append without a local delta leaves the state document untouched", + this::untouchedLocalState), + dynamicTest("listing has no N+1 queries", this::listing), + dynamicTest("a conflict reported by the database is safe to repeat", this::conflict), + dynamicTest( + "the configured timeout reaches every prepared statement", this::statementTimeout), + dynamicTest("state and history share a consistent read snapshot", this::snapshot), + dynamicTest("connections restored on reads writes and errors", this::connectionState)); + if (dialect == Dialect.SQLITE) { + return common; + } + Stream server = + Stream.concat( + common, + Stream.of( + dynamicTest( + "unrelated app row lock does not block local append", this::unrelatedLock))); + return dialect == Dialect.MYSQL || dialect == Dialect.MARIADB + ? Stream.concat( + server, + Stream.of( + dynamicTest("existing shared rows do not serialize create", this::createLock))) + : server; + } + + private DatabaseSessionService service(DataSource source) { + return DatabaseSessionService.builder(source).dialect(dialect).build(); + } + + private Session create(DatabaseSessionService service) { + return service + .createSession( + "jdbc-" + UUID.randomUUID(), "u", Map.of("app:original", 1, "user:original", 2), "id") + .blockingGet(); + } + + private Session load(Session session) { + return service(source).getSession(session.sessionKey(), null).blockingGet(); + } + + private void cold() { + JdbcProbe probe = new JdbcProbe(source, dialect); + AtomicInteger ids = new AtomicInteger(); + AtomicInteger times = new AtomicInteger(); + DatabaseSessionService service = + DatabaseSessionService.builder(probe) + .dialect(dialect) + .uuidProvider(() -> "identity-" + ids.incrementAndGet()) + .instantSource( + () -> { + times.incrementAndGet(); + return Instant.ofEpochMilli(123); + }) + .build(); + String app = "cold-" + UUID.randomUUID(); + var create = service.createSession(app, "u", Map.of(), "id"); + var get = service.getSession(app, "u", "id", Optional.empty()); + var list = service.listSessions(app, "u"); + var events = service.listEvents(app, "u", "id"); + var delete = service.deleteSession(app, "u", "id"); + var invalid = service.appendEvent(null, null); + assertThat(probe.borrowed.get()).isEqualTo(0); + assertThat(ids.get()).isEqualTo(0); + assertThat(times.get()).isEqualTo(0); + create.blockingGet(); + get.blockingGet(); + list.blockingGet(); + events.blockingGet(); + delete.blockingAwait(); + assertThrows(SessionException.class, invalid::blockingGet); + assertThat(ids.get()).isEqualTo(0); + assertThat(times.get()).isEqualTo(1); + assertThat(probe.closed.get()).isEqualTo(probe.borrowed.get()); + } + + private void rollback() { + for (String point : List.of("UPDATE sessions", "INSERT INTO events", "COMMIT")) { + Session session = create(service(source)); + String before = session.toJson(); + JdbcProbe probe = new JdbcProbe(source, dialect); + AtomicBoolean armed = new AtomicBoolean(true); + probe.hook = + (sql, after) -> { + if (!after && sql.startsWith(point) && armed.compareAndSet(true, false)) + throw new SQLException("private-value"); + }; + SessionException failure = + assertThrows( + SessionException.class, + () -> + service(probe) + .appendEvent( + session, + DatabaseSessionContract.event( + "failure", 1, Map.of("app:wrong", 3, "user:wrong", 4, "local", 5))) + .blockingGet()); + assertThat(failure).hasCauseThat().isNull(); + assertThat(failure.getSuppressed()).isEmpty(); + assertThat(failure).hasMessageThat().doesNotContain("private"); + assertThat(session.toJson()).isEqualTo(before); + Session loaded = load(session); + assertThat(loaded.immutableEvents()).isEmpty(); + assertThat(loaded.state()).containsExactly("app:original", 1, "user:original", 2); + assertThat(probe.borrowed.get()).isEqualTo(probe.closed.get()); + } + } + + private void uncertainCommit() { + Session session = create(service(source)); + String before = session.toJson(); + JdbcProbe probe = new JdbcProbe(source, dialect); + AtomicBoolean armed = new AtomicBoolean(true); + probe.hook = + (sql, after) -> { + if (after && sql.equals("COMMIT") && armed.compareAndSet(true, false)) + throw new SQLException("private-value after server commit"); + }; + SessionException uncertain = + assertThrows( + SessionException.class, + () -> + service(probe) + .appendEvent( + session, + DatabaseSessionContract.event("committed", 1, Map.of("local", true))) + .blockingGet()); + assertThat(uncertain).hasMessageThat().contains("may have committed"); + assertThat(uncertain).hasMessageThat().doesNotContain("private-value"); + assertThat(uncertain).hasCauseThat().isNull(); + assertThat(probe.sql.stream().filter(sql -> sql.equals("COMMIT")).count()).isEqualTo(1); + assertThat(session.toJson()).isEqualTo(before); + assertThat(load(session).immutableEvents()).hasSize(1); + assertThrows( + SessionException.class, + () -> + service(source) + .appendEvent(session, DatabaseSessionContract.event("unsafe-retry", 2, Map.of())) + .blockingGet()); + assertThat(load(session).immutableEvents()).hasSize(1); + assertThat(probe.borrowed.get()).isEqualTo(probe.closed.get()); + } + + private void localQueries() { + Session session = create(service(source)); + JdbcProbe probe = new JdbcProbe(source, dialect); + service(probe) + .appendEvent(session, DatabaseSessionContract.event("local", 1, Map.of("local", 1))) + .blockingGet(); + assertThat( + probe.sql.stream() + .noneMatch( + sql -> + (sql.contains("app_states") || sql.contains("user_states")) + && (sql.startsWith("UPDATE") + || sql.startsWith("INSERT") + || sql.contains("FOR UPDATE")))) + .isTrue(); + assertThat(probe.sql.stream().filter(sql -> sql.startsWith("SELECT s.id")).count()) + .isEqualTo(0); + assertThat(probe.borrowed.get()).isEqualTo(probe.closed.get()); + } + + private void untouchedLocalState() throws Exception { + Session session = create(service(source)); + // Valid JSON that any decode/encode cycle would rewrite in a normalized form. + String stored = "{ \"kept\" : 1.50 }"; + try (Connection connection = source.getConnection(); + var update = connection.prepareStatement("UPDATE sessions SET state=? WHERE app_name=?")) { + DatabaseDialect.bindJson(update, 1, stored, dialect); + update.setString(2, session.appName()); + assertThat(update.executeUpdate()).isEqualTo(1); + } + JdbcProbe probe = new JdbcProbe(source, dialect); + service(probe) + .appendEvent( + session, DatabaseSessionContract.event("shared-only", 5, Map.of("app:shared", 1))) + .blockingGet(); + assertThat( + probe.sql.stream() + .noneMatch(sql -> sql.contains(" sessions ") && sql.contains("state"))) + .isTrue(); + assertThat(storedState(session)).isEqualTo(stored); + assertThat(load(session).lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(5)); + assertThat(load(session).state()).containsEntry("app:shared", 1); + service(source) + .appendEvent(session, DatabaseSessionContract.event("local", 6, Map.of("local", 1))) + .blockingGet(); + assertThat(load(session).state()).containsAtLeast("kept", 1.5, "local", 1); + assertThat(storedState(session)).isNotEqualTo(stored); + assertThat(probe.borrowed.get()).isEqualTo(probe.closed.get()); + } + + private String storedState(Session session) throws Exception { + try (Connection connection = source.getConnection(); + var query = connection.prepareStatement("SELECT state FROM sessions WHERE app_name=?")) { + query.setString(1, session.appName()); + try (var rows = query.executeQuery()) { + assertThat(rows.next()).isTrue(); + return rows.getString(1); + } + } + } + + private void conflict() { + Session session = create(service(source)); + String before = session.toJson(); + Event event = DatabaseSessionContract.event("victim", 1, Map.of("app:k", 3, "local", 5)); + JdbcProbe probe = new JdbcProbe(source, dialect); + AtomicBoolean armed = new AtomicBoolean(true); + probe.hook = + (sql, after) -> { + if (!after && sql.startsWith("INSERT INTO events") && armed.compareAndSet(true, false)) + throw new SQLException("private-deadlock", "40001", 1213); + }; + SessionException failure = + assertThrows( + SessionException.class, () -> service(probe).appendEvent(session, event).blockingGet()); + assertThat(failure).hasMessageThat().isEqualTo(DatabaseSessionStore.CONFLICT); + assertThat(failure).hasCauseThat().isNull(); + assertThat(failure.getSuppressed()).isEmpty(); + assertThat(session.toJson()).isEqualTo(before); + assertThat(load(session).immutableEvents()).isEmpty(); + assertThat(load(session).state()).doesNotContainKey("app:k"); + + // The very same call, repeated without reloading anything. + service(probe).appendEvent(session, event).blockingGet(); + assertThat(load(session).immutableEvents().stream().map(Event::id).toList()) + .containsExactly("victim"); + assertThat(load(session).state()).containsAtLeast("app:k", 3, "local", 5); + + // The same code from the commit itself says nothing about whether the write was applied. + armed.set(true); + probe.hook = + (sql, after) -> { + if (after && sql.equals("COMMIT") && armed.compareAndSet(true, false)) + throw new SQLException("private-deadlock", "40001", 1213); + }; + SessionException uncertain = + assertThrows( + SessionException.class, + () -> + service(probe) + .appendEvent(session, DatabaseSessionContract.event("unknown", 2, Map.of())) + .blockingGet()); + assertThat(uncertain).hasMessageThat().contains("may have committed"); + assertThat(probe.borrowed.get()).isEqualTo(probe.closed.get()); + } + + private void statementTimeout() { + JdbcProbe configured = new JdbcProbe(source, dialect); + DatabaseSessionService service = + DatabaseSessionService.builder(configured) + .dialect(dialect) + .statementTimeout(Duration.ofSeconds(7)) + .build(); + Session session = create(service); + service + .appendEvent( + session, DatabaseSessionContract.event("timed", 1, Map.of("app:k", 1, "local", 2))) + .blockingGet(); + service.getSession(session.sessionKey(), null).blockingGet(); + service.listSessions(session.appName(), "u").blockingGet(); + service.listEvents(session.sessionKey()).blockingGet(); + service.deleteSession(session.sessionKey()).blockingAwait(); + assertThat(configured.timeouts.stream().distinct().toList()).containsExactly(7); + if (dialect == Dialect.SQLITE) { + assertThat(configured.sql).contains("PRAGMA busy_timeout=7000"); + } + + JdbcProbe standard = new JdbcProbe(source, dialect); + create(service(standard)); + assertThat(standard.timeouts.stream().distinct().toList()).containsExactly(15); + if (dialect == Dialect.SQLITE) { + assertThat(standard.sql).contains("PRAGMA busy_timeout=15000"); + } + assertThat(configured.borrowed.get()).isEqualTo(configured.closed.get()); + assertThat(standard.borrowed.get()).isEqualTo(standard.closed.get()); + } + + private void confirmedCommitCleanup() { + List faultPoints = + dialect == Dialect.SQLITE + ? List.of("CLOSE CONNECTION", "CLOSE COMMIT STATEMENT") + : List.of("CLOSE CONNECTION"); + for (String faultPoint : faultPoints) { + Session session = create(service(source)); + String before = session.toJson(); + JdbcProbe probe = new JdbcProbe(source, dialect); + probe.hook = + (sql, after) -> { + if (after && sql.equals(faultPoint)) { + throw new SQLException("private-confirmed-cleanup"); + } + }; + SessionException failure = + assertThrows( + SessionException.class, + () -> + service(probe) + .appendEvent( + session, + DatabaseSessionContract.event("acknowledged", 1, Map.of("local", true))) + .blockingGet()); + assertThat(failure) + .hasMessageThat() + .isEqualTo("The database write committed, but cleanup failed; reload the session."); + assertThat(failure).hasCauseThat().isNull(); + assertThat(failure.getSuppressed()).isEmpty(); + assertThat(probe.sql).doesNotContain("ROLLBACK"); + assertThat(session.toJson()).isEqualTo(before); + Session reloaded = load(session); + assertThat(reloaded.state()).containsEntry("local", true); + assertThat(reloaded.immutableEvents().stream().map(Event::id).toList()) + .containsExactly("acknowledged"); + assertThat(probe.borrowed.get()).isEqualTo(probe.closed.get()); + } + } + + private void liveWriteDuringCommit() throws Exception { + Session session = + service(source) + .createSession( + "publish-" + UUID.randomUUID(), "u", Map.of("unrelated", Map.of("k", 1)), "id") + .blockingGet(); + var executor = Executors.newSingleThreadExecutor(); + CountDownLatch committed = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + JdbcProbe probe = new JdbcProbe(source, dialect); + probe.hook = + (sql, after) -> { + if (after && sql.equals("COMMIT")) { + committed.countDown(); + if (!release.await(20, TimeUnit.SECONDS)) { + throw new AssertionError("Publication barrier timed out"); + } + } + }; + try { + var append = + executor.submit( + () -> + service(probe) + .appendEvent( + session, + DatabaseSessionContract.event("stored", 1, Map.of("value", "new"))) + .blockingGet()); + assertThat(committed.await(20, TimeUnit.SECONDS)).isTrue(); + // An equal-but-distinct assignment outside the event delta is not replaced on publication. + Map pending = new HashMap<>(Map.of("k", 1)); + session.state().put("unrelated", pending); + release.countDown(); + append.get(20, TimeUnit.SECONDS); + assertThat(session.state().get("unrelated")).isSameInstanceAs(pending); + assertThat(session.state()).containsEntry("value", "new"); + assertThat(load(session).state()).containsEntry("value", "new"); + } finally { + release.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(20, TimeUnit.SECONDS)).isTrue(); + } + } + + private void listing() { + Session session = create(service(source)); + JdbcProbe probe = new JdbcProbe(source, dialect); + DatabaseSessionService service = service(probe); + service.listSessions(session.appName(), "u").blockingGet(); + long one = probe.sql.stream().filter(sql -> sql.startsWith("SELECT")).count(); + for (int i = 0; i < 12; i++) + service(source).createSession(session.appName(), "u", Map.of(), "other-" + i).blockingGet(); + probe.sql.clear(); + assertThat(service.listSessions(session.appName(), "u").blockingGet().sessions()).hasSize(13); + assertThat(probe.sql.stream().filter(sql -> sql.startsWith("SELECT")).count()).isEqualTo(one); + assertThat(probe.sql.stream().noneMatch(sql -> sql.contains("FROM events"))).isTrue(); + assertThat(probe.borrowed.get()).isEqualTo(probe.closed.get()); + } + + private void connectionState() { + JdbcProbe probe = new JdbcProbe(source, dialect); + DatabaseSessionService service = service(probe); + Session session = create(service); + service + .appendEvent(session, DatabaseSessionContract.event("event", 1, Map.of("app:k", 1))) + .blockingGet(); + service.getSession(session.sessionKey(), null).blockingGet(); + service.listSessions(session.appName(), "u").blockingGet(); + service.listEvents(session.sessionKey()).blockingGet(); + service.deleteSession(session.sessionKey()).blockingAwait(); + assertThrows( + SessionNotFoundException.class, + () -> service.deleteSession(session.sessionKey()).blockingAwait()); + assertThat(probe.borrowed.get()).isEqualTo(7); + assertThat(probe.closed.get()).isEqualTo(7); + } + + private void snapshot() throws Exception { + String oldJournal = null; + if (dialect == Dialect.SQLITE) { + oldJournal = journal(null); + journal("wal"); + } + var executor = Executors.newFixedThreadPool(2); + CountDownLatch viewed = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch committed = new CountDownLatch(1); + try { + Session session = + service(source) + .createSession("snapshot-" + UUID.randomUUID(), "u", Map.of("version", 0), "id") + .blockingGet(); + JdbcProbe reader = new JdbcProbe(source, dialect); + reader.hook = + (sql, after) -> { + if (after && sql.startsWith("SELECT s.id")) { + viewed.countDown(); + if (!release.await(20, TimeUnit.SECONDS)) + throw new AssertionError("Read barrier timed out"); + } + }; + var reading = + executor.submit( + () -> service(reader).getSession(session.sessionKey(), null).blockingGet()); + assertThat(viewed.await(10, TimeUnit.SECONDS)).isTrue(); + JdbcProbe writer = new JdbcProbe(source, dialect); + writer.hook = + (sql, after) -> { + if (after && sql.equals("COMMIT")) committed.countDown(); + }; + var writing = + executor.submit( + () -> + service(writer) + .appendEvent( + session, + DatabaseSessionContract.event("concurrent", 1, Map.of("version", 1))) + .blockingGet()); + assertThat(committed.await(20, TimeUnit.SECONDS)).isTrue(); + release.countDown(); + Session snapshot = reading.get(20, TimeUnit.SECONDS); + writing.get(20, TimeUnit.SECONDS); + assertThat(snapshot.state()).containsExactly("version", 0); + assertThat(snapshot.immutableEvents()).isEmpty(); + assertThat(load(session).state()).containsExactly("version", 1); + assertThat(load(session).immutableEvents()).hasSize(1); + assertThat(reader.borrowed.get()).isEqualTo(reader.closed.get()); + assertThat(writer.borrowed.get()).isEqualTo(writer.closed.get()); + } finally { + release.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(20, TimeUnit.SECONDS)).isTrue(); + if (oldJournal != null) journal(oldJournal); + } + } + + private String journal(String mode) throws Exception { + try (Connection connection = source.getConnection(); + var statement = connection.createStatement(); + var result = + statement.executeQuery( + mode == null ? "PRAGMA journal_mode" : "PRAGMA journal_mode=" + mode)) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } + + private void unrelatedLock() throws Exception { + Session session = create(service(source)); + whileAppRowLocked( + session, + () -> + service(source) + .appendEvent(session, DatabaseSessionContract.event("local", 1, Map.of("local", 1))) + .blockingGet()); + } + + private void createLock() throws Exception { + DatabaseSessionService service = service(source); + Session session = create(service); + whileAppRowLocked( + session, + () -> { + Session created = + service.createSession(session.appName(), "u", Map.of(), "another").blockingGet(); + assertThat(created.state()).containsEntry("app:original", 1); + SessionException duplicate = + assertThrows( + SessionException.class, + () -> + service + .createSession( + session.appName(), "u", Map.of("app:wrong", 2), session.id()) + .blockingGet()); + assertThat(duplicate).hasMessageThat().isEqualTo(SessionException.SESSION_ALREADY_EXISTS); + return created; + }); + } + + private void whileAppRowLocked(Session session, Callable work) throws Exception { + var executor = Executors.newSingleThreadExecutor(); + try (Connection blocker = source.getConnection()) { + blocker.setAutoCommit(false); + try (var update = + blocker.prepareStatement( + "UPDATE app_states SET update_time=update_time WHERE app_name=?")) { + update.setString(1, session.appName()); + update.executeUpdate(); + } + try { + // A deadline for proving lock independence, not a latency/performance benchmark. + executor.submit(work).get(5, TimeUnit.SECONDS); + } finally { + blocker.rollback(); + blocker.setAutoCommit(true); + } + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(20, TimeUnit.SECONDS)).isTrue(); + } + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceConfigurationTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceConfigurationTest.java new file mode 100644 index 000000000..8b63d76e6 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceConfigurationTest.java @@ -0,0 +1,258 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.google.adk.platform.UuidProvider; +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.time.Duration; +import java.time.InstantSource; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; + +/** Verifies configuration and borrowed-connection ownership before persistence is wired in. */ +final class DatabaseSessionServiceConfigurationTest { + @Test + void constructorDoesNotBorrowAConnectionOrMigrate() { + DataSource dataSource = mock(DataSource.class); + + DatabaseSessionService service = new DatabaseSessionService(dataSource); + + assertThat(service.dataSource()).isSameInstanceAs(dataSource); + assertThat(service.autoMigrate()).isFalse(); + assertThat(service.instantSource()).isNotNull(); + assertThat(service.uuidProvider()).isSameInstanceAs(UuidProvider.SYSTEM); + assertThat(service.statementTimeout()).isEqualTo(Duration.ofSeconds(15)); + verifyNoInteractions(dataSource); + } + + @Test + void statementTimeoutIsWholeSecondsWithinWhatEveryDialectCanExpress() { + DataSource dataSource = mock(DataSource.class); + DatabaseSessionService.Builder builder = DatabaseSessionService.builder(dataSource); + + assertThat(builder.statementTimeout(Duration.ofSeconds(1)).build().statementTimeout()) + .isEqualTo(Duration.ofSeconds(1)); + assertThat(builder.statementTimeout(Duration.ofMinutes(2)).build().statementTimeout()) + .isEqualTo(Duration.ofSeconds(120)); + assertThat(builder.statementTimeout(Duration.ofSeconds(2_147_483)).build().statementTimeout()) + .isEqualTo(Duration.ofSeconds(2_147_483)); + for (Duration invalid : + new Duration[] { + null, + Duration.ZERO, + Duration.ofSeconds(-1), + Duration.ofMillis(999), + Duration.ofMillis(1500), + Duration.ofSeconds(2_147_484) + }) { + SessionException error = + assertThrows(SessionException.class, () -> builder.statementTimeout(invalid)); + assertThat(error).hasMessageThat().contains("statementTimeout"); + } + // A rejected value leaves the last accepted one in place. + assertThat(builder.build().statementTimeout()).isEqualTo(Duration.ofSeconds(2_147_483)); + verifyNoInteractions(dataSource); + } + + @Test + void explicitDialectDoesNotInspectTheDataSource() { + DataSource dataSource = mock(DataSource.class); + + DatabaseSessionService service = + DatabaseSessionService.builder(dataSource).dialect(Dialect.MARIADB).build(); + + assertThat(service.resolveDialect()).isEqualTo(Dialect.MARIADB); + verifyNoInteractions(dataSource); + } + + @Test + void autoMigrationIsOnlyAnOptInSettingAtConstructionTime() { + DataSource dataSource = mock(DataSource.class); + + DatabaseSessionService service = + DatabaseSessionService.builder(dataSource).autoMigrate(true).build(); + + assertThat(service.autoMigrate()).isTrue(); + verifyNoInteractions(dataSource); + } + + @Test + void providersAreRetainedButNotInvokedDuringConstruction() { + DataSource dataSource = mock(DataSource.class); + InstantSource clock = mock(InstantSource.class); + UuidProvider identifiers = mock(UuidProvider.class); + + DatabaseSessionService service = + DatabaseSessionService.builder(dataSource) + .instantSource(clock) + .uuidProvider(identifiers) + .build(); + + assertThat(service.instantSource()).isSameInstanceAs(clock); + assertThat(service.uuidProvider()).isSameInstanceAs(identifiers); + verifyNoInteractions(dataSource, clock, identifiers); + } + + @Test + void reusingABuilderDoesNotMutatePreviouslyBuiltServices() { + DataSource dataSource = mock(DataSource.class); + InstantSource firstClock = mock(InstantSource.class); + InstantSource secondClock = mock(InstantSource.class); + UuidProvider firstIds = mock(UuidProvider.class); + UuidProvider secondIds = mock(UuidProvider.class); + DatabaseSessionService.Builder builder = + DatabaseSessionService.builder(dataSource) + .dialect(Dialect.SQLITE) + .instantSource(firstClock) + .uuidProvider(firstIds); + DatabaseSessionService first = builder.build(); + + DatabaseSessionService second = + builder + .dialect(Dialect.POSTGRESQL) + .autoMigrate(true) + .instantSource(secondClock) + .uuidProvider(secondIds) + .build(); + + assertThat(first.resolveDialect()).isEqualTo(Dialect.SQLITE); + assertThat(first.autoMigrate()).isFalse(); + assertThat(first.instantSource()).isSameInstanceAs(firstClock); + assertThat(first.uuidProvider()).isSameInstanceAs(firstIds); + assertThat(second.resolveDialect()).isEqualTo(Dialect.POSTGRESQL); + assertThat(second.autoMigrate()).isTrue(); + assertThat(second.instantSource()).isSameInstanceAs(secondClock); + assertThat(second.uuidProvider()).isSameInstanceAs(secondIds); + verifyNoInteractions(dataSource, firstClock, secondClock, firstIds, secondIds); + } + + @Test + void nullDataSourceIsRejectedByBothEntryPoints() { + assertThrows(SessionException.class, () -> new DatabaseSessionService((DataSource) null)); + assertThrows(SessionException.class, () -> DatabaseSessionService.builder(null)); + } + + @Test + void nullBuilderComponentsAreRejected() { + DatabaseSessionService.Builder builder = DatabaseSessionService.builder(mock(DataSource.class)); + + assertThrows(SessionException.class, () -> builder.dialect(null)); + assertThrows(SessionException.class, () -> builder.instantSource(null)); + assertThrows(SessionException.class, () -> builder.uuidProvider(null)); + } + + @Test + void defaultDialectDetectionIsLazyAndCachedPerService() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()).thenReturn("PostgreSQL"); + DatabaseSessionService service = new DatabaseSessionService(dataSource); + verifyNoInteractions(dataSource); + + assertThat(service.resolveDialect()).isEqualTo(Dialect.POSTGRESQL); + assertThat(service.resolveDialect()).isEqualTo(Dialect.POSTGRESQL); + + verify(dataSource).getConnection(); + verify(connection).close(); + } + + @Test + void failedDetectionDoesNotPoisonTheServicePermanently() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()) + .thenThrow(new SQLException("private driver details")) + .thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()).thenReturn("MySQL"); + DatabaseSessionService service = new DatabaseSessionService(dataSource); + + assertThrows(SessionException.class, service::resolveDialect); + assertThat(service.resolveDialect()).isEqualTo(Dialect.MYSQL); + assertThat(service.resolveDialect()).isEqualTo(Dialect.MYSQL); + + verify(dataSource, times(2)).getConnection(); + verify(connection).close(); + } + + @Test + void dialectIsNotCachedWhenReturningTheConnectionFails() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection failedConnection = mock(Connection.class); + Connection recoveredConnection = mock(Connection.class); + DatabaseMetaData firstMetadata = mock(DatabaseMetaData.class); + DatabaseMetaData recoveredMetadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(failedConnection, recoveredConnection); + when(failedConnection.getMetaData()).thenReturn(firstMetadata); + when(recoveredConnection.getMetaData()).thenReturn(recoveredMetadata); + when(firstMetadata.getDatabaseProductName()).thenReturn("SQLite"); + when(recoveredMetadata.getDatabaseProductName()).thenReturn("PostgreSQL"); + doThrow(new SQLException("private cleanup details")).when(failedConnection).close(); + DatabaseSessionService service = new DatabaseSessionService(dataSource); + + assertThrows(SessionException.class, service::resolveDialect); + assertThat(service.resolveDialect()).isEqualTo(Dialect.POSTGRESQL); + assertThat(service.resolveDialect()).isEqualTo(Dialect.POSTGRESQL); + + verify(dataSource, times(2)).getConnection(); + verify(failedConnection).close(); + verify(recoveredConnection).close(); + } + + @Test + void concurrentResolutionBorrowsOnlyOneConnection() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()).thenReturn("SQLite"); + DatabaseSessionService service = new DatabaseSessionService(dataSource); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future first = workers.submit(service::resolveDialect); + Future second = workers.submit(service::resolveDialect); + + assertThat(first.get(10, TimeUnit.SECONDS)).isEqualTo(Dialect.SQLITE); + assertThat(second.get(10, TimeUnit.SECONDS)).isEqualTo(Dialect.SQLITE); + verify(dataSource).getConnection(); + verify(connection).close(); + } finally { + workers.shutdownNow(); + assertThat(workers.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + } + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceIT.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceIT.java new file mode 100644 index 000000000..6bd795c78 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceIT.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.sql.Connection; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DynamicContainer; +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.TestInstance; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.mariadb.MariaDBContainer; +import org.testcontainers.mysql.MySQLContainer; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** Opt-in real-engine tests. Missing Docker/images fail; nothing is silently skipped. */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +final class DatabaseSessionServiceIT { + private static final String POSTGRESQL = + "postgres:17.10-bookworm@sha256:9b18b78397054fce88a9552e9d5a3ad5bb7fd258c5b3cc1c5028e46373d6ea8f"; + private static final String POSTGRESQL18 = + "postgres:18.4-bookworm@sha256:882236b897e39051d2368c5ccc6cda944904723506b2dfc97f2a8f5bc9afa382"; + private static final String MYSQL = + "mysql:8.4.11@sha256:0f51e63e537a1f176423fa03ef559aabe6fa4372a53160a44bc7e75da7d15364"; + private static final String MARIADB = + "mariadb:11.8.7@sha256:78185355dd49b54dd6909072531ce8d7e06aa0eccd7aa5b23c93ebb7e34c5aaa"; + private static final String MARIADB114 = + "mariadb:11.4.12@sha256:4f1d8d202fcf7bcb3902f63af09f9c1a050c2922a89652f22abaec0d4f015e83"; + private final List fixtures = new ArrayList<>(); + + @TestFactory + Stream databaseContracts() { + String configured = System.getProperty("adk.test.backends", "postgresql,mysql,mariadb"); + if (configured.isBlank()) + throw new IllegalArgumentException("At least one test backend is required."); + return Arrays.stream(configured.split(",")) + .map(String::strip) + .distinct() + .map( + backend -> { + Fixture fixture = start(backend); + Stream checks = + Stream.concat( + Stream.of( + dynamicTest( + "schema migration repeatability and actual driver", + () -> { + DatabaseSessionService service = + new DatabaseSessionService(fixture.source); + assertThat(service.resolveDialect()).isEqualTo(fixture.dialect); + service.migrate().blockingAwait(); + service.migrate().blockingAwait(); + try (Connection connection = fixture.source.getConnection()) { + var metadata = connection.getMetaData(); + String timezone; + try (var query = connection.createStatement(); + var row = + query.executeQuery( + fixture.dialect == Dialect.POSTGRESQL + ? "SHOW TimeZone" + : "SELECT @@session.time_zone")) { + assertThat(row.next()).isTrue(); + timezone = row.getString(1); + } + assertThat(timezone) + .isEqualTo( + fixture.dialect == Dialect.POSTGRESQL + ? "Pacific/Auckland" + : fixture.dialect == Dialect.MYSQL + ? "+05:30" + : "-03:00"); + System.out.printf( + "ADK_DATABASE_TEST backend=%s engine=%s version=%s driver=%s java=%s timezone=%s%n", + backend, + metadata.getDatabaseProductName(), + metadata.getDatabaseProductVersion(), + metadata.getDriverVersion(), + System.getProperty("java.version"), + timezone); + } + })), + Stream.concat( + new DatabaseSessionContract(fixture.source, fixture.dialect).tests(), + new DatabaseSessionJdbcContract(fixture.source, fixture.dialect) + .tests())); + return DynamicContainer.dynamicContainer( + backend, + Stream.concat( + checks, + Stream.of( + dynamicTest( + "isolated consumer: selected driver and plugin only", + () -> + ConsumerClasspath.verify( + fixture.container.getJdbcUrl(), + fixture.container.getUsername(), + fixture.container.getPassword(), + fixture.dialect)), + dynamicTest("release fixture resources", fixture::close)))); + }); + } + + private Fixture start(String backend) { + Dialect dialect; + JdbcDatabaseContainer container; + switch (backend) { + case "postgresql", "postgresql18" -> { + dialect = Dialect.POSTGRESQL; + container = + new PostgreSQLContainer( + image(backend, backend.equals("postgresql") ? POSTGRESQL : POSTGRESQL18)) + .withEnv("TZ", "Pacific/Auckland") + .withCommand( + "postgres", + "-c", + "timezone=Pacific/Auckland", + "-c", + "lock_timeout=10s", + "-c", + "statement_timeout=15s"); + } + case "mysql" -> { + dialect = Dialect.MYSQL; + container = + new MySQLContainer(image(backend, MYSQL)) + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_0900_ai_ci", + "--default-time-zone=+05:30", + "--innodb-lock-wait-timeout=10", + "--sql-mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"); + } + case "mariadb", "mariadb114" -> { + dialect = Dialect.MARIADB; + container = + new MariaDBContainer(image(backend, backend.equals("mariadb") ? MARIADB : MARIADB114)) + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_unicode_ci", + "--default-time-zone=-03:00", + "--innodb-lock-wait-timeout=10", + "--sql-mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"); + } + default -> throw new IllegalArgumentException("Unknown test backend."); + } + container.withDatabaseName("adk_sessions"); + container.withUsername("adk_test"); + container.withPassword(UUID.randomUUID().toString()); + container.withStartupTimeout(Duration.ofMinutes(3)); + container.withLabel("org.google.adk.database-session-service", "contract-tests"); + Fixture fixture = new Fixture(container, dialect); + fixtures.add(fixture); // Register cleanup before startup, including a failed startup. + container.start(); + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(container.getJdbcUrl()); + config.setDriverClassName(container.getDriverClassName()); + config.setUsername(container.getUsername()); + config.setPassword(container.getPassword()); + config.setMaximumPoolSize(4); + config.setMinimumIdle(0); + config.setConnectionTimeout(15_000); + config.setTransactionIsolation("TRANSACTION_READ_COMMITTED"); + config.setConnectionInitSql( + dialect == Dialect.POSTGRESQL + ? "SET TIME ZONE 'Pacific/Auckland'" + : dialect == Dialect.MYSQL ? "SET time_zone='+05:30'" : "SET time_zone='-03:00'"); + fixture.source = new HikariDataSource(config); + new DatabaseSessionService(fixture.source).migrate().blockingAwait(); + return fixture; + } + + private static DockerImageName image(String backend, String fallback) { + String canonical = + backend.startsWith("postgresql") + ? "postgres" + : backend.startsWith("mariadb") ? "mariadb" : "mysql"; + return DockerImageName.parse(System.getProperty("adk.test.image." + backend, fallback)) + .asCompatibleSubstituteFor(canonical); + } + + @AfterAll + void closeFixtures() { + for (Fixture fixture : fixtures) fixture.close(); + } + + private static final class Fixture implements AutoCloseable { + final JdbcDatabaseContainer container; + final Dialect dialect; + HikariDataSource source; + + Fixture(JdbcDatabaseContainer container, Dialect dialect) { + this.container = container; + this.dialect = dialect; + } + + @Override + public void close() { + if (source != null) source.close(); + container.stop(); + } + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceTest.java new file mode 100644 index 000000000..381cec7e1 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionServiceTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import java.nio.file.Path; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +/** The shared contract's fast, real SQLite execution (Docker is not required). */ +@Timeout(120) +final class DatabaseSessionServiceTest { + @TempDir Path directory; + + /** Keeps the shared contract honest: it must describe what the reference in core really does. */ + @TestFactory + Stream inMemoryReferenceContract() { + InMemorySessionService reference = + new InMemorySessionService(InMemorySessionService.DuplicateSessionIdBehavior.REJECT); + return new SessionServiceContract(() -> reference).tests(); + } + + @TestFactory + Stream sqliteContract() throws Exception { + var source = DatabaseSchemaMigratorTest.source(directory.resolve("contract.db")); + new DatabaseSessionService(source).migrate().blockingAwait(); + printVersion(source, "default"); + return Stream.concat( + new DatabaseSessionContract(source, DatabaseSessionService.Dialect.SQLITE).tests(), + new DatabaseSessionJdbcContract(source, DatabaseSessionService.Dialect.SQLITE).tests()); + } + + @TestFactory + Stream sqliteWalContract() throws Exception { + var source = DatabaseSchemaMigratorTest.source(directory.resolve("wal-contract.db")); + new DatabaseSessionService(source).migrate().blockingAwait(); + try (var connection = source.getConnection(); + var statement = connection.createStatement()) { + statement.execute("PRAGMA journal_mode=WAL"); + } + printVersion(source, "wal"); + return Stream.concat( + new DatabaseSessionContract(source, DatabaseSessionService.Dialect.SQLITE).tests(), + new DatabaseSessionJdbcContract(source, DatabaseSessionService.Dialect.SQLITE).tests()); + } + + private static void printVersion(javax.sql.DataSource source, String journal) throws Exception { + try (var connection = source.getConnection()) { + var metadata = connection.getMetaData(); + System.out.printf( + "ADK_DATABASE_TEST backend=sqlite journal=%s engine=%s driver=%s java=%s%n", + journal, + metadata.getDatabaseProductVersion(), + metadata.getDriverVersion(), + System.getProperty("java.version")); + } + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionStoreTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionStoreTest.java new file mode 100644 index 000000000..0b4848b5a --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionStoreTest.java @@ -0,0 +1,291 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.adk.events.Event; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import org.sqlite.SQLiteDataSource; + +/** Lifecycle/read vertical slice against a real SQLite database. */ +@Timeout(30) +final class DatabaseSessionStoreTest { + @TempDir Path directory; + private SQLiteDataSource source; + private DatabaseSessionStore store; + private final SessionKey key = new SessionKey("app", "user", "session"); + + @BeforeEach + void setUp() { + source = DatabaseSchemaMigratorTest.source(directory.resolve("store.db")); + new DatabaseSessionService(source).migrate().blockingAwait(); + store = new DatabaseSessionStore(source, DatabaseSessionService.Dialect.SQLITE, 15); + } + + @Test + void lifecycleSharesOnlyTheCorrectScopesAndListingHasNoHistory() throws Exception { + Session created = + create( + key, + Map.of( + "app:theme", "dark", "user:name", "Ada", "local", 1, "temp:secret", new Object())); + create(new SessionKey("app", "user", "other"), Map.of()); + create(new SessionKey("app", "other-user", "session"), Map.of()); + create(new SessionKey("other-app", "user", "session"), Map.of()); + seed(1, 1000); + + assertThat(created.lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(123)); + assertThat(created.state()) + .containsExactly("app:theme", "dark", "user:name", "Ada", "local", 1); + assertThat(load(key).immutableEvents()).hasSize(1); + assertThat(load(new SessionKey("app", "other-user", "session")).state()) + .containsExactly("app:theme", "dark"); + assertThat(load(new SessionKey("other-app", "user", "session")).state()).isEmpty(); + assertThat(store.list("app", "user")).hasSize(2); + assertThat( + store.list("app", "user").stream() + .allMatch(session -> session.immutableEvents().isEmpty())) + .isTrue(); + } + + @Test + void duplicateCreationRollsBackSharedStateAndKeepsTheStandardError() { + create(key, Map.of("app:theme", "original", "user:name", "Ada")); + + SessionException error = + assertThrows( + SessionException.class, + () -> create(key, Map.of("app:theme", "changed", "user:name", "changed"))); + + assertThat(error).hasMessageThat().isEqualTo(SessionException.SESSION_ALREADY_EXISTS); + assertThat(error).hasCauseThat().isNull(); + assertThat(load(key).state()).containsExactly("app:theme", "original", "user:name", "Ada"); + } + + @Test + void filteringLimitsAppendPositionsBeforeApplyingInclusivePreciseCursor() throws Exception { + create(key, Map.of()); + seed(1, 3000); + seed(2, 1000); + seed(3, 2000); + + Session filtered = + store.get( + key, + GetSessionConfig.builder() + .numRecentEvents(2) + .afterTimestamp(Instant.ofEpochMilli(1500)) + .build()); + assertThat(filtered.immutableEvents().stream().map(Event::timestamp).toList()) + .containsExactly(2000L); + assertThat( + store + .get( + key, + GetSessionConfig.builder() + .afterTimestamp(Instant.ofEpochMilli(1000).plusNanos(1)) + .build()) + .immutableEvents() + .stream() + .map(Event::timestamp) + .toList()) + .containsExactly(3000L, 2000L) + .inOrder(); + assertThat( + store.get(key, GetSessionConfig.builder().numRecentEvents(0).build()).immutableEvents()) + .isEmpty(); + assertThat(store.listEvents(key).stream().map(Event::id).toList()) + .containsExactly("event-1", "event-2", "event-3") + .inOrder(); + } + + @Test + void deleteCascadesHistoryButRetainsSharedStateAndMissingErrorsFollowTheInterface() + throws Exception { + create(key, Map.of("app:shared", 1, "user:shared", 2)); + seed(1, 1000); + store.delete(key); + + assertThat(store.get(key, GetSessionConfig.builder().build())).isNull(); + assertThrows(SessionNotFoundException.class, () -> store.listEvents(key)); + assertThrows(SessionNotFoundException.class, () -> store.delete(key)); + assertThat(create(key, Map.of()).state()).containsExactly("app:shared", 1, "user:shared", 2); + assertThat(store.listEvents(key)).isEmpty(); + } + + @Test + void keysUseExactUnicodeCaseAndTrailingSpaces() { + for (String id : new String[] {"a", "A", "á", "a ", "雪😀"}) { + create(new SessionKey("app", "user", id), Map.of("key", id)); + } + assertThat(store.list("app", "user")).hasSize(5); + assertThat(load(new SessionKey("app", "user", "a ")).state()).containsExactly("key", "a "); + } + + @Test + void readSnapshotsAreDetachedAndVersionMismatchFailsSafely() throws Exception { + create(key, Map.of("nested", Map.of("value", 1))); + load(key).state().clear(); + assertThat(load(key).state()).containsEntry("nested", Map.of("value", 1)); + try (Connection connection = source.getConnection(); + var statement = connection.createStatement()) { + statement.executeUpdate( + "UPDATE adk_internal_metadata SET value='private-future-version' WHERE key='schema_version'"); + } + SessionException error = assertThrows(SessionException.class, () -> load(key)); + assertThat(error).hasCauseThat().isNull(); + assertThat(error).hasMessageThat().doesNotContain("private-future-version"); + assertThat(error).hasMessageThat().contains("schema version"); + } + + @Test + void missingSharedRowIsCorruptionNotASilentlyMissingSession() throws Exception { + create(key, Map.of("user:retained", 1)); + try (Connection connection = source.getConnection(); + var statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM user_states"); + } + SessionException error = assertThrows(SessionException.class, () -> load(key)); + assertThat(error).hasCauseThat().isNull(); + assertThat(error).hasMessageThat().isEqualTo("Unable to decode session state."); + } + + @Test + void listingReadsSharedStateOnceAndStillGivesEverySessionItsOwnValues() throws Exception { + create(key, Map.of("app:shared", List.of("original"), "user:name", "Ada", "local", 1)); + create(new SessionKey("app", "user", "other"), Map.of("local", 2)); + JdbcProbe probe = new JdbcProbe(source, DatabaseSessionService.Dialect.SQLITE); + List listed = + new DatabaseSessionStore(probe, DatabaseSessionService.Dialect.SQLITE, 15) + .list("app", "user"); + + assertThat(probe.sql.stream().filter(sql -> sql.contains("app_states")).count()).isEqualTo(1); + assertThat(listed.stream().map(Session::id).toList()) + .containsExactly("other", "session") + .inOrder(); + assertThat(listed.get(0).state()) + .containsExactly("app:shared", List.of("original"), "user:name", "Ada", "local", 2); + ((List) listed.get(0).state().get("app:shared")).clear(); + assertThat(listed.get(1).state()) + .containsExactly("app:shared", List.of("original"), "user:name", "Ada", "local", 1); + // A user without sessions has no shared rows to read, and that is not an error. + assertThat(store.list("app", "nobody")).isEmpty(); + try (Connection connection = source.getConnection(); + var statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM app_states"); + } + SessionException error = assertThrows(SessionException.class, () -> store.list("app", "user")); + assertThat(error).hasMessageThat().isEqualTo("Unable to decode session state."); + } + + @Test + void corruptStoredEventKeepsSafeCodecMessageWithoutSuppressedCloseDetails() throws Exception { + create(key, Map.of()); + seed(1, 1000); + try (Connection connection = source.getConnection(); + var statement = connection.createStatement()) { + // An object passes the schema CHECK but does not have the required native Event fields. + statement.executeUpdate("UPDATE events SET event_data='{}'"); + } + JdbcProbe probe = new JdbcProbe(source, DatabaseSessionService.Dialect.SQLITE); + probe.hook = + (sql, after) -> { + if (after && sql.equals("CLOSE CONNECTION")) { + throw new SQLException("private-codec-close"); + } + }; + DatabaseSessionStore failingStore = + new DatabaseSessionStore(probe, DatabaseSessionService.Dialect.SQLITE, 15); + SessionException failure = + assertThrows( + SessionException.class, + () -> failingStore.get(key, GetSessionConfig.builder().build())); + assertThat(failure.getClass()).isEqualTo(SessionException.class); + assertThat(failure).hasMessageThat().isEqualTo("Unable to decode session event."); + assertThat(failure).hasCauseThat().isNull(); + assertThat(failure.getSuppressed()).isEmpty(); + assertThat(probe.borrowed.get()).isEqualTo(probe.closed.get()); + } + + @Test + void sqliteOverridesWeakReadIsolationOnlyDuringItsTransaction() throws Exception { + DataSource weak = mock(DataSource.class); + var current = new java.util.concurrent.atomic.AtomicReference(); + when(weak.getConnection()) + .thenAnswer( + ignored -> { + Connection connection = source.getConnection(); + connection.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED); + current.set(connection); + return connection; + }); + JdbcProbe probe = new JdbcProbe(weak, DatabaseSessionService.Dialect.SQLITE); + probe.hook = + (sql, after) -> { + if (!after && sql.contains("FROM adk_internal_metadata")) { + assertThat(current.get().getTransactionIsolation()) + .isEqualTo(Connection.TRANSACTION_SERIALIZABLE); + } + }; + DatabaseSessionService service = + DatabaseSessionService.builder(probe) + .dialect(DatabaseSessionService.Dialect.SQLITE) + .build(); + Session session = service.createSession("app", "user").blockingGet(); + assertThat(service.getSession(session.sessionKey(), null).blockingGet()).isNotNull(); + assertThat(probe.closed.get()).isEqualTo(2); + } + + private Session create(SessionKey key, Map state) { + return store.create(key, state, Instant.ofEpochMilli(123)); + } + + private Session load(SessionKey key) { + return store.get(key, GetSessionConfig.builder().build()); + } + + private void seed(long sequence, long timestamp) throws Exception { + try (Connection connection = source.getConnection(); + PreparedStatement statement = + connection.prepareStatement( + "INSERT INTO events VALUES ('app','user','session',?,?,'',?,?)")) { + String id = "event-" + sequence; + statement.setLong(1, sequence); + statement.setString(2, id); + DatabaseTime.bind( + statement, 3, Instant.ofEpochMilli(timestamp), DatabaseSessionService.Dialect.SQLITE); + statement.setString( + 4, + new SessionDataCodec().writeEvent(Event.builder().id(id).timestamp(timestamp).build())); + statement.executeUpdate(); + } + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionValidationTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionValidationTest.java new file mode 100644 index 000000000..8b8ff90be --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/DatabaseSessionValidationTest.java @@ -0,0 +1,130 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.google.adk.events.Event; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Map; +import java.util.Optional; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; + +final class DatabaseSessionValidationTest { + @Test + void invalidArgumentsDoNotTriggerEvenOptedInDdl() { + DataSource source = mock(DataSource.class); + DatabaseSessionService service = + DatabaseSessionService.builder(source) + .dialect(DatabaseSessionService.Dialect.SQLITE) + .autoMigrate(true) + .build(); + var create = service.createSession(null, "u", Map.of(), "id"); + var get = service.getSession("app", null, "id", Optional.empty()); + var delete = service.deleteSession("app", "u", null); + var list = service.listSessions(null, "u"); + var events = service.listEvents("app", "u", null); + verifyNoInteractions(source); + assertThrows(SessionException.class, create::blockingGet); + assertThrows(SessionException.class, get::blockingGet); + assertThrows(SessionException.class, delete::blockingAwait); + assertThrows(SessionException.class, list::blockingGet); + assertThrows(SessionException.class, events::blockingGet); + verifyNoInteractions(source); + } + + @Test + void brokenProvidersHaveSafeErrorsAndNoDatabaseSideEffects() { + DataSource source = mock(DataSource.class); + DatabaseSessionService uuid = + DatabaseSessionService.builder(source) + .uuidProvider( + () -> { + throw new IllegalStateException("private-provider"); + }) + .build(); + DatabaseSessionService clock = + DatabaseSessionService.builder(source) + .instantSource( + () -> { + throw new IllegalStateException("private-clock"); + }) + .build(); + for (DatabaseSessionService service : new DatabaseSessionService[] {uuid, clock}) { + SessionException failure = + assertThrows( + SessionException.class, () -> service.createSession("app", "u").blockingGet()); + assertThat(failure).hasCauseThat().isNull(); + assertThat(failure).hasMessageThat().doesNotContain("private"); + } + verifyNoInteractions(source); + } + + @Test + void partialAndInvalidEventsNeverBorrowAConnection() { + DataSource source = mock(DataSource.class); + DatabaseSessionService service = new DatabaseSessionService(source); + Session session = Session.builder("id").appName("app").userId("u").build(); + Event event = Event.builder().id("invalid\0event").timestamp(1).partial(true).build(); + assertThat(service.appendEvent(session, event).blockingGet()).isSameInstanceAs(event); + assertThrows( + SessionException.class, + () -> service.appendEvent(session, event.toBuilder().partial(false).build()).blockingGet()); + verifyNoInteractions(source); + } + + @Test + void callerTransactionsAreRejectedWithoutCommittingOrRollingThemBack() throws Exception { + DataSource source = mock(DataSource.class); + Connection connection = mock(Connection.class); + when(source.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(false); + DatabaseSessionService service = + DatabaseSessionService.builder(source) + .dialect(DatabaseSessionService.Dialect.SQLITE) + .build(); + assertThrows(SessionException.class, () -> service.listSessions("app", "u").blockingGet()); + verify(connection, never()).commit(); + verify(connection, never()).rollback(); + verify(connection, never()).setAutoCommit(true); + verify(connection).close(); + } + + @Test + void driverFailuresNeverExposeRawMessagesCausesOrSuppressedData() throws Exception { + DataSource source = mock(DataSource.class); + SQLException raw = new SQLException("private-password-and-url"); + raw.addSuppressed(new SQLException("private-suppressed")); + when(source.getConnection()).thenThrow(raw); + DatabaseSessionService service = + DatabaseSessionService.builder(source) + .dialect(DatabaseSessionService.Dialect.SQLITE) + .build(); + SessionException failure = + assertThrows(SessionException.class, () -> service.listSessions("app", "u").blockingGet()); + assertThat(failure).hasCauseThat().isNull(); + assertThat(failure.getSuppressed()).isEmpty(); + assertThat(failure).hasMessageThat().doesNotContain("private"); + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/JdbcProbe.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/JdbcProbe.java new file mode 100644 index 000000000..4202c761f --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/JdbcProbe.java @@ -0,0 +1,199 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.sessions.DatabaseSessionService.Dialect; +import java.io.PrintWriter; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Statement; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import javax.sql.DataSource; + +/** + * Test-only real JDBC delegation, deterministic fault/barrier hooks, and connection leak checks. + */ +final class JdbcProbe implements DataSource { + @FunctionalInterface + interface Hook { + void execute(String sql, boolean after) throws Exception; + } + + final List sql = new CopyOnWriteArrayList<>(); + final List timeouts = new CopyOnWriteArrayList<>(); + final AtomicInteger borrowed = new AtomicInteger(); + final AtomicInteger closed = new AtomicInteger(); + volatile Hook hook = (sql, after) -> {}; + private final DataSource delegate; + private final Dialect dialect; + + JdbcProbe(DataSource delegate, Dialect dialect) { + this.delegate = delegate; + this.dialect = dialect; + } + + @Override + public Connection getConnection() throws SQLException { + return wrap(delegate.getConnection()); + } + + @Override + public Connection getConnection(String user, String password) throws SQLException { + return wrap(delegate.getConnection(user, password)); + } + + private Connection wrap(Connection connection) throws SQLException { + borrowed.incrementAndGet(); + boolean autoCommit = connection.getAutoCommit(); + boolean readOnly = connection.isReadOnly(); + int isolation = connection.getTransactionIsolation(); + int fk = dialect == Dialect.SQLITE ? pragma(connection, "foreign_keys") : -1; + int busy = dialect == Dialect.SQLITE ? pragma(connection, "busy_timeout") : -1; + AtomicBoolean returned = new AtomicBoolean(); + AtomicBoolean aborted = new AtomicBoolean(); + return (Connection) + Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] {Connection.class}, + (proxy, method, args) -> { + String name = method.getName(); + if (name.equals("abort")) aborted.set(true); + if (name.equals("close") && returned.compareAndSet(false, true)) { + try { + if (!aborted.get()) { + assertThat(connection.getAutoCommit()).isEqualTo(autoCommit); + assertThat(connection.isReadOnly()).isEqualTo(readOnly); + assertThat(connection.getTransactionIsolation()).isEqualTo(isolation); + if (dialect == Dialect.SQLITE) { + assertThat(pragma(connection, "foreign_keys")).isEqualTo(fk); + assertThat(pragma(connection, "busy_timeout")).isEqualTo(busy); + try (Statement statement = connection.createStatement()) { + statement.execute("BEGIN"); + statement.execute("ROLLBACK"); + } + } + } + } finally { + closed.incrementAndGet(); + connection.close(); + } + hook.execute("CLOSE CONNECTION", true); + return null; + } + if (name.equals("commit") || name.equals("rollback")) { + String command = name.toUpperCase(java.util.Locale.ROOT); + sql.add(command); + hook.execute(command, false); + Object result = invoke(connection, method, args); + hook.execute(command, true); + return result; + } + Object result = invoke(connection, method, args); + if (result instanceof Statement statement + && (name.equals("createStatement") || name.equals("prepareStatement"))) { + String prepared = name.equals("prepareStatement") ? (String) args[0] : null; + Class type = + prepared == null ? Statement.class : java.sql.PreparedStatement.class; + AtomicBoolean executedCommit = new AtomicBoolean(); + return Proxy.newProxyInstance( + Statement.class.getClassLoader(), + new Class[] {type}, + (statementProxy, operation, parameters) -> { + if (operation.getName().startsWith("execute")) { + String command = prepared == null ? (String) parameters[0] : prepared; + sql.add(command); + hook.execute(command, false); + Object answer = invoke(statement, operation, parameters); + executedCommit.set(command.equals("COMMIT")); + hook.execute(command, true); + return answer; + } + if (operation.getName().equals("setQueryTimeout")) { + timeouts.add((Integer) parameters[0]); + } + Object answer = invoke(statement, operation, parameters); + if (operation.getName().equals("close") && executedCommit.get()) { + hook.execute("CLOSE COMMIT STATEMENT", true); + } + return answer; + }); + } + return result; + }); + } + + private static Object invoke(Object receiver, Method method, Object[] args) throws Throwable { + try { + return method.invoke(receiver, args); + } catch (InvocationTargetException wrapped) { + throw wrapped.getCause(); + } + } + + private static int pragma(Connection connection, String name) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("PRAGMA " + name)) { + if (!result.next()) throw new SQLException(); + return result.getInt(1); + } + } + + @Override + public PrintWriter getLogWriter() throws SQLException { + return delegate.getLogWriter(); + } + + @Override + public void setLogWriter(PrintWriter writer) throws SQLException { + delegate.setLogWriter(writer); + } + + @Override + public int getLoginTimeout() throws SQLException { + return delegate.getLoginTimeout(); + } + + @Override + public void setLoginTimeout(int timeout) throws SQLException { + delegate.setLoginTimeout(timeout); + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + return delegate.getParentLogger(); + } + + @Override + public T unwrap(Class type) throws SQLException { + return delegate.unwrap(type); + } + + @Override + public boolean isWrapperFor(Class type) throws SQLException { + return delegate.isWrapperFor(type); + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/SessionDataCodecTest.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/SessionDataCodecTest.java new file mode 100644 index 000000000..3a0e8b917 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/SessionDataCodecTest.java @@ -0,0 +1,767 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.JsonBaseModel; +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.adk.events.EventCompaction; +import com.google.adk.events.ToolConfirmation; +import com.google.adk.models.CacheMetadata; +import com.google.adk.workflow.NodeInfo; +import com.google.adk.workflow.Route; +import com.google.genai.types.Blob; +import com.google.genai.types.Content; +import com.google.genai.types.CustomMetadata; +import com.google.genai.types.FinishReason; +import com.google.genai.types.FunctionCall; +import com.google.genai.types.FunctionResponse; +import com.google.genai.types.GenerateContentResponseUsageMetadata; +import com.google.genai.types.GroundingMetadata; +import com.google.genai.types.Part; +import com.google.genai.types.Transcription; +import java.math.BigInteger; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** Pure JSON/scope tests. These do not claim to verify a JDBC engine or its physical schema. */ +final class SessionDataCodecTest { + private static final String SENTINEL_TEXT = "__ADK_SENTINEL_REMOVED__"; + private final SessionDataCodec codec = new SessionDataCodec(); + + @Test + void nullAndEmptyCombinedStateHaveEmptyScopes() { + assertThat(codec.splitState(null)).isEqualTo(codec.splitState(Map.of())); + assertThat(codec.splitState(null).app()).isEmpty(); + assertThat(codec.splitState(null).user()).isEmpty(); + assertThat(codec.splitState(null).local()).isEmpty(); + } + + @Test + void splitUsesOnlyTheFirstPrefixAndKeepsDottedAndEmptyKeysLiteral() { + SessionDataCodec.ScopedState split = + codec.splitState( + Map.of( + "app:theme", "dark", + "user:name", "Ada", + "settings.name", "local", + "app:temp:key", "app value", + "user:app:key", "user value", + "app:", "empty app key", + "", "empty local key", + "temp:hidden", "ignored")); + + assertThat(split.app()) + .containsExactly("theme", "dark", "temp:key", "app value", "", "empty app key"); + assertThat(split.user()).containsExactly("name", "Ada", "app:key", "user value"); + assertThat(split.local()).containsExactly("settings.name", "local", "", "empty local key"); + } + + @Test + void splitSkipsTempValuesBeforeSerialization() { + BrokenValue value = new BrokenValue(); + + SessionDataCodec.ScopedState split = codec.splitState(Map.of("temp:runtime", value, "ok", 1)); + + assertThat(split.local()).containsExactly("ok", 1); + assertThat(value.reads).isEqualTo(0); + } + + @Test + void splitPreservesDeletionIdentityWithoutInterpretingLiteralSentinelStrings() { + Map state = new LinkedHashMap<>(); + state.put("app:deleted", State.REMOVED); + state.put("user:deleted", null); + state.put("deleted", State.REMOVED); + state.put("literal", SENTINEL_TEXT); + state.put("nested", nullableValue()); + + SessionDataCodec.ScopedState split = codec.splitState(state); + + assertThat(split.app().get("deleted")).isSameInstanceAs(State.REMOVED); + assertThat(split.user().get("deleted")).isSameInstanceAs(State.REMOVED); + assertThat(split.local().get("deleted")).isSameInstanceAs(State.REMOVED); + assertThat(split.local().get("literal")).isEqualTo(SENTINEL_TEXT); + assertThat(split.local().get("nested")).isEqualTo(nullableValue()); + assertThat(state).containsEntry("user:deleted", null); + } + + @Test + void splitSnapshotsAreDetachedAndTheirTopLevelMapsAreUnmodifiable() { + List items = new ArrayList<>(List.of("original")); + Map state = new LinkedHashMap<>(Map.of("app:items", items)); + + SessionDataCodec.ScopedState split = codec.splitState(state); + items.add("later"); + state.put("other", 1); + + assertThat(split.app()).containsExactly("items", List.of("original")); + assertThat(split.local()).isEmpty(); + assertThrows(UnsupportedOperationException.class, () -> split.app().put("changed", true)); + assertThrows(UnsupportedOperationException.class, () -> split.user().put("changed", true)); + assertThrows(UnsupportedOperationException.class, () -> split.local().put("changed", true)); + } + + @Test + void stateDocumentsOmitDeletedEntriesButKeepNestedNullsAndLiteralStrings() throws Exception { + Map document = new LinkedHashMap<>(); + document.put("removed", State.REMOVED); + document.put("null", null); + document.put("literal", SENTINEL_TEXT); + document.put("nested", nullableValue()); + document.put("empty", Map.of()); + + String json = codec.writeState(document); + Map restored = codec.readState(json); + + assertThat(restored) + .containsExactly("literal", SENTINEL_TEXT, "nested", nullableValue(), "empty", Map.of()); + assertThat(tree(json).has("removed")).isFalse(); + assertThat(tree(json).has("null")).isFalse(); + assertThat(document.get("removed")).isSameInstanceAs(State.REMOVED); + assertThat(document).containsEntry("null", null); + } + + @Test + void readStateTreatsOnlyTopLevelNullAsAbsent() { + assertThat(codec.readState("{\"gone\":null,\"nested\":{\"value\":null}}")) + .containsExactly("nested", nullableValue()); + } + + @Test + void alreadyScopedDocumentsKeepPrefixLookingAndDottedKeys() { + Map document = Map.of("temp:key", 1, "app:key", 2, "user:key", 3, "a.b", 4); + + assertThat(codec.readState(codec.writeState(document))).isEqualTo(document); + } + + @Test + void eventSerializationOverridesDoNotChangeArbitraryModelValuesInsideState() throws Exception { + EventActions value = + EventActions.builder() + .stateDelta(Map.of("temp:literalPayload", 1, "removed", State.REMOVED)) + .build(); + Map state = Map.of("modelAsUserData", value); + + assertThat(tree(codec.writeState(state))).isEqualTo(tree(JsonBaseModel.toJsonString(state))); + assertThat(codec.splitState(state).local().get("modelAsUserData")) + .isEqualTo(JsonBaseModel.getMapper().readValue(value.toJson(), Map.class)); + } + + @Test + void onlyRootEventActionsDeltaUsesPersistenceControlRules() throws Exception { + EventActions nested = + EventActions.builder() + .stateDelta(Map.of("temp:literal", 1, "literalRemoval", State.REMOVED)) + .build(); + Event original = + event(Map.of("temp:root", 1, "removed", State.REMOVED)).toBuilder() + .output(Map.of("model", nested)) + .build(); + String json = codec.writeEvent(original); + assertThat(tree(json).path("output").path("model")).isEqualTo(tree(nested.toJson())); + assertThat(tree(json).path("actions").path("stateDelta").has("temp:root")).isFalse(); + assertThat(codec.readEvent(json).actions().stateDelta().get("removed")) + .isSameInstanceAs(State.REMOVED); + } + + @Test + void stateNumbersFollowTheAdkMapperWithoutLosingLargeIntegers() throws Exception { + Map state = + Map.of( + "beyondDoubleInteger", + 9_007_199_254_740_993L, + "beyondLong", + new BigInteger("123456789012345678901234567890"), + "fraction", + 1.25, + "false", + false, + "emptyString", + ""); + + Map restored = codec.readState(codec.writeState(state)); + + assertThat(restored) + .isEqualTo( + JsonBaseModel.getMapper().readValue(JsonBaseModel.toJsonString(state), Map.class)); + assertThat(restored.get("beyondDoubleInteger")).isEqualTo(9_007_199_254_740_993L); + assertThat(restored.get("beyondLong")) + .isEqualTo(new BigInteger("123456789012345678901234567890")); + } + + @Test + void snapshotsUseActualJsonRepresentationsAndDetachBinaryInput() { + byte[] bytes = {1, 2, 3}; + + SessionDataCodec.ScopedState snapshot = codec.splitState(Map.of("data", bytes)); + bytes[0] = 9; + + assertThat(snapshot.local()).containsExactly("data", "AQID"); + } + + @Test + void applyStateDeletesReplacesWholeMapsAndDoesNotDeepMergeDottedKeys() { + Map current = + Map.of("map", Map.of("old", 1), "gone", 2, "alsoGone", 3, "a.b", 4, "a", Map.of("b", 5)); + Map delta = new LinkedHashMap<>(); + delta.put("map", Map.of("new", 6)); + delta.put("gone", State.REMOVED); + delta.put("alsoGone", null); + delta.put("a.b", 7); + delta.put("literal", SENTINEL_TEXT); + + Map updated = codec.applyState(current, delta); + + assertThat(updated) + .containsExactly( + "map", Map.of("new", 6), "a.b", 7, "a", Map.of("b", 5), "literal", SENTINEL_TEXT); + assertThat(current).containsEntry("map", Map.of("old", 1)); + assertThat(current).containsKey("gone"); + assertThat(delta.get("gone")).isSameInstanceAs(State.REMOVED); + assertThat(delta).containsEntry("alsoGone", null); + } + + @Test + void applyStateWithoutDeltaStillReturnsOnlyActiveValuesInANewMap() { + Map current = + new LinkedHashMap<>(Map.of("items", List.of("original"), "gone", State.REMOVED)); + current.put("null", null); + + Map updated = codec.applyState(current, null); + updated.put("later", true); + + assertThat(updated).containsExactly("items", List.of("original"), "later", true); + assertThat(current).containsKey("gone"); + assertThat(current).doesNotContainKey("later"); + } + + @Test + void applyingADeltaToADecodedDocumentWritesTheSameTextAsEncodingTheResultTwice() + throws Exception { + Map current = + codec.readState( + "{\"kept\":{\"n\":null,\"big\":123456789012345678901234567890},\"f\":-0.0,\"gone\":1}"); + Map delta = new LinkedHashMap<>(); + delta.put("gone", State.REMOVED); + delta.put("exponent", 1e23); + delta.put("bytes", new byte[] {1, 2, 3}); + delta.put("text", "before\0after"); + + String written = codec.writeState(codec.applyState(current, codec.splitState(delta).local())); + + assertThat(written).isEqualTo(codec.writeState(codec.readState(written))); + assertThat(tree(written)) + .isEqualTo( + tree( + "{\"kept\":{\"n\":null,\"big\":123456789012345678901234567890},\"f\":-0.0," + + "\"exponent\":1.0E23,\"bytes\":\"AQID\",\"text\":\"before\\u0000after\"}")); + } + + @Test + void mergeKeepsScopesDistinctAndDoesNotResurrectDeletedSharedValuesFromLocalState() { + Map local = + Map.of( + "key", + "local", + "app:key", + "stale", + "user:deleted", + "stale", + "temp:runtime", + new BrokenValue()); + Map user = new LinkedHashMap<>(); + user.put("key", "user"); + user.put("deleted", State.REMOVED); + user.put("null", null); + + Map merged = codec.mergeState(Map.of("key", "app"), user, local); + + assertThat(merged).containsExactly("key", "local", "app:key", "app", "user:key", "user"); + assertThat(codec.mergeState(Map.of(), Map.of(), Map.of("app:deleted", "stale"))).isEmpty(); + assertThat(local).containsEntry("app:key", "stale"); + assertThat(user.get("deleted")).isSameInstanceAs(State.REMOVED); + } + + @Test + void mergingScopedSuffixesDoesNotDropLegitimatePrefixLookingRawKeys() { + SessionDataCodec.ScopedState split = + codec.splitState(Map.of("app:temp:key", 1, "user:app:key", 2, "app:", 3)); + + assertThat(codec.mergeState(split.app(), split.user(), split.local())) + .containsExactly("app:temp:key", 1, "user:app:key", 2, "app:", 3); + } + + @Test + void mergingSeparatelyDecodedDocumentsReturnsIndependentlyMutableSnapshots() { + String app = "{\"items\":[\"original\"]}"; + Map first = codec.mergeState(codec.readState(app), Map.of(), Map.of()); + Map second = codec.mergeState(codec.readState(app), Map.of(), Map.of()); + + first.put("local", true); + ((List) first.get("app:items")).clear(); + + assertThat(second).containsExactly("app:items", List.of("original")); + } + + @Test + void minimalEventRoundTripsWithZeroTimestampAndEmptyActions() throws Exception { + Event original = event(Map.of()); + + Event restored = codec.readEvent(codec.writeEvent(original)); + + assertThat(restored.id()).isEqualTo("event-雪"); + assertThat(restored.timestamp()).isEqualTo(0); + assertThat(tree(restored.toJson())).isEqualTo(tree(original.toJson())); + assertThat(restored.actions().stateDelta()).isEmpty(); + } + + @Test + void fullEventRetainsEveryModeledFieldIncludingBinaryAndWorkflowData() throws Exception { + Event original = fullEvent(); + String json = codec.writeEvent(original); + + Event restored = codec.readEvent(json); + + assertThat(tree(json)).isEqualTo(tree(original.toJson())); + assertThat(tree(restored.toJson())).isEqualTo(tree(original.toJson())); + assertThat(tree(json).has("formatVersion")).isFalse(); + assertThat(tree(json).has("event")).isFalse(); + assertThat( + restored + .content() + .orElseThrow() + .parts() + .orElseThrow() + .get(0) + .thoughtSignature() + .orElseThrow()) + .isEqualTo(new byte[] {1, 2, 3}); + assertThat( + restored + .content() + .orElseThrow() + .parts() + .orElseThrow() + .get(3) + .inlineData() + .orElseThrow() + .data() + .orElseThrow()) + .isEqualTo(new byte[] {4, 5, 6}); + assertThat(restored.actions().compaction()).isPresent(); + assertThat(restored.nodeInfo()).isPresent(); + assertThat(restored.cacheMetadata()).isPresent(); + assertThat(restored.cacheMetadata()).isEqualTo(original.cacheMetadata()); + assertThat(restored.actions().route()) + .hasValue( + List.of( + new Route.Tag("next"), + new Route.Num(2), + new Route.Flag(false), + Route.Default.INSTANCE)); + } + + @Test + void structuredToolArgumentsAndConfirmationPayloadKeepTheirJsonShape() throws Exception { + ToolConfirmation confirmation = + ToolConfirmation.builder() + .hint("Approve?") + .confirmed(true) + .payload(nullableValue()) + .build(); + Event original = + event(Map.of()).toBuilder() + .actions( + EventActions.builder() + .requestedToolConfirmations(Map.of("call", confirmation)) + .build()) + .content( + Content.fromParts( + Part.builder() + .functionCall( + FunctionCall.builder() + .id("confirmation") + .name("adk_request_confirmation") + .args( + Map.of( + "originalFunctionCall", + FunctionCall.builder().id("call").name("tool").build(), + "toolConfirmation", + confirmation)) + .build()) + .build())) + .build(); + + Event restored = codec.readEvent(codec.writeEvent(original)); + + assertThat(tree(restored.toJson())).isEqualTo(tree(original.toJson())); + assertThat(restored.actions().requestedToolConfirmations().get("call").payload()) + .isEqualTo(nullableValue()); + } + + @Test + void eventDeltaStoresRealRemovalsAsNullAndKeepsLiteralSentinelString() throws Exception { + Map delta = new LinkedHashMap<>(); + delta.put("app:deleted", State.REMOVED); + delta.put("user:deleted", null); + delta.put("literal", SENTINEL_TEXT); + delta.put("nested", nullableValue()); + Event original = event(delta); + + String json = codec.writeEvent(original); + Event restored = codec.readEvent(json); + + assertThat(tree(json).path("actions").path("stateDelta").path("app:deleted").isNull()).isTrue(); + assertThat(restored.actions().stateDelta().get("app:deleted")).isSameInstanceAs(State.REMOVED); + assertThat(restored.actions().stateDelta().get("user:deleted")).isSameInstanceAs(State.REMOVED); + assertThat(restored.actions().stateDelta().get("literal")).isEqualTo(SENTINEL_TEXT); + assertThat(restored.actions().stateDelta().get("nested")).isEqualTo(nullableValue()); + assertThat(codec.writeEvent(restored)).isEqualTo(json); + assertThat(original.actions().stateDelta().get("app:deleted")).isSameInstanceAs(State.REMOVED); + } + + @Test + void eventTempValuesAreNeverVisitedOrRemovedFromTheCallerEvent() { + BrokenValue value = new BrokenValue(); + Event original = event(Map.of("temp:runtime", value, "persisted", 1)); + + Event restored = codec.readEvent(codec.writeEvent(original)); + + assertThat(restored.actions().stateDelta()).containsExactly("persisted", 1); + assertThat(original.actions().stateDelta().get("temp:runtime")).isSameInstanceAs(value); + assertThat(value.reads).isEqualTo(0); + } + + @Test + void tempPrefixIsNotRecursivelyStrippedFromUserPayloadOrOtherEventFields() throws Exception { + Event original = + event(Map.of("nested", Map.of("temp:key", 1), "app:temp:key", 2, "user:temp:key", 3)) + .toBuilder() + .actions( + EventActions.builder() + .stateDelta( + Map.of( + "nested", Map.of("temp:key", 1), "app:temp:key", 2, "user:temp:key", 3)) + .agentState(Map.of("temp:key", 4)) + .build()) + .output(Map.of("temp:key", 5)) + .build(); + + Event restored = codec.readEvent(codec.writeEvent(original)); + + assertThat(tree(restored.toJson())).isEqualTo(tree(original.toJson())); + } + + @Test + void readingEventAlsoDropsTempDeltaWithoutChangingOtherPayloads() { + Event restored = + codec.readEvent( + "{\"id\":\"e\",\"timestamp\":1,\"actions\":{\"stateDelta\":{\"temp:discard\":1,\"nested\":{\"temp:keep\":2}}}}"); + + assertThat(restored.actions().stateDelta()).containsExactly("nested", Map.of("temp:keep", 2)); + } + + @Test + void identityLookingFieldsInsideThePayloadDoNotDecideTheRootIdentity() { + Event restored = + codec.readEvent( + "{\"actions\":{\"stateDelta\":{\"id\":5,\"timestamp\":\"later\"}},\"id\":\"e\",\"timestamp\":1}"); + + assertThat(restored.id()).isEqualTo("e"); + assertThat(restored.timestamp()).isEqualTo(1); + assertThat(restored.actions().stateDelta()).containsExactly("id", 5, "timestamp", "later"); + } + + @Test + void emptyAndNullOptionalEventValuesFollowNativeJavaDefaults() throws Exception { + for (String json : + List.of( + "{\"id\":\"\",\"timestamp\":-1,\"actions\":null,\"content\":null}", + "{\"id\":\"e\",\"timestamp\":9223372036854775807,\"actions\":{\"stateDelta\":null,\"route\":[]}}")) { + Event nativeEvent = Event.fromJson(json); + + assertThat(tree(codec.readEvent(json).toJson())).isEqualTo(tree(nativeEvent.toJson())); + assertThat(tree(codec.readEvent(codec.writeEvent(nativeEvent)).toJson())) + .isEqualTo(tree(nativeEvent.toJson())); + } + } + + @Test + void codecDoesNotModifyTheSharedAdkMapper() throws Exception { + ObjectMapper sharedMapper = JsonBaseModel.getMapper(); + Event original = event(Map.of("temp:keepInMemory", 1, "removed", State.REMOVED)); + String before = original.toJson(); + + codec.writeEvent(original); + new SessionDataCodec().writeEvent(original); + + assertThat(JsonBaseModel.getMapper()).isSameInstanceAs(sharedMapper); + assertThat(original.toJson()).isEqualTo(before); + assertThat(tree(before).path("actions").path("stateDelta").path("removed").asText()) + .isEqualTo(SENTINEL_TEXT); + assertThat(tree(before).path("actions").path("stateDelta").has("temp:keepInMemory")).isTrue(); + } + + @Test + void corruptStateDocumentsFailWithSafeErrors() { + for (String json : + Arrays.asList( + null, + "", + "null", + "[]", + "true", + "123", + "\"private-value\"", + "{\"private-value\":", + "{} {}", + "{} private-value")) { + assertSafeError( + assertThrows(SessionException.class, () -> codec.readState(json)), + "Unable to decode session state."); + } + } + + @Test + void corruptEventDocumentsFailInsteadOfSupplyingMissingIdentityOrWallClock() { + for (String json : + Arrays.asList( + null, + "", + "null", + "[]", + "123", + "\"private-value\"", + "{}", + "{\"id\":\"private-value\"}", + "{\"timestamp\":1}", + "{\"id\":null,\"timestamp\":1}", + "{\"id\":2,\"timestamp\":1}", + "{\"id\":{\"id\":\"e\"},\"timestamp\":1}", + "{\"id\":\"e\",\"timestamp\":[1]}", + "{\"id\":\"e\",\"timestamp\":null}", + "{\"id\":\"e\",\"timestamp\":\"1\"}", + "{\"id\":\"e\",\"timestamp\":1.5}", + "{\"id\":\"e\",\"timestamp\":9223372036854775808}", + "{\"id\":\"e\",\"timestamp\":1,\"actions\":{\"stateDelta\":[]}}", + "{\"id\":\"e\",\"timestamp\":1,\"content\":\"private-value\"}", + "{\"id\":\"e\",\"timestamp\":1} {}", + "{\"private-value\":", + "{\"formatVersion\":1,\"event\":{\"id\":\"e\",\"timestamp\":1}}")) { + assertSafeError( + assertThrows(SessionException.class, () -> codec.readEvent(json)), + "Unable to decode session event."); + } + } + + @Test + void eventWithoutAnIdCannotBeWrittenAsAnUnreadableRecord() { + assertSafeError( + assertThrows( + SessionException.class, () -> codec.writeEvent(Event.builder().timestamp(1).build())), + "Unable to encode session event."); + } + + @Test + void serializationFailuresDoNotLeakPrivateDataCausesOrPartialMutations() { + BrokenValue value = new BrokenValue(); + Map invalid = Map.of("private-key", value); + Event original = event(Map.of("temp:keep", 1, "private-key", value, "removed", State.REMOVED)); + + assertSafeError( + assertThrows(SessionException.class, () -> codec.writeState(invalid)), + "Unable to encode session state."); + assertSafeError( + assertThrows(SessionException.class, () -> codec.splitState(invalid)), + "Unable to split session state."); + // A value that cannot be encoded never reaches applyState or mergeState: their inputs are + // snapshots that splitState and readState have already put through JSON. + assertSafeError( + assertThrows(SessionException.class, () -> codec.writeEvent(original)), + "Unable to encode session event."); + assertThat(original.actions().stateDelta()).containsEntry("temp:keep", 1); + assertThat(original.actions().stateDelta().get("removed")).isSameInstanceAs(State.REMOVED); + assertThat(invalid.get("private-key")).isSameInstanceAs(value); + } + + @Test + void cyclicStateFailsSafelyInsteadOfLeakingTheSerializerCause() { + Map cyclic = new LinkedHashMap<>(); + cyclic.put("private-key", cyclic); + + assertSafeError( + assertThrows(SessionException.class, () -> codec.writeState(cyclic)), + "Unable to encode session state."); + } + + private static Event event(Map delta) { + return Event.builder() + .id("event-雪") + .invocationId("invocation") + .author("agent") + .timestamp(0) + .actions(EventActions.builder().stateDelta(delta).build()) + .build(); + } + + private static Map nullableValue() { + Map nested = new LinkedHashMap<>(); + nested.put("value", null); + return nested; + } + + private static JsonNode tree(String json) throws Exception { + return JsonBaseModel.getMapper().readTree(json); + } + + private static void assertSafeError(SessionException error, String message) { + assertThat(error).hasMessageThat().isEqualTo(message); + assertThat(error).hasCauseThat().isNull(); + assertThat(error.getSuppressed()).isEmpty(); + } + + static Event fullEvent() { + return event(Map.of("nested", nullableValue())).toBuilder() + .branch("root.child") + .isolationScope("scope") + .partial(false) + .turnComplete(true) + .errorCode(new FinishReason("error-code")) + .errorMessage("message") + .finishReason(new FinishReason("STOP")) + .usageMetadata( + GenerateContentResponseUsageMetadata.builder() + .promptTokenCount(10) + .candidatesTokenCount(20) + .totalTokenCount(30) + .build()) + .avgLogprobs(-0.25) + .interrupted(false) + .longRunningToolIds(Set.of("call")) + .modelVersion("model-version") + .groundingMetadata(GroundingMetadata.builder().webSearchQueries(List.of("query")).build()) + .customMetadata( + List.of(CustomMetadata.builder().key("source").stringValue("value").build())) + .inputTranscription(Transcription.builder().text("input").finished(true).build()) + .outputTranscription(Transcription.builder().text("output").finished(false).build()) + .output(Map.of("answer", 42)) + .nodeInfo( + NodeInfo.builder() + .path("workflow@1/node@2") + .outputFor(List.of("workflow@1")) + .messageAsOutput(true) + .build()) + .cacheMetadata( + CacheMetadata.builder() + .fingerprint("fingerprint") + .contentsCount(2) + .cacheName("cache/name") + .expireTime(Instant.ofEpochSecond(2_000_000_000L, 123_456_789)) + .invocationsUsed(3) + .createdAt(Instant.ofEpochSecond(1_999_998_200L, 987_654_321)) + .build()) + .actions( + EventActions.builder() + .stateDelta(Map.of("nested", nullableValue())) + .skipSummarization(false) + .artifactDelta(Map.of("artifact", 2)) + .deletedArtifactIds(Set.of("deleted-artifact")) + .transferToAgent("next-agent") + .escalate(false) + .endOfAgent(true) + .requestedAuthConfigs(Map.of("call", Map.of("scheme", "oauth"))) + .requestedToolConfirmations( + Map.of( + "call", + ToolConfirmation.builder() + .hint("Approve?") + .confirmed(false) + .payload(Map.of("value", 1)) + .build())) + .agentState(Map.of("step", 2)) + .setModelResponse(Map.of("ok", true)) + .rewindBeforeInvocationId("previous-invocation") + .route( + List.of( + new Route.Tag("next"), + new Route.Num(2), + new Route.Flag(false), + Route.Default.INSTANCE)) + .compaction( + EventCompaction.builder() + .startTimestamp(1) + .endTimestamp(999) + .compactedContent(Content.fromParts(Part.fromText("summary"))) + .build()) + .build()) + .content( + Content.fromParts( + Part.builder() + .text("thought") + .thought(true) + .thoughtSignature(new byte[] {1, 2, 3}) + .build(), + Part.builder() + .functionCall( + FunctionCall.builder() + .id("call") + .name("tool") + .args( + Map.of( + "input", + "value", + "beyondDoubleInteger", + 9_007_199_254_740_993L, + "beyondLong", + new BigInteger("123456789012345678901234567890"))) + .build()) + .build(), + Part.builder() + .functionResponse( + FunctionResponse.builder() + .id("call") + .name("tool") + .response(Map.of("result", "value")) + .build()) + .build(), + Part.builder() + .inlineData( + Blob.builder().data(new byte[] {4, 5, 6}).mimeType("image/png").build()) + .build())) + .build(); + } + + private static final class BrokenValue { + private int reads; + + public String getValue() { + reads++; + throw new IllegalStateException("private-value must not escape"); + } + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/SessionServiceContract.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/SessionServiceContract.java new file mode 100644 index 000000000..b8ff7df6e --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/SessionServiceContract.java @@ -0,0 +1,276 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; + +/** + * The session behavior that {@link InMemorySessionService}, the reference implementation in core, + * and the JDBC service have in common. The same checks run against the reference itself and against + * every database, so a divergence shows up as a failure rather than in production. + * + *

Where the two deliberately differ (missing sessions, stale writers, duplicate event IDs, what + * survives a reload), the JDBC behavior is in {@link DatabaseSessionContract} and the README. + */ +final class SessionServiceContract { + private static final GetSessionConfig ALL = GetSessionConfig.builder().build(); + private final Supplier services; + + /** + * @param services a service over the same storage on every call; it may be the same instance + */ + SessionServiceContract(Supplier services) { + this.services = services; + } + + Stream tests() { + return Stream.of( + dynamicTest("shared: create, get, list and delete", this::lifecycle), + dynamicTest("shared: a blank session ID asks for a generated one", this::identifiers), + dynamicTest("shared: a duplicate session ID is rejected", this::duplicate), + dynamicTest("shared: sessions are scoped by app, user and ID", this::scope), + dynamicTest("shared: history keeps append order, not time order", this::order), + dynamicTest("shared: the last N events, then the inclusive time cursor", this::filters), + dynamicTest("shared: a delta reaches the app, user and session scopes", this::scopes), + dynamicTest("shared: a removal empties every scope", this::removal), + dynamicTest("shared: temp values reach the supplied session", this::temp), + dynamicTest("shared: a partial event is not stored", this::partial)); + } + + private void lifecycle() { + BaseSessionService service = services.get(); + String app = app(); + Session created = service.createSession(app, "user").blockingGet(); + assertThat(created.id()).isNotEmpty(); + assertThat(created.appName()).isEqualTo(app); + assertThat(created.userId()).isEqualTo("user"); + assertThat(created.state()).isEmpty(); + assertThat(created.immutableEvents()).isEmpty(); + assertThat(load(created).id()).isEqualTo(created.id()); + assertThat(service.listSessions(app, "user").blockingGet().sessionIds()) + .containsExactly(created.id()); + + service.deleteSession(created.sessionKey()).blockingAwait(); + + assertThat(services.get().getSession(created.sessionKey(), ALL).blockingGet()).isNull(); + assertThat(service.listSessions(app, "user").blockingGet().sessions()).isEmpty(); + } + + private void identifiers() { + BaseSessionService service = services.get(); + String app = app(); + Session generated = service.createSession(app, "user", Map.of(), " ").blockingGet(); + assertThat(generated.id().trim()).isNotEmpty(); + assertThat(create(app, "user", "given", Map.of()).id()).isEqualTo("given"); + assertThat(service.listSessions(app, "user").blockingGet().sessionIds()) + .containsExactly(generated.id(), "given"); + } + + private void duplicate() { + String app = app(); + create(app, "user", "id", Map.of("local", "original")); + SessionException error = + assertThrows( + SessionException.class, () -> create(app, "user", "id", Map.of("local", "second"))); + assertThat(error).hasMessageThat().isEqualTo(SessionException.SESSION_ALREADY_EXISTS); + assertThat(load(app, "user", "id").state()).containsExactly("local", "original"); + } + + private void scope() { + BaseSessionService service = services.get(); + String app = app(); + Session a = create(app, "user", "id", Map.of("local", 1)); + Session b = create(app, "other-user", "id", Map.of("local", 2)); + Session c = create(app(), "user", "id", Map.of("local", 3)); + assertThat(load(a).state()).containsExactly("local", 1); + + service.deleteSession(a.sessionKey()).blockingAwait(); + + assertThat(load(b).state()).containsExactly("local", 2); + assertThat(load(c).state()).containsExactly("local", 3); + assertThat(service.listSessions(app, "user").blockingGet().sessions()).isEmpty(); + } + + private void order() { + BaseSessionService service = services.get(); + Session session = create(app(), "user", "id", Map.of()); + int index = 0; + for (long time : new long[] {3000, 1000, 1000, 2000}) { + service.appendEvent(session, event("e-" + index++, time, Map.of())).blockingGet(); + } + List appended = List.of("e-0", "e-1", "e-2", "e-3"); + assertThat(ids(session.immutableEvents())).isEqualTo(appended); + assertThat(ids(load(session).immutableEvents())).isEqualTo(appended); + assertThat(ids(service.listEvents(session.sessionKey()).blockingGet().events())) + .isEqualTo(appended); + assertThat(session.lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(2000)); + assertThat(load(session).lastUpdateTime()).isEqualTo(Instant.ofEpochMilli(2000)); + } + + private void filters() { + BaseSessionService service = services.get(); + Session session = create(app(), "user", "id", Map.of()); + for (long time : new long[] {3000, 1000, 2000}) { + service.appendEvent(session, event("e-" + time, time, Map.of())).blockingGet(); + } + assertThat( + times( + GetSessionConfig.builder() + .numRecentEvents(2) + .afterTimestamp(Instant.ofEpochMilli(1500)) + .build(), + session)) + .containsExactly(2000L); + assertThat( + times( + GetSessionConfig.builder().afterTimestamp(Instant.ofEpochMilli(1000)).build(), + session)) + .containsExactly(3000L, 1000L, 2000L) + .inOrder(); + assertThat(times(GetSessionConfig.builder().numRecentEvents(10).build(), session)) + .containsExactly(3000L, 1000L, 2000L) + .inOrder(); + assertThat(times(GetSessionConfig.builder().numRecentEvents(0).build(), session)).isEmpty(); + assertThat( + service + .listSessions(session.appName(), "user") + .blockingGet() + .sessions() + .get(0) + .immutableEvents()) + .isEmpty(); + } + + private void scopes() { + String app = app(); + Session a = create(app, "user", "a", Map.of()); + Session b = create(app, "user", "b", Map.of()); + Session c = create(app, "other-user", "c", Map.of()); + + services + .get() + .appendEvent(a, event("e", 1, Map.of("app:k", 1, "user:k", 2, "local", 3))) + .blockingGet(); + + assertThat(a.state()).containsExactly("app:k", 1, "user:k", 2, "local", 3); + assertThat(load(a).state()).containsExactly("app:k", 1, "user:k", 2, "local", 3); + assertThat(load(b).state()).containsExactly("app:k", 1, "user:k", 2); + assertThat(load(c).state()).containsExactly("app:k", 1); + } + + private void removal() { + BaseSessionService service = services.get(); + String app = app(); + Session a = create(app, "user", "a", Map.of()); + Session b = create(app, "user", "b", Map.of()); + service + .appendEvent(a, event("set", 1, Map.of("app:k", 1, "user:k", 2, "local", 3, "kept", 4))) + .blockingGet(); + + service + .appendEvent( + a, + event( + "remove", + 2, + Map.of("app:k", State.REMOVED, "user:k", State.REMOVED, "local", State.REMOVED))) + .blockingGet(); + + assertThat(a.state()).containsExactly("kept", 4); + assertThat(load(a).state()).containsExactly("kept", 4); + assertThat(load(b).state()).isEmpty(); + } + + private void temp() { + Session session = create(app(), "user", "id", Map.of()); + // Not something a JSON encoder could write: temp values are handed over as they are. + Object runtime = new Object(); + + services + .get() + .appendEvent(session, event("e", 1, Map.of("temp:runtime", runtime, "local", 1))) + .blockingGet(); + + assertThat(session.state().get("temp:runtime")).isSameInstanceAs(runtime); + assertThat(session.state()).containsEntry("local", 1); + } + + private void partial() { + BaseSessionService service = services.get(); + Session session = create(app(), "user", "id", Map.of()); + Event partial = event("partial", 5, Map.of()).toBuilder().partial(true).build(); + + assertThat(service.appendEvent(session, partial).blockingGet()).isSameInstanceAs(partial); + + assertThat(session.immutableEvents()).isEmpty(); + assertThat(load(session).immutableEvents()).isEmpty(); + service.appendEvent(session, event("complete", 6, Map.of())).blockingGet(); + assertThat(ids(load(session).immutableEvents())).containsExactly("complete"); + } + + private Session create(String app, String user, String id, Map state) { + return services.get().createSession(app, user, state, id).blockingGet(); + } + + private Session load(Session session) { + return load(session.appName(), session.userId(), session.id()); + } + + private Session load(String app, String user, String id) { + return services.get().getSession(new SessionKey(app, user, id), ALL).blockingGet(); + } + + private List times(GetSessionConfig config, Session session) { + return services + .get() + .getSession(session.sessionKey(), config) + .blockingGet() + .immutableEvents() + .stream() + .map(Event::timestamp) + .toList(); + } + + private static List ids(List events) { + return events.stream().map(Event::id).toList(); + } + + private static String app() { + return "shared-" + UUID.randomUUID(); + } + + private static Event event(String id, long timestamp, Map delta) { + return Event.builder() + .id(id) + .invocationId("invocation") + .author("agent") + .timestamp(timestamp) + .actions(EventActions.builder().stateDelta(delta).build()) + .build(); + } +} diff --git a/contrib/database-session-service/src/test/java/com/google/adk/sessions/consumer/ConsumerProbe.java b/contrib/database-session-service/src/test/java/com/google/adk/sessions/consumer/ConsumerProbe.java new file mode 100644 index 000000000..6cd56a396 --- /dev/null +++ b/contrib/database-session-service/src/test/java/com/google/adk/sessions/consumer/ConsumerProbe.java @@ -0,0 +1,166 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.sessions.consumer; + +import com.google.adk.events.Event; +import com.google.adk.events.EventActions; +import com.google.adk.sessions.DatabaseSessionService; +import com.google.adk.sessions.Session; +import com.google.adk.sessions.SessionException; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; +import javax.sql.DataSource; + +/** + * Standalone consumer process, with no JUnit, pool, Mockito, or Testcontainers on its classpath. + */ +public final class ConsumerProbe { + private ConsumerProbe() {} + + public static void main(String[] arguments) { + try { + DatabaseSessionService.Dialect dialect = DatabaseSessionService.Dialect.valueOf(arguments[0]); + boolean migrations = Boolean.parseBoolean(arguments[1]); + absent("com.zaxxer.hikari.HikariDataSource"); + absent("org.testcontainers.DockerClientFactory"); + absent("org.mockito.Mockito"); + absent("org.slf4j.simple.SimpleLoggerFactory"); + String selected = + switch (dialect) { + case SQLITE -> "org.sqlite.JDBC"; + case POSTGRESQL -> "org.postgresql.Driver"; + case MYSQL -> "com.mysql.cj.jdbc.Driver"; + case MARIADB -> "org.mariadb.jdbc.Driver"; + }; + for (String driver : + new String[] { + "org.sqlite.JDBC", + "org.postgresql.Driver", + "com.mysql.cj.jdbc.Driver", + "org.mariadb.jdbc.Driver" + }) { + if (driver.equals(selected)) Class.forName(driver); + else absent(driver); + } + if (!migrations) absent("org.flywaydb.core.Flyway"); + if (!migrations || dialect != DatabaseSessionService.Dialect.POSTGRESQL) + absent("org.flywaydb.database.postgresql.PostgreSQLDatabaseType"); + if (!migrations + || dialect == DatabaseSessionService.Dialect.SQLITE + || dialect == DatabaseSessionService.Dialect.POSTGRESQL) + absent("org.flywaydb.database.mysql.MySQLDatabaseType"); + DataSource source = + source( + System.getenv("ADK_TEST_JDBC_URL"), + System.getenv("ADK_TEST_USERNAME"), + System.getenv("ADK_TEST_PASSWORD")); + DatabaseSessionService service = + DatabaseSessionService.builder(source).dialect(dialect).build(); + if (migrations) service.migrate().blockingAwait(); + else { + try { + service.migrate().blockingAwait(); + throw new IllegalStateException(); + } catch (SessionException expected) { + if (expected.getCause() != null) throw new IllegalStateException(); + } + } + Session session = + service.createSession("consumer-" + UUID.randomUUID(), "user").blockingGet(); + service + .appendEvent( + session, + Event.builder() + .id("event") + .author("agent") + .timestamp(1) + .actions(EventActions.builder().stateDelta(Map.of("retained", true)).build()) + .build()) + .blockingGet(); + Session restored = + new DatabaseSessionService(source).getSession(session.sessionKey(), null).blockingGet(); + if (restored == null + || restored.immutableEvents().size() != 1 + || !Boolean.TRUE.equals(restored.state().get("retained"))) + throw new IllegalStateException(); + System.out.println("ADK_CONSUMER_OK dialect=" + dialect + " migrations=" + migrations); + } catch (Exception | LinkageError failure) { + // Never print raw URLs/passwords or nested JDBC exception messages, even on test failure. + System.err.println("ADK_CONSUMER_FAILURE type=" + failure.getClass().getName()); + System.exit(1); + } + } + + private static void absent(String type) { + try { + Class.forName(type); + throw new IllegalStateException("Unexpected consumer dependency."); + } catch (ClassNotFoundException expected) { + /* This dependency must genuinely be absent. */ + } + } + + private static DataSource source(String url, String user, String password) { + return new DataSource() { + @Override + public Connection getConnection() throws SQLException { + return DriverManager.getConnection(url, user, password); + } + + @Override + public Connection getConnection(String username, String secret) throws SQLException { + return DriverManager.getConnection(url, username, secret); + } + + @Override + public PrintWriter getLogWriter() { + return null; + } + + @Override + public void setLogWriter(PrintWriter writer) {} + + @Override + public int getLoginTimeout() { + return 0; + } + + @Override + public void setLoginTimeout(int timeout) {} + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException(); + } + + @Override + public T unwrap(Class type) throws SQLException { + throw new SQLException(); + } + + @Override + public boolean isWrapperFor(Class type) { + return false; + } + }; + } +} diff --git a/contrib/database-session-service/src/test/resources/db/migration/V1__application.sql b/contrib/database-session-service/src/test/resources/db/migration/V1__application.sql new file mode 100644 index 000000000..5c9a1f0fc --- /dev/null +++ b/contrib/database-session-service/src/test/resources/db/migration/V1__application.sql @@ -0,0 +1,2 @@ +-- Test application migration: ADK must never discover this default location. +CREATE TABLE application_data (id INTEGER NOT NULL PRIMARY KEY); diff --git a/pom.xml b/pom.xml index 0627f3f71..93b74e95c 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,7 @@ contrib/spring-ai contrib/planners contrib/firestore-session-service + contrib/database-session-service examples tutorials/city-time-weather tutorials/live-audio-single-agent @@ -62,6 +63,7 @@ 5.23.0 1.6.0 2.20.2 + 12.4.0 5.3.2 3.7.0 0.18.1 @@ -94,6 +96,21 @@ + + org.flywaydb + flyway-core + ${flyway.version} + + + org.flywaydb + flyway-database-postgresql + ${flyway.version} + + + org.flywaydb + flyway-mysql + ${flyway.version} + com.fasterxml.jackson diff --git a/release-please-config.json b/release-please-config.json index e30c1a9a7..69cba411e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -7,6 +7,7 @@ "extra-files": [ "core/src/main/java/com/google/adk/Version.java", "README.md", - "contrib/firestore-session-service/README.md" + "contrib/firestore-session-service/README.md", + "contrib/database-session-service/README.md" ] }