Source code for sqlspec.adapters.oracledb.adk.store

"""Oracle ADK store for Google Agent Development Kit session/event storage."""

from decimal import Decimal
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, cast

from typing_extensions import NotRequired, TypedDict

from sqlspec import SQL
from sqlspec.adapters.oracledb._storage import oracle_table_feature_report, validate_oracle_identifier
from sqlspec.adapters.oracledb._typing import DatabaseError as OracleDatabaseError
from sqlspec.adapters.oracledb.data_dictionary import JSONStorageType, OracleVersionInfo, storage_type_from_version
from sqlspec.config import ADKConfig
from sqlspec.extensions.adk import (
    BaseAsyncADKStore,
    BaseSyncADKStore,
    StoredEvent,
    StoredSession,
    normalize_session_list_options,
)
from sqlspec.extensions.adk.memory.store import BaseAsyncADKMemoryStore, BaseSyncADKMemoryStore
from sqlspec.utils.logging import get_logger
from sqlspec.utils.serializers import from_json, to_json
from sqlspec.utils.type_guards import is_async_readable, is_readable

if TYPE_CHECKING:
    from collections.abc import Sequence
    from datetime import datetime, timedelta

    from sqlspec.adapters.oracledb.config import OracleAsyncConfig, OracleSyncConfig
    from sqlspec.extensions.adk import SessionOrderBy, StoredMemory

__all__ = (
    "JSONStorageType",
    "OracleADKCompressionConfig",
    "OracleADKConfig",
    "OracleADKPartitionConfig",
    "OracleAsyncADKMemoryStore",
    "OracleAsyncADKStore",
    "OracleSyncADKMemoryStore",
    "OracleSyncADKStore",
    "coerce_decimal_values",
    "storage_type_from_version",
)

logger = get_logger("sqlspec.adapters.oracledb.adk.store")

ORACLE_TABLE_NOT_FOUND_ERROR: Final = 942
ORACLE_DUPLICATE_KEY_ERROR: Final = 1
ORACLE_DEFAULT_SESSION_TABLE: Final = "adk_session"
ORACLE_DEFAULT_EVENTS_TABLE: Final = "adk_event"
ORACLE_DEFAULT_APP_STATE_TABLE: Final = "adk_app_state"
ORACLE_DEFAULT_USER_STATE_TABLE: Final = "adk_user_state"
ORACLE_DEFAULT_METADATA_TABLE: Final = "adk_internal_metadata"


_ADK_METADATA_TABLE_DDL_TEMPLATE = (
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE TABLE {0} (\n"
    "                key VARCHAR2(128) PRIMARY KEY,\n"
    "                value VARCHAR2(512) NOT NULL\n"
    "            )';\n"
    "        END;\n"
    "        "
)


_ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE = ", {0}"

_ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE_2 = (
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE TABLE {0} (\n"
    "                id VARCHAR2(128) PRIMARY KEY,\n"
    "                app_name VARCHAR2(128) NOT NULL,\n"
    "                user_id VARCHAR2(128) NOT NULL,\n"
    "                {1},\n"
    "                create_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,\n"
    "                update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL{2}\n"
    "            ){3}';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{4}_app_user\n"
    "                ON {5}(app_name, user_id)';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{6}_update_time\n"
    "                ON {7}(update_time DESC)';\n"
    "        END;\n"
    "        "
)

_ADK_EVENTS_TABLE_DDL_FOR_TYPE_TEMPLATE = (
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE TABLE {0} (\n"
    "                id VARCHAR2(128) PRIMARY KEY,\n"
    "                app_name VARCHAR2(128) NOT NULL,\n"
    "                user_id VARCHAR2(128) NOT NULL,\n"
    "                session_id VARCHAR2(128) NOT NULL,\n"
    "                invocation_id VARCHAR2(256),\n"
    "                timestamp TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,\n"
    "                {1},\n"
    "                CONSTRAINT fk_{2}_session FOREIGN KEY (session_id)\n"
    "                    REFERENCES {3}(id) ON DELETE CASCADE\n"
    "            ){4}';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{5}_session\n"
    "                ON {6}(session_id, timestamp ASC)';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{7}_invocation\n"
    "                ON {8}(invocation_id)';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{9}_timestamp\n"
    "                ON {10}(timestamp ASC)';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{11}_app_timestamp\n"
    "                ON {12}(app_name, timestamp ASC)';\n"
    "        END;\n"
    "        "
)

_ADK_APP_STATES_TABLE_DDL_FOR_TYPE_TEMPLATE = (
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE TABLE {0} (\n"
    "                app_name VARCHAR2(128) PRIMARY KEY,\n"
    "                {1},\n"
    "                update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL\n"
    "            ){2}';\n"
    "        END;\n"
    "        "
)

_ADK_USER_STATES_TABLE_DDL_FOR_TYPE_TEMPLATE = (
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE TABLE {0} (\n"
    "                app_name VARCHAR2(128) NOT NULL,\n"
    "                user_id VARCHAR2(128) NOT NULL,\n"
    "                {1},\n"
    "                update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,\n"
    "                PRIMARY KEY (app_name, user_id)\n"
    "            ){2}';\n"
    "        END;\n"
    "        "
)

_ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE = ",\n                {0}"

_ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE_2 = (
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{0}_fts\n"
    "                ON {1}(content_text) INDEXTYPE IS CTXSYS.CONTEXT';\n"
    "        END;\n"
    "            "
)

_ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE_3 = (
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE TABLE {0} (\n"
    "                id VARCHAR2(128) PRIMARY KEY,\n"
    "                session_id VARCHAR2(128) NOT NULL,\n"
    "                app_name VARCHAR2(128) NOT NULL,\n"
    "                user_id VARCHAR2(128) NOT NULL,\n"
    "                scope VARCHAR2(16) DEFAULT ''user'' NOT NULL,\n"
    "                event_id VARCHAR2(128) NOT NULL UNIQUE,\n"
    "                author VARCHAR2(256){1},\n"
    "                timestamp TIMESTAMP WITH TIME ZONE NOT NULL,\n"
    "                {2},\n"
    "                content_text CLOB NOT NULL,\n"
    "                inserted_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL\n"
    "            ){3}';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{4}_app_scope_user_time\n"
    "                ON {5}(app_name, scope, user_id, timestamp DESC)';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{6}_scope\n"
    "                ON {7}(app_name, scope)';\n"
    "        END;\n"
    "\n"
    "        BEGIN\n"
    "            EXECUTE IMMEDIATE 'CREATE INDEX idx_{8}_session\n"
    "                ON {9}(session_id)';\n"
    "        END;\n"
    "        {10}\n"
    "        "
)

_ADK_JSON_COLUMN_DDL_TEMPLATE = "{0} JSON NOT NULL"

_ADK_JSON_COLUMN_DDL_TEMPLATE_2 = "{0} BLOB CHECK ({1} IS JSON) NOT NULL"

_ADK_JSON_COLUMN_DDL_TEMPLATE_3 = "{0} BLOB NOT NULL"


[docs] class OracleADKCompressionConfig(TypedDict): """Oracle-specific ADK table compression settings.""" enabled: NotRequired[bool] """Enable Oracle table compression clauses.""" algorithm: NotRequired[str] """Oracle compression algorithm key."""
[docs] class OracleADKPartitionConfig(TypedDict): """Oracle-specific ADK table partitioning settings.""" strategy: NotRequired[str] """Partitioning strategy, such as ``hash`` or ``range``.""" partition_count: NotRequired[int] """Hash partition count.""" partitions: NotRequired[int] """Alias for ``partition_count``.""" interval: NotRequired[str] """Range partition interval key.""" initial_less_than: NotRequired[str] """Initial range partition upper bound expression.""" partition_key: NotRequired[str] """Default partition key for all ADK tables.""" session_partition_key: NotRequired[str] """Partition key override for the session table.""" events_partition_key: NotRequired[str] """Partition key override for the events table.""" app_state_partition_key: NotRequired[str] """Partition key override for the app state table.""" user_state_partition_key: NotRequired[str] """Partition key override for the user state table.""" memory_partition_key: NotRequired[str] """Partition key override for the memory table."""
[docs] class OracleADKConfig(ADKConfig): """Oracle ADK extension settings consumed by Oracle ADK stores.""" in_memory: NotRequired[bool] """Enable Oracle INMEMORY clauses on ADK tables.""" compression: NotRequired[OracleADKCompressionConfig] """Oracle ADK table compression settings.""" partitioning: NotRequired[OracleADKPartitionConfig] """Oracle ADK table partitioning settings.""" session_table_options: NotRequired[str] """Raw Oracle table options for the ADK session table.""" events_table_options: NotRequired[str] """Raw Oracle table options for the ADK events table.""" app_state_table_options: NotRequired[str] """Raw Oracle table options for the ADK app state table.""" user_state_table_options: NotRequired[str] """Raw Oracle table options for the ADK user state table.""" memory_table_options: NotRequired[str] """Raw Oracle table options for the ADK memory table."""
def coerce_decimal_values(value: Any) -> Any: return _coerce_decimal_values(value) class OracleAsyncADKStore(BaseAsyncADKStore["OracleAsyncConfig"]): """Oracle async ADK store using oracledb async driver. Implements session and event storage for Google Agent Development Kit using Oracle Database via the python-oracledb async driver. Provides: - Session state management with version-specific JSON storage - Full-fidelity event storage via ``event_data`` column - Atomic ``append_event_and_update_state`` for durable session mutations - TIMESTAMP WITH TIME ZONE for timezone-aware timestamps - Foreign key constraints with cascade delete - Efficient upserts using MERGE statement Args: config: OracleAsyncConfig with extension_config["adk"] settings. Notes: - JSON storage type detected by capability (native JSON, BLOB IS JSON, or plain LOB) - event_data stored as JSON (21c+) or BLOB (older versions) - TIMESTAMP WITH TIME ZONE for timezone-aware timestamps - Named parameters using :param_name - State merging handled at application level - owner_id_column supports NUMBER, VARCHAR2, RAW for Oracle FK types - Configuration is read from config.extension_config["adk"] """ __slots__ = ("_in_memory",) def __init__(self, config: "OracleAsyncConfig") -> None: """Initialize Oracle ADK store. Args: config: OracleAsyncConfig instance. Notes: Configuration is read from config.extension_config["adk"]: - session_table: Sessions table name (default: "adk_session") - events_table: Events table name (default: "adk_event") - owner_id_column: Optional owner FK column DDL (default: None) - in_memory: Enable INMEMORY PRIORITY HIGH clause (default: False) """ super().__init__(config) _configure_oracle_adk_session_tables(self, config) adk_config = _adk_config(config) self._in_memory: bool = bool(adk_config.get("in_memory", False)) async def create_tables(self) -> None: """Create the ADK tables that the data dictionary reports as missing. Notes: Detects Oracle version to determine optimal JSON storage type. Consults ``data_dictionary.get_tables`` so existing tables are left untouched instead of relying on an ORA-955 swallow. """ if not self.create_schema_enabled: await self.reconcile_schema() return storage_type = await self._detect_json_storage_type() logger.debug("Creating ADK tables with storage type: %s", storage_type) async with self._config.provide_session() as driver: existing = _existing_table_names(await driver.data_dictionary.get_tables(driver)) if _bare_table_name(self._session_table) not in existing: await driver.execute_script(self._sessions_table_ddl_for_type(storage_type)) if _bare_table_name(self._events_table) not in existing: await driver.execute_script(self._events_table_ddl_for_type(storage_type)) if _bare_table_name(self._app_state_table) not in existing: await driver.execute_script(self._app_states_table_ddl_for_type(storage_type)) if _bare_table_name(self._user_state_table) not in existing: await driver.execute_script(self._user_states_table_ddl_for_type(storage_type)) if _bare_table_name(self._metadata_table) not in existing: await driver.execute_script(await self._metadata_table_ddl()) await driver.commit() async def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new session. Args: session_id: Unique session identifier. app_name: Application name. user_id: User identifier. state: Initial session state. owner_id: Optional owner ID value for owner_id_column (if configured). Returns: Created session record. Notes: Uses SYSTIMESTAMP for create_time and update_time. State is serialized using version-appropriate format. owner_id is ignored if owner_id_column not configured. """ state_data = await self._serialize_state(state) if self._owner_id_column_name: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time, {self._owner_id_column_name}) VALUES (:id, :app_name, :user_id, :state, SYSTIMESTAMP, SYSTIMESTAMP, :owner_id) """ params = { "id": session_id, "app_name": app_name, "user_id": user_id, "state": state_data, "owner_id": owner_id, } else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES (:id, :app_name, :user_id, :state, SYSTIMESTAMP, SYSTIMESTAMP) """ params = {"id": session_id, "app_name": app_name, "user_id": user_id, "state": state_data} async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) await conn.commit() result = await self.get_session(app_name, user_id, session_id) if result is None: msg = "Failed to fetch created session" raise RuntimeError(msg) return result async def get_session( self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None ) -> "StoredSession | None": """Get session by ID. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. renew_for: If positive, touch update_time while reading. Returns: Session record or None if not found. Notes: Oracle returns datetime objects for TIMESTAMP columns. State is deserialized using version-appropriate format. """ try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: if renew_for is not None and self._calculate_expires_at(renew_for) is not None: await cursor.execute( f"UPDATE {self._session_table} SET update_time = SYSTIMESTAMP WHERE app_name = :app_name AND user_id = :user_id AND id = :id", {"app_name": app_name, "user_id": user_id, "id": session_id}, ) await conn.commit() await cursor.execute( f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {self._session_table} WHERE app_name = :app_name AND user_id = :user_id AND id = :id """, {"app_name": app_name, "user_id": user_id, "id": session_id}, ) row = await cursor.fetchone() if row is None: return None session_id_val, app_name, user_id, state_data, create_time, update_time = row state = await self._deserialize_state(state_data) return StoredSession( id=session_id_val, app_name=app_name, user_id=user_id, state=state, create_time=create_time, update_time=update_time, ) except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return None raise async def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: """Update session state. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. state: New state dictionary (replaces existing state). Notes: This replaces the entire state dictionary. Updates update_time to current timestamp. State is serialized using version-appropriate format. """ state_data = await self._serialize_state(state) sql = f""" UPDATE {self._session_table} SET state = :state, update_time = SYSTIMESTAMP WHERE app_name = :app_name AND user_id = :user_id AND id = :id """ async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute( sql, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} ) await conn.commit() async def list_sessions( self, app_name: str, user_id: "str | None" = None, *, order_by: "SessionOrderBy" = "update_time", descending: bool = True, limit: "int | None" = None, offset: "int | None" = None, ) -> "list[StoredSession]": """List sessions for an app, optionally filtered by user. Args: app_name: Application name. user_id: User identifier. If None, lists all sessions for the app. order_by: Timestamp column to sort on. descending: Sort direction for the timestamp column and the id tie-break. limit: Maximum number of sessions to return. offset: Number of leading rows to skip. Returns: List of session records. Notes: Uses composite index on (app_name, user_id) when user_id is provided. State is deserialized using version-appropriate format. """ column, direction, page_limit, page_offset = normalize_session_list_options(order_by, descending, limit, offset) if page_limit == 0: return [] sql, params = _session_list_query( self._session_table, app_name, user_id, column, direction, page_limit, page_offset ) try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) rows = await cursor.fetchall() results = [] for row in rows: state = await self._deserialize_state(row[3]) results.append( StoredSession( id=row[0], app_name=row[1], user_id=row[2], state=state, create_time=row[4], update_time=row[5], ) ) return results except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return [] raise async def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: """Delete session and all associated events (cascade). Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. Notes: Foreign key constraint ensures events are cascade-deleted. """ sql = f"DELETE FROM {self._session_table} WHERE app_name = :app_name AND user_id = :user_id AND id = :id" async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, {"app_name": app_name, "user_id": user_id, "id": session_id}) await conn.commit() async def append_event(self, event_record: StoredEvent) -> None: """Append an event to a session. Args: event_record: Event record. """ sql = f""" INSERT INTO {self._events_table} ( id, app_name, user_id, session_id, invocation_id, timestamp, event_data ) VALUES ( :id, :app_name, :user_id, :session_id, :invocation_id, :timestamp, :event_data ) """ async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute( sql, { "id": event_record["id"], "app_name": event_record["app_name"], "user_id": event_record["user_id"], "session_id": event_record["session_id"], "invocation_id": event_record["invocation_id"], "timestamp": event_record["timestamp"], "event_data": await self._serialize_event_data(event_record["event_data"]), }, ) await conn.commit() async def append_event_and_update_state( self, event_record: StoredEvent, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]", *, app_state: "dict[str, Any] | None" = None, user_state: "dict[str, Any] | None" = None, ) -> StoredSession: """Atomically append an event and update session + scoped state. All writes are executed within a single transaction so they succeed or fail together. """ insert_sql = f""" INSERT INTO {self._events_table} ( id, app_name, user_id, session_id, invocation_id, timestamp, event_data ) VALUES ( :id, :app_name, :user_id, :session_id, :invocation_id, :timestamp, :event_data ) """ state_data = await self._serialize_state(state) update_sql = f""" UPDATE {self._session_table} SET state = :state, update_time = SYSTIMESTAMP WHERE app_name = :app_name AND user_id = :user_id AND id = :id """ select_sql = f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {self._session_table} WHERE app_name = :app_name AND user_id = :user_id AND id = :id """ app_upsert_sql = f""" MERGE INTO {self._app_state_table} target USING (SELECT :app_name AS app_name, :state AS state FROM DUAL) source ON (target.app_name = source.app_name) WHEN MATCHED THEN UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, state, update_time) VALUES (source.app_name, source.state, SYSTIMESTAMP) """ user_upsert_sql = f""" MERGE INTO {self._user_state_table} target USING (SELECT :app_name AS app_name, :user_id AS user_id, :state AS state FROM DUAL) source ON (target.app_name = source.app_name AND target.user_id = source.user_id) WHEN MATCHED THEN UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, user_id, state, update_time) VALUES (source.app_name, source.user_id, source.state, SYSTIMESTAMP) """ async with self._config.provide_connection() as conn: with conn.cursor() as cursor: try: await cursor.execute( update_sql, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} ) await cursor.execute(select_sql, {"app_name": app_name, "user_id": user_id, "id": session_id}) row = await cursor.fetchone() if row is None: _raise_session_not_found(session_id) await cursor.execute( insert_sql, { "id": event_record["id"], "app_name": event_record["app_name"], "user_id": event_record["user_id"], "session_id": event_record["session_id"], "invocation_id": event_record["invocation_id"], "timestamp": event_record["timestamp"], "event_data": await self._serialize_event_data(event_record["event_data"]), }, ) if app_state is not None: await cursor.execute( app_upsert_sql, {"app_name": app_name, "state": await self._serialize_state(app_state)} ) if user_state is not None: await cursor.execute( user_upsert_sql, { "app_name": app_name, "user_id": user_id, "state": await self._serialize_state(user_state), }, ) await conn.commit() except Exception: await conn.rollback() raise session_id_val, row_app_name, row_user_id, state_data_row, create_time, update_time = row return StoredSession( id=session_id_val, app_name=row_app_name, user_id=row_user_id, state=await self._deserialize_state(state_data_row), create_time=create_time, update_time=update_time, ) async def get_events( self, app_name: str, user_id: str, session_id: str, after_timestamp: "datetime | None" = None, limit: "int | None" = None, ) -> "list[StoredEvent]": """Get events for a session. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. after_timestamp: Only return events after this time. limit: Maximum number of events to return. Returns: List of event records ordered by timestamp ASC. """ if limit == 0: return [] where_clauses = ["s.app_name = :app_name", "s.user_id = :user_id", "e.session_id = :session_id"] params: dict[str, Any] = {"app_name": app_name, "user_id": user_id, "session_id": session_id} if after_timestamp is not None: where_clauses.append("e.timestamp > :after_timestamp") params["after_timestamp"] = after_timestamp where_clause = " AND ".join(where_clauses) limit_clause = "" if limit is not None: limit_clause = f" FETCH FIRST {limit} ROWS ONLY" sql = f""" SELECT e.id, e.session_id, e.invocation_id, e.timestamp, e.event_data, s.app_name, s.user_id FROM {self._events_table} e JOIN {self._session_table} s ON e.session_id = s.id WHERE {where_clause} ORDER BY e.timestamp ASC{limit_clause} """ try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) rows = await cursor.fetchall() return [ StoredEvent( id=row[0], session_id=row[1], invocation_id=_oracle_text_value(row[2]), timestamp=row[3], event_data=await self._deserialize_json_field(row[4]) or {}, app_name=row[5], user_id=row[6], ) for row in rows ] except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return [] raise async def delete_expired_events(self, before: "datetime", app_name: "str | None" = None) -> int: sql = f"DELETE FROM {self._events_table} WHERE timestamp < :before" params: dict[str, Any] = {"before": before} if app_name is not None: sql += " AND app_name = :app_name" params["app_name"] = app_name try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) await conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return 0 raise async def delete_idle_sessions(self, updated_before: "datetime", app_name: "str | None" = None) -> int: sql = f"DELETE FROM {self._session_table} WHERE update_time < :updated_before" params: dict[str, Any] = {"updated_before": updated_before} if app_name is not None: sql += " AND app_name = :app_name" params["app_name"] = app_name try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) await conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return 0 raise async def delete_idle_user_states(self, updated_before: "datetime", app_name: "str | None" = None) -> int: sql = f"DELETE FROM {self._user_state_table} WHERE update_time < :updated_before" params: dict[str, Any] = {"updated_before": updated_before} if app_name is not None: sql += " AND app_name = :app_name" params["app_name"] = app_name try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) await conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return 0 raise async def get_app_state(self, app_name: str) -> "dict[str, Any] | None": """Return app-scoped state for an application.""" sql = f"SELECT state FROM {self._app_state_table} WHERE app_name = :app_name" try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, {"app_name": app_name}) row = await cursor.fetchone() return await self._deserialize_state(row[0]) if row is not None else None except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return None raise async def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": """Return user-scoped state for an application user.""" sql = f""" SELECT state FROM {self._user_state_table} WHERE app_name = :app_name AND user_id = :user_id """ try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, {"app_name": app_name, "user_id": user_id}) row = await cursor.fetchone() return await self._deserialize_state(row[0]) if row is not None else None except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return None raise async def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state for an application.""" sql = f""" MERGE INTO {self._app_state_table} target USING (SELECT :app_name AS app_name, :state AS state FROM DUAL) source ON (target.app_name = source.app_name) WHEN MATCHED THEN UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, state, update_time) VALUES (source.app_name, source.state, SYSTIMESTAMP) """ async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, {"app_name": app_name, "state": await self._serialize_state(state)}) await conn.commit() async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: """Insert or replace user-scoped state for an application user.""" sql = f""" MERGE INTO {self._user_state_table} target USING (SELECT :app_name AS app_name, :user_id AS user_id, :state AS state FROM DUAL) source ON (target.app_name = source.app_name AND target.user_id = source.user_id) WHEN MATCHED THEN UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, user_id, state, update_time) VALUES (source.app_name, source.user_id, source.state, SYSTIMESTAMP) """ async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute( sql, {"app_name": app_name, "user_id": user_id, "state": await self._serialize_state(state)} ) await conn.commit() async def get_metadata(self, key: str) -> "str | None": """Return a value from the ADK internal metadata table.""" sql = f"SELECT value FROM {self._metadata_table} WHERE key = :key" try: async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, {"key": key}) row = await cursor.fetchone() return str(row[0]) if row is not None else None except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return None raise async def set_metadata(self, key: str, value: str) -> None: """Set a value in the ADK internal metadata table.""" sql = f""" MERGE INTO {self._metadata_table} target USING (SELECT :key AS key, :value AS value FROM DUAL) source ON (target.key = source.key) WHEN MATCHED THEN UPDATE SET target.value = source.value WHEN NOT MATCHED THEN INSERT (key, value) VALUES (source.key, source.value) """ async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, {"key": key, "value": value}) await conn.commit() async def _sessions_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for sessions table. Auto-detects optimal JSON storage type based on Oracle version. Result is cached to minimize database queries. """ storage_type = await self._detect_json_storage_type() return self._sessions_table_ddl_for_type(storage_type) async def _events_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for events table. Auto-detects optimal JSON storage type based on Oracle version. Result is cached to minimize database queries. """ storage_type = await self._detect_json_storage_type() return self._events_table_ddl_for_type(storage_type) async def _app_states_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for app-scoped state.""" storage_type = await self._detect_json_storage_type() return self._app_states_table_ddl_for_type(storage_type) async def _user_states_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for user-scoped state.""" storage_type = await self._detect_json_storage_type() return self._user_states_table_ddl_for_type(storage_type) async def _metadata_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for ADK internal metadata.""" return _ADK_METADATA_TABLE_DDL_TEMPLATE.format(self._metadata_table) async def _detect_json_storage_type(self) -> JSONStorageType: """Resolve the JSON storage type from the pool-scoped Oracle version. - Oracle 21c+ with compatible >= 20: native JSON type - Oracle 12c+: BLOB with IS JSON constraint - Oracle 11g and earlier: plain BLOB """ return storage_type_from_version(await self._get_version_info()) async def _get_version_info(self) -> "OracleVersionInfo | None": """Return the pool-scoped Oracle version through the data dictionary.""" cache = self._config._oracle_version_cache if cache.resolved: return cache.version async with self._config.provide_session() as driver: version_info = await driver.data_dictionary.get_version(driver) if version_info is None: logger.warning("Could not detect Oracle version, defaulting to BLOB_JSON storage") return version_info async def _serialize_state(self, state: "dict[str, Any]") -> "str | bytes": """Serialize state dictionary to appropriate format based on storage type. Args: state: State dictionary to serialize. Returns: JSON string for JSON_NATIVE, bytes for BLOB types. """ storage_type = await self._detect_json_storage_type() if storage_type == JSONStorageType.JSON_NATIVE: return to_json(state) return to_json(state, as_bytes=True) async def _deserialize_state(self, data: Any) -> "dict[str, Any]": """Deserialize state data from database format. Args: data: Data from database (may be LOB, str, bytes, or dict). Returns: Deserialized state dictionary. Notes: Handles LOB reading if data has read() method. Oracle JSON type may return dict directly. """ if is_async_readable(data): data = await data.read() elif is_readable(data): data = data.read() if isinstance(data, dict): return cast("dict[str, Any]", _coerce_decimal_values(data)) if isinstance(data, bytes): return cast("dict[str, Any]", from_json(data)) if isinstance(data, str): return cast("dict[str, Any]", from_json(data)) return cast("dict[str, Any]", from_json(str(data))) async def _deserialize_json_field(self, data: Any) -> "dict[str, Any] | None": """Deserialize JSON payloads from Oracle JSON/BLOB/LOB values.""" if data is None: return None return await self._deserialize_state(data) async def _serialize_event_data(self, event_data: Any) -> "str | bytes": """Serialize event_data to the configured Oracle JSON storage format.""" storage_type = await self._detect_json_storage_type() event_data = _normalize_event_data_for_storage(event_data) if storage_type == JSONStorageType.JSON_NATIVE: return to_json(event_data) return to_json(event_data, as_bytes=True) async def _read_event_data(self, data: Any) -> str: """Read event_data from database, handling LOB types. Args: data: Data from database (may be LOB, str, or dict). Returns: JSON string. """ if is_async_readable(data): data = await data.read() elif is_readable(data): data = data.read() if isinstance(data, dict): return to_json(data) if isinstance(data, bytes): return data.decode("utf-8") return str(data) def _sessions_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: """Get Oracle CREATE TABLE SQL for sessions with specified storage type. Args: storage_type: JSON storage type to use. Returns: SQL statement to create adk_session table. """ if storage_type == JSONStorageType.JSON_NATIVE: state_column = "state JSON NOT NULL" elif storage_type == JSONStorageType.BLOB_JSON: state_column = "state BLOB CHECK (state IS JSON) NOT NULL" else: state_column = "state BLOB NOT NULL" owner_id_column_sql = ( _ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl) if self._owner_id_column_ddl else "" ) table_clauses = _adk_table_feature_clause( self._config, "session", in_memory=self._in_memory, hash_partition_key="id", range_partition_key="create_time", ) return _ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE_2.format( self._session_table, state_column, owner_id_column_sql, table_clauses, self._session_table, self._session_table, self._session_table, self._session_table, ) def _events_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: """Get Oracle CREATE TABLE SQL for events with specified storage type. The events table stores the full ADK Event in ``event_data`` and keeps scalar event fields indexed for efficient scoped reads. Args: storage_type: JSON storage type to use. Returns: SQL statement to create adk_event table. """ event_data_col = _event_data_column_ddl(storage_type) table_clauses = _adk_table_feature_clause( self._config, "events", in_memory=self._in_memory, hash_partition_key="session_id", range_partition_key="timestamp", ) return _ADK_EVENTS_TABLE_DDL_FOR_TYPE_TEMPLATE.format( self._events_table, event_data_col, self._events_table, self._session_table, table_clauses, self._events_table, self._events_table, self._events_table, self._events_table, self._events_table, self._events_table, self._events_table, self._events_table, ) def _app_states_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: """Get Oracle CREATE TABLE SQL for app-scoped state with specified storage type.""" state_column = _json_column_ddl("state", storage_type) table_clauses = _adk_table_feature_clause( self._config, "app_state", in_memory=self._in_memory, hash_partition_key="app_name", range_partition_key="update_time", ) return _ADK_APP_STATES_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._app_state_table, state_column, table_clauses) def _user_states_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: """Get Oracle CREATE TABLE SQL for user-scoped state with specified storage type.""" state_column = _json_column_ddl("state", storage_type) table_clauses = _adk_table_feature_clause( self._config, "user_state", in_memory=self._in_memory, hash_partition_key="user_id", range_partition_key="update_time", ) return _ADK_USER_STATES_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._user_state_table, state_column, table_clauses) def _drop_app_states_table_sql(self) -> str: return f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._app_state_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """ def _drop_user_states_table_sql(self) -> str: return f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._user_state_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """ def _drop_metadata_table_sql(self) -> str: return f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._metadata_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """ def _drop_tables_sql(self) -> "list[str]": """Get Oracle DROP TABLE SQL statements. Returns: List of SQL statements to drop tables and indexes. Notes: Order matters: drop events table (child) before sessions (parent). Oracle automatically drops indexes when dropping tables. """ return [ self._drop_metadata_table_sql(), self._drop_user_states_table_sql(), self._drop_app_states_table_sql(), f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._events_table}_session'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._session_table}_update_time'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._session_table}_app_user'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._events_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._session_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """, ] class OracleSyncADKStore(BaseSyncADKStore["OracleSyncConfig"]): """Oracle synchronous ADK store using oracledb sync driver. Implements session and event storage for Google Agent Development Kit using Oracle Database via the python-oracledb synchronous driver. Provides: - Session state management with version-specific JSON storage - Full-fidelity event storage via ``event_data`` column - Atomic ``create_event_and_update_state`` for durable session mutations - TIMESTAMP WITH TIME ZONE for timezone-aware timestamps - Foreign key constraints with cascade delete - Efficient upserts using MERGE statement Args: config: OracleSyncConfig with extension_config["adk"] settings. Notes: - JSON storage type detected by capability (native JSON, BLOB IS JSON, or plain LOB) - event_data stored as JSON (21c+) or BLOB (older versions) - TIMESTAMP WITH TIME ZONE for timezone-aware timestamps - Named parameters using :param_name - State merging handled at application level - owner_id_column supports NUMBER, VARCHAR2, RAW for Oracle FK types - Configuration is read from config.extension_config["adk"] """ __slots__ = ("_in_memory",) def __init__(self, config: "OracleSyncConfig") -> None: """Initialize Oracle synchronous ADK store. Args: config: OracleSyncConfig instance. Notes: Configuration is read from config.extension_config["adk"]: - session_table: Sessions table name (default: "adk_session") - events_table: Events table name (default: "adk_event") - owner_id_column: Optional owner FK column DDL (default: None) - in_memory: Enable INMEMORY PRIORITY HIGH clause (default: False) """ super().__init__(config) _configure_oracle_adk_session_tables(self, config) adk_config = _adk_config(config) self._in_memory: bool = bool(adk_config.get("in_memory", False)) def create_tables(self) -> None: """Create the ADK tables that the data dictionary reports as missing. Notes: Detects Oracle version to determine optimal JSON storage type. Consults ``data_dictionary.get_tables`` so existing tables are left untouched instead of relying on an ORA-955 swallow. """ if not self.create_schema_enabled: self.reconcile_schema() return storage_type = self._detect_json_storage_type() logger.info("Creating ADK tables with storage type: %s", storage_type) with self._config.provide_session() as driver: existing = _existing_table_names(driver.data_dictionary.get_tables(driver)) if _bare_table_name(self._session_table) not in existing: driver.execute_script(SQL(self._sessions_table_ddl_for_type(storage_type))) if _bare_table_name(self._events_table) not in existing: driver.execute_script(SQL(self._events_table_ddl_for_type(storage_type))) if _bare_table_name(self._app_state_table) not in existing: driver.execute_script(SQL(self._app_states_table_ddl_for_type(storage_type))) if _bare_table_name(self._user_state_table) not in existing: driver.execute_script(SQL(self._user_states_table_ddl_for_type(storage_type))) if _bare_table_name(self._metadata_table) not in existing: driver.execute_script(SQL(self._metadata_table_ddl())) driver.commit() def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new session. Args: session_id: Unique session identifier. app_name: Application name. user_id: User identifier. state: Initial session state. owner_id: Optional owner ID value for owner_id_column (if configured). Returns: Created session record. Notes: Uses SYSTIMESTAMP for create_time and update_time. State is serialized using version-appropriate format. owner_id is ignored if owner_id_column not configured. """ state_data = self._serialize_state(state) if self._owner_id_column_name: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time, {self._owner_id_column_name}) VALUES (:id, :app_name, :user_id, :state, SYSTIMESTAMP, SYSTIMESTAMP, :owner_id) """ params = { "id": session_id, "app_name": app_name, "user_id": user_id, "state": state_data, "owner_id": owner_id, } else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES (:id, :app_name, :user_id, :state, SYSTIMESTAMP, SYSTIMESTAMP) """ params = {"id": session_id, "app_name": app_name, "user_id": user_id, "state": state_data} with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) conn.commit() result = self.get_session(app_name, user_id, session_id) if result is None: msg = "Failed to fetch created session" raise RuntimeError(msg) return result def get_session( self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None ) -> "StoredSession | None": """Get session by ID. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. renew_for: If positive, touch update_time while reading. Returns: Session record or None if not found. Notes: Oracle returns datetime objects for TIMESTAMP columns. State is deserialized using version-appropriate format. """ sql = f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {self._session_table} WHERE app_name = :app_name AND user_id = :user_id AND id = :id """ try: with self._config.provide_connection() as conn, conn.cursor() as cursor: if renew_for is not None and self._calculate_expires_at(renew_for) is not None: cursor.execute( f"UPDATE {self._session_table} SET update_time = SYSTIMESTAMP WHERE app_name = :app_name AND user_id = :user_id AND id = :id", {"app_name": app_name, "user_id": user_id, "id": session_id}, ) conn.commit() cursor.execute(sql, {"app_name": app_name, "user_id": user_id, "id": session_id}) row = cursor.fetchone() if row is None: return None session_id_val, app_name, user_id, state_data, create_time, update_time = row state = self._deserialize_state(state_data) return StoredSession( id=session_id_val, app_name=app_name, user_id=user_id, state=state, create_time=create_time, update_time=update_time, ) except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return None raise def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: """Update session state. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. state: New state dictionary (replaces existing state). Notes: This replaces the entire state dictionary. Updates update_time to current timestamp. State is serialized using version-appropriate format. """ state_data = self._serialize_state(state) sql = f""" UPDATE {self._session_table} SET state = :state, update_time = SYSTIMESTAMP WHERE app_name = :app_name AND user_id = :user_id AND id = :id """ with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id}) conn.commit() def list_sessions( self, app_name: str, user_id: "str | None" = None, *, order_by: "SessionOrderBy" = "update_time", descending: bool = True, limit: "int | None" = None, offset: "int | None" = None, ) -> "list[StoredSession]": """List sessions for an app, optionally filtered by user. Args: app_name: Application name. user_id: User identifier. If None, lists all sessions for the app. order_by: Timestamp column to sort on. descending: Sort direction for the timestamp column and the id tie-break. limit: Maximum number of sessions to return. offset: Number of leading rows to skip. Returns: List of session records. Notes: Uses composite index on (app_name, user_id) when user_id is provided. State is deserialized using version-appropriate format. """ column, direction, page_limit, page_offset = normalize_session_list_options(order_by, descending, limit, offset) if page_limit == 0: return [] sql, params = _session_list_query( self._session_table, app_name, user_id, column, direction, page_limit, page_offset ) try: with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) rows = cursor.fetchall() results = [] for row in rows: state = self._deserialize_state(row[3]) results.append( StoredSession( id=row[0], app_name=row[1], user_id=row[2], state=state, create_time=row[4], update_time=row[5], ) ) return results except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return [] raise def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: """Delete session and all associated events (cascade). Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. Notes: Foreign key constraint ensures events are cascade-deleted. """ sql = f"DELETE FROM {self._session_table} WHERE app_name = :app_name AND user_id = :user_id AND id = :id" with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"app_name": app_name, "user_id": user_id, "id": session_id}) conn.commit() def append_event(self, event_record: StoredEvent) -> None: """Append an event to a session.""" sql = f""" INSERT INTO {self._events_table} ( id, app_name, user_id, session_id, invocation_id, timestamp, event_data ) VALUES ( :id, :app_name, :user_id, :session_id, :invocation_id, :timestamp, :event_data ) """ with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute( sql, { "id": event_record["id"], "app_name": event_record["app_name"], "user_id": event_record["user_id"], "session_id": event_record["session_id"], "invocation_id": event_record["invocation_id"], "timestamp": event_record["timestamp"], "event_data": self._serialize_event_data(event_record["event_data"]), }, ) conn.commit() def append_event_and_update_state( self, event_record: StoredEvent, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]", *, app_state: "dict[str, Any] | None" = None, user_state: "dict[str, Any] | None" = None, ) -> StoredSession: """Atomically append an event and update session + scoped state.""" insert_sql = f""" INSERT INTO {self._events_table} ( id, app_name, user_id, session_id, invocation_id, timestamp, event_data ) VALUES ( :id, :app_name, :user_id, :session_id, :invocation_id, :timestamp, :event_data ) """ state_data = self._serialize_state(state) update_sql = f""" UPDATE {self._session_table} SET state = :state, update_time = SYSTIMESTAMP WHERE app_name = :app_name AND user_id = :user_id AND id = :id """ select_sql = f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {self._session_table} WHERE app_name = :app_name AND user_id = :user_id AND id = :id """ app_upsert_sql = f""" MERGE INTO {self._app_state_table} target USING (SELECT :app_name AS app_name, :state AS state FROM DUAL) source ON (target.app_name = source.app_name) WHEN MATCHED THEN UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, state, update_time) VALUES (source.app_name, source.state, SYSTIMESTAMP) """ user_upsert_sql = f""" MERGE INTO {self._user_state_table} target USING (SELECT :app_name AS app_name, :user_id AS user_id, :state AS state FROM DUAL) source ON (target.app_name = source.app_name AND target.user_id = source.user_id) WHEN MATCHED THEN UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, user_id, state, update_time) VALUES (source.app_name, source.user_id, source.state, SYSTIMESTAMP) """ with self._config.provide_connection() as conn, conn.cursor() as cursor: try: cursor.execute( update_sql, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} ) cursor.execute(select_sql, {"app_name": app_name, "user_id": user_id, "id": session_id}) row = cursor.fetchone() if row is None: _raise_session_not_found(session_id) cursor.execute( insert_sql, { "id": event_record["id"], "app_name": event_record["app_name"], "user_id": event_record["user_id"], "session_id": event_record["session_id"], "invocation_id": event_record["invocation_id"], "timestamp": event_record["timestamp"], "event_data": self._serialize_event_data(event_record["event_data"]), }, ) if app_state is not None: cursor.execute(app_upsert_sql, {"app_name": app_name, "state": self._serialize_state(app_state)}) if user_state is not None: cursor.execute( user_upsert_sql, {"app_name": app_name, "user_id": user_id, "state": self._serialize_state(user_state)}, ) conn.commit() except Exception: conn.rollback() raise session_id_val, row_app_name, row_user_id, state_data_row, create_time, update_time = row return StoredSession( id=session_id_val, app_name=row_app_name, user_id=row_user_id, state=self._deserialize_state(state_data_row), create_time=create_time, update_time=update_time, ) def get_events( self, app_name: str, user_id: str, session_id: str, after_timestamp: "datetime | None" = None, limit: "int | None" = None, ) -> "list[StoredEvent]": """List events for a session ordered by timestamp. Args: app_name: Application name. user_id: User identifier. session_id: Session identifier. after_timestamp: Only return events after this time. limit: Maximum number of events to return. Returns: List of event records ordered by timestamp ASC. """ if limit == 0: return [] where_clauses = ["s.app_name = :app_name", "s.user_id = :user_id", "e.session_id = :session_id"] params: dict[str, Any] = {"app_name": app_name, "user_id": user_id, "session_id": session_id} if after_timestamp is not None: where_clauses.append("e.timestamp > :after_timestamp") params["after_timestamp"] = after_timestamp where_clause = " AND ".join(where_clauses) limit_clause = f" FETCH FIRST {limit} ROWS ONLY" if limit is not None else "" sql = f""" SELECT e.id, e.session_id, e.invocation_id, e.timestamp, e.event_data, s.app_name, s.user_id FROM {self._events_table} e JOIN {self._session_table} s ON e.session_id = s.id WHERE {where_clause} ORDER BY e.timestamp ASC{limit_clause} """ try: with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) rows = cursor.fetchall() return [ StoredEvent( id=row[0], session_id=row[1], invocation_id=_oracle_text_value(row[2]), timestamp=row[3], event_data=self._deserialize_json_field(row[4]) or {}, app_name=row[5], user_id=row[6], ) for row in rows ] except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return [] raise def delete_expired_events(self, before: "datetime", app_name: "str | None" = None) -> int: """Delete events older than the given timestamp, optionally scoped to one application.""" sql = f"DELETE FROM {self._events_table} WHERE timestamp < :before" params: dict[str, Any] = {"before": before} if app_name is not None: sql += " AND app_name = :app_name" params["app_name"] = app_name try: with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return 0 raise def delete_idle_sessions(self, updated_before: "datetime", app_name: "str | None" = None) -> int: """Delete sessions whose update_time predates the given threshold, optionally scoped to one application.""" sql = f"DELETE FROM {self._session_table} WHERE update_time < :updated_before" params: dict[str, Any] = {"updated_before": updated_before} if app_name is not None: sql += " AND app_name = :app_name" params["app_name"] = app_name try: with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return 0 raise def delete_idle_user_states(self, updated_before: "datetime", app_name: "str | None" = None) -> int: """Delete user-scoped state rows whose update_time predates the given threshold, optionally scoped to one application.""" sql = f"DELETE FROM {self._user_state_table} WHERE update_time < :updated_before" params: dict[str, Any] = {"updated_before": updated_before} if app_name is not None: sql += " AND app_name = :app_name" params["app_name"] = app_name try: with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return 0 raise def get_app_state(self, app_name: str) -> "dict[str, Any] | None": """Return app-scoped state for an application.""" sql = f"SELECT state FROM {self._app_state_table} WHERE app_name = :app_name" try: with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"app_name": app_name}) row = cursor.fetchone() return self._deserialize_state(row[0]) if row is not None else None except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return None raise def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": """Return user-scoped state for an application user.""" sql = f""" SELECT state FROM {self._user_state_table} WHERE app_name = :app_name AND user_id = :user_id """ try: with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"app_name": app_name, "user_id": user_id}) row = cursor.fetchone() return self._deserialize_state(row[0]) if row is not None else None except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return None raise def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state for an application.""" sql = f""" MERGE INTO {self._app_state_table} target USING (SELECT :app_name AS app_name, :state AS state FROM DUAL) source ON (target.app_name = source.app_name) WHEN MATCHED THEN UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, state, update_time) VALUES (source.app_name, source.state, SYSTIMESTAMP) """ with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"app_name": app_name, "state": self._serialize_state(state)}) conn.commit() def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: """Insert or replace user-scoped state for an application user.""" sql = f""" MERGE INTO {self._user_state_table} target USING (SELECT :app_name AS app_name, :user_id AS user_id, :state AS state FROM DUAL) source ON (target.app_name = source.app_name AND target.user_id = source.user_id) WHEN MATCHED THEN UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, user_id, state, update_time) VALUES (source.app_name, source.user_id, source.state, SYSTIMESTAMP) """ with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"app_name": app_name, "user_id": user_id, "state": self._serialize_state(state)}) conn.commit() def get_metadata(self, key: str) -> "str | None": """Return a value from the ADK internal metadata table.""" sql = f"SELECT value FROM {self._metadata_table} WHERE key = :key" try: with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"key": key}) row = cursor.fetchone() return str(row[0]) if row is not None else None except OracleDatabaseError as e: error_obj = e.args[0] if e.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return None raise def set_metadata(self, key: str, value: str) -> None: """Set a value in the ADK internal metadata table.""" sql = f""" MERGE INTO {self._metadata_table} target USING (SELECT :key AS key, :value AS value FROM DUAL) source ON (target.key = source.key) WHEN MATCHED THEN UPDATE SET target.value = source.value WHEN NOT MATCHED THEN INSERT (key, value) VALUES (source.key, source.value) """ with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"key": key, "value": value}) conn.commit() def _sessions_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for sessions table. Auto-detects optimal JSON storage type based on Oracle version. Result is cached to minimize database queries. """ storage_type = self._detect_json_storage_type() return self._sessions_table_ddl_for_type(storage_type) def _events_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for events table. Auto-detects optimal JSON storage type based on Oracle version. Result is cached to minimize database queries. """ storage_type = self._detect_json_storage_type() return self._events_table_ddl_for_type(storage_type) def _app_states_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for app-scoped state.""" storage_type = self._detect_json_storage_type() return self._app_states_table_ddl_for_type(storage_type) def _user_states_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for user-scoped state.""" storage_type = self._detect_json_storage_type() return self._user_states_table_ddl_for_type(storage_type) def _metadata_table_ddl(self) -> str: """Get Oracle CREATE TABLE SQL for ADK internal metadata.""" return _ADK_METADATA_TABLE_DDL_TEMPLATE.format(self._metadata_table) def _detect_json_storage_type(self) -> JSONStorageType: """Resolve the JSON storage type from the pool-scoped Oracle version. - Oracle 21c+ with compatible >= 20: native JSON type - Oracle 12c+: BLOB with IS JSON constraint - Oracle 11g and earlier: plain BLOB """ return storage_type_from_version(self._get_version_info()) def _get_version_info(self) -> "OracleVersionInfo | None": """Return the pool-scoped Oracle version through the data dictionary.""" cache = self._config._oracle_version_cache if cache.resolved: return cache.version with self._config.provide_session() as driver: version_info = driver.data_dictionary.get_version(driver) if version_info is None: logger.warning("Could not detect Oracle version, defaulting to BLOB_JSON storage") return version_info def _serialize_state(self, state: "dict[str, Any]") -> "str | bytes": """Serialize state dictionary to appropriate format based on storage type. Args: state: State dictionary to serialize. Returns: JSON string for JSON_NATIVE, bytes for BLOB types. """ storage_type = self._detect_json_storage_type() if storage_type == JSONStorageType.JSON_NATIVE: return to_json(state) return to_json(state, as_bytes=True) def _deserialize_state(self, data: Any) -> "dict[str, Any]": """Deserialize state data from database format. Args: data: Data from database (may be LOB, str, bytes, or dict). Returns: Deserialized state dictionary. Notes: Handles LOB reading if data has read() method. Oracle JSON type may return dict directly. """ if is_readable(data): data = data.read() if isinstance(data, dict): return cast("dict[str, Any]", _coerce_decimal_values(data)) if isinstance(data, bytes): return cast("dict[str, Any]", from_json(data)) if isinstance(data, str): return cast("dict[str, Any]", from_json(data)) return cast("dict[str, Any]", from_json(str(data))) def _deserialize_json_field(self, data: Any) -> "dict[str, Any] | None": """Deserialize JSON payloads from Oracle JSON/BLOB/LOB values.""" if data is None: return None return self._deserialize_state(data) def _serialize_event_data(self, event_data: Any) -> "str | bytes": """Serialize event_data to the configured Oracle JSON storage format.""" storage_type = self._detect_json_storage_type() event_data = _normalize_event_data_for_storage(event_data) if storage_type == JSONStorageType.JSON_NATIVE: return to_json(event_data) return to_json(event_data, as_bytes=True) def _read_event_data(self, data: Any) -> str: """Read event_data from database, handling LOB types. Args: data: Data from database (may be LOB, str, or dict). Returns: JSON string. """ if is_readable(data): data = data.read() if isinstance(data, dict): return to_json(data) if isinstance(data, bytes): return data.decode("utf-8") return str(data) def _sessions_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: """Get Oracle CREATE TABLE SQL for sessions with specified storage type. Args: storage_type: JSON storage type to use. Returns: SQL statement to create adk_session table. """ if storage_type == JSONStorageType.JSON_NATIVE: state_column = "state JSON NOT NULL" elif storage_type == JSONStorageType.BLOB_JSON: state_column = "state BLOB CHECK (state IS JSON) NOT NULL" else: state_column = "state BLOB NOT NULL" owner_id_column_sql = ( _ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl) if self._owner_id_column_ddl else "" ) table_clauses = _adk_table_feature_clause( self._config, "session", in_memory=self._in_memory, hash_partition_key="id", range_partition_key="create_time", ) return _ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE_2.format( self._session_table, state_column, owner_id_column_sql, table_clauses, self._session_table, self._session_table, self._session_table, self._session_table, ) def _events_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: """Get Oracle CREATE TABLE SQL for events with specified storage type. The events table stores the full ADK Event in ``event_data`` and keeps scalar event fields indexed for efficient scoped reads. Args: storage_type: JSON storage type to use. Returns: SQL statement to create adk_event table. """ event_data_col = _event_data_column_ddl(storage_type) table_clauses = _adk_table_feature_clause( self._config, "events", in_memory=self._in_memory, hash_partition_key="session_id", range_partition_key="timestamp", ) return _ADK_EVENTS_TABLE_DDL_FOR_TYPE_TEMPLATE.format( self._events_table, event_data_col, self._events_table, self._session_table, table_clauses, self._events_table, self._events_table, self._events_table, self._events_table, self._events_table, self._events_table, self._events_table, self._events_table, ) def _app_states_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: """Get Oracle CREATE TABLE SQL for app-scoped state with specified storage type.""" state_column = _json_column_ddl("state", storage_type) table_clauses = _adk_table_feature_clause( self._config, "app_state", in_memory=self._in_memory, hash_partition_key="app_name", range_partition_key="update_time", ) return _ADK_APP_STATES_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._app_state_table, state_column, table_clauses) def _user_states_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: """Get Oracle CREATE TABLE SQL for user-scoped state with specified storage type.""" state_column = _json_column_ddl("state", storage_type) table_clauses = _adk_table_feature_clause( self._config, "user_state", in_memory=self._in_memory, hash_partition_key="user_id", range_partition_key="update_time", ) return _ADK_USER_STATES_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._user_state_table, state_column, table_clauses) def _drop_app_states_table_sql(self) -> str: return f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._app_state_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """ def _drop_user_states_table_sql(self) -> str: return f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._user_state_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """ def _drop_metadata_table_sql(self) -> str: return f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._metadata_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """ def _drop_tables_sql(self) -> "list[str]": """Get Oracle DROP TABLE SQL statements. Returns: List of SQL statements to drop tables and indexes. Notes: Order matters: drop events table (child) before sessions (parent). Oracle automatically drops indexes when dropping tables. """ return [ self._drop_metadata_table_sql(), self._drop_user_states_table_sql(), self._drop_app_states_table_sql(), f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._events_table}_session'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._session_table}_update_time'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._session_table}_app_user'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._events_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._session_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """, ] class OracleAsyncADKMemoryStore(BaseAsyncADKMemoryStore["OracleAsyncConfig"]): """Oracle ADK memory store using async oracledb driver.""" __slots__ = ("_in_memory",) def __init__(self, config: "OracleAsyncConfig") -> None: super().__init__(config) adk_config = _adk_config(config) self._in_memory: bool = bool(adk_config.get("in_memory", False)) async def create_tables(self) -> None: if not self.create_schema_enabled: await self.reconcile_schema() return if not self._enabled: return async with self._config.provide_session() as driver: existing = _existing_table_names(await driver.data_dictionary.get_tables(driver)) if _bare_table_name(self._memory_table) not in existing: await driver.execute_script(await self._memory_table_ddl()) async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) if not entries: return 0 owner_column = f", {self._owner_id_column_name}" if self._owner_id_column_name else "" owner_param = ", :owner_id" if self._owner_id_column_name else "" sql = f""" INSERT INTO {self._memory_table} ( id, session_id, app_name, user_id, scope, event_id, author{owner_column}, timestamp, content_json, content_text, metadata_json, inserted_at ) VALUES ( :id, :session_id, :app_name, :user_id, :scope, :event_id, :author{owner_param}, :timestamp, :content_json, :content_text, :metadata_json, :inserted_at ) """ inserted_count = 0 async with self._config.provide_connection() as conn: with conn.cursor() as cursor: for entry in entries: content_json = await self._serialize_json_field(entry["content_json"]) metadata_json = await self._serialize_json_field(entry["metadata_json"]) params = { "id": entry["id"], "session_id": entry["session_id"], "app_name": entry["app_name"], "user_id": entry["user_id"], "scope": entry.get("scope", "user"), "event_id": entry["event_id"], "author": entry["author"], "timestamp": entry["timestamp"], "content_json": content_json, "content_text": entry["content_text"], "metadata_json": metadata_json, "inserted_at": entry["inserted_at"], } if self._owner_id_column_name: params["owner_id"] = str(owner_id) if owner_id is not None else None if await self._execute_insert_entry(cursor, sql, params): inserted_count += 1 await conn.commit() return inserted_count async def search_entries( self, query: str, app_name: str, user_id: str, limit: "int | None" = None, scope_filter: Literal["all", "user", "app"] = "all", embedding: "Sequence[float] | None" = None, ) -> "list[StoredMemory]": if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) effective_limit = limit if limit is not None else self._max_results try: if self._use_fts: return await self._search_entries_fts(query, app_name, user_id, effective_limit, scope_filter) return await self._search_entries_simple(query, app_name, user_id, effective_limit, scope_filter) except OracleDatabaseError as exc: error_obj = exc.args[0] if exc.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return [] raise async def delete_entries_by_session(self, session_id: str) -> int: sql = f"DELETE FROM {self._memory_table} WHERE session_id = :session_id" async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, {"session_id": session_id}) await conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 async def delete_entries_older_than( self, days: int, app_name: "str | None" = None, scope: "str | None" = None ) -> int: clauses = ["inserted_at < SYSTIMESTAMP - NUMTODSINTERVAL(:days, 'DAY')"] params: dict[str, Any] = {"days": days} if app_name is not None: clauses.append("app_name = :app_name") params["app_name"] = app_name if scope is not None: clauses.append("scope = :scope") params["scope"] = scope where_sql = " AND ".join(clauses) sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) await conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 async def _detect_json_storage_type(self) -> "JSONStorageType": return storage_type_from_version(await self._get_version_info()) async def _get_version_info(self) -> "OracleVersionInfo | None": cache = self._config._oracle_version_cache if cache.resolved: return cache.version async with self._config.provide_session() as driver: version_info = await driver.data_dictionary.get_version(driver) if version_info is None: logger.warning("Could not detect Oracle version, defaulting to BLOB_JSON storage") return version_info async def _serialize_json_field(self, value: Any) -> "str | bytes | None": if value is None: return None storage_type = await self._detect_json_storage_type() if storage_type == JSONStorageType.JSON_NATIVE: return to_json(value) return to_json(value, as_bytes=True) async def _deserialize_json_field(self, data: Any) -> "dict[str, Any] | None": if data is None: return None if is_async_readable(data) or is_readable(data): data = await _read_lob_async(data) return _extract_json_value(data) async def _memory_table_ddl(self) -> str: storage_type = await self._detect_json_storage_type() return self._memory_table_ddl_for_type(storage_type) def _memory_table_ddl_for_type(self, storage_type: "JSONStorageType") -> str: if storage_type == JSONStorageType.JSON_NATIVE: json_columns = """ content_json JSON, metadata_json JSON """ elif storage_type == JSONStorageType.BLOB_JSON: json_columns = """ content_json BLOB CHECK (content_json IS JSON), metadata_json BLOB CHECK (metadata_json IS JSON) """ else: json_columns = """ content_json BLOB, metadata_json BLOB """ owner_id_line = ( _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl) if self._owner_id_column_ddl else "" ) table_clauses = _adk_table_feature_clause( self._config, "memory", in_memory=self._in_memory, hash_partition_key="id", range_partition_key="inserted_at", ) fts_index = "" if self._use_fts: fts_index = _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE_2.format(self._memory_table, self._memory_table) return _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE_3.format( self._memory_table, owner_id_line, json_columns, table_clauses, self._memory_table, self._memory_table, self._memory_table, self._memory_table, self._memory_table, self._memory_table, fts_index, ) def _drop_memory_table_sql(self) -> "list[str]": return [ f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_session'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_app_user_time'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._memory_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """, ] async def _execute_insert_entry(self, cursor: Any, sql: str, params: "dict[str, Any]") -> bool: """Execute an insert and skip duplicate key errors.""" try: await cursor.execute(sql, params) except OracleDatabaseError as exc: error_obj = exc.args[0] if exc.args else None if error_obj and error_obj.code == ORACLE_DUPLICATE_KEY_ERROR: return False raise return True async def _search_entries_fts( self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" ) -> "list[StoredMemory]": where_scope, scope_params = _build_oracle_scope_where(app_name, user_id, scope_filter) sql = f""" SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM ( SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at, SCORE(1) AS score FROM {self._memory_table} WHERE {where_scope} AND CONTAINS(content_text, :query, 1) > 0 ORDER BY score DESC, timestamp DESC ) WHERE ROWNUM <= :limit """ params = {**scope_params, "query": query, "limit": limit} async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) rows = await cursor.fetchall() return await self._rows_to_records(rows) async def _search_entries_simple( self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" ) -> "list[StoredMemory]": where_scope, scope_params = _build_oracle_scope_where(app_name, user_id, scope_filter) sql = f""" SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM ( SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM {self._memory_table} WHERE {where_scope} AND LOWER(content_text) LIKE :pattern ORDER BY timestamp DESC ) WHERE ROWNUM <= :limit """ pattern = f"%{query.lower()}%" params = {**scope_params, "pattern": pattern, "limit": limit} async with self._config.provide_connection() as conn: with conn.cursor() as cursor: await cursor.execute(sql, params) rows = await cursor.fetchall() return await self._rows_to_records(rows) async def _rows_to_records(self, rows: "list[Any]") -> "list[StoredMemory]": records: list[StoredMemory] = [] for row in rows: content_json = await self._deserialize_json_field(row[8]) if row[8] is not None else {} metadata_json = await self._deserialize_json_field(row[10]) content_text = row[9] if is_async_readable(content_text) or is_readable(content_text): content_text = await _read_lob_async(content_text) records.append({ "id": row[0], "session_id": row[1], "app_name": row[2], "user_id": row[3], "scope": row[4], "event_id": row[5], "author": row[6], "timestamp": row[7], "content_json": cast("dict[str, Any]", content_json), "content_text": str(content_text), "metadata_json": metadata_json, "inserted_at": row[11], "embedding": None, }) return records class OracleSyncADKMemoryStore(BaseSyncADKMemoryStore["OracleSyncConfig"]): """Oracle ADK memory store using sync oracledb driver.""" __slots__ = ("_in_memory",) def __init__(self, config: "OracleSyncConfig") -> None: super().__init__(config) adk_config = _adk_config(config) self._in_memory = bool(adk_config.get("in_memory", False)) def create_tables(self) -> None: """Create the memory table when the data dictionary reports it missing.""" if not self.create_schema_enabled: self.reconcile_schema() return if not self._enabled: return with self._config.provide_session() as driver: existing = _existing_table_names(driver.data_dictionary.get_tables(driver)) if _bare_table_name(self._memory_table) not in existing: driver.execute_script(self._memory_table_ddl()) def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with deduplication.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) if not entries: return 0 owner_column = f", {self._owner_id_column_name}" if self._owner_id_column_name else "" owner_param = ", :owner_id" if self._owner_id_column_name else "" sql = f""" INSERT INTO {self._memory_table} ( id, session_id, app_name, user_id, scope, event_id, author{owner_column}, timestamp, content_json, content_text, metadata_json, inserted_at ) VALUES ( :id, :session_id, :app_name, :user_id, :scope, :event_id, :author{owner_param}, :timestamp, :content_json, :content_text, :metadata_json, :inserted_at ) """ inserted_count = 0 with self._config.provide_connection() as conn, conn.cursor() as cursor: for entry in entries: content_json = self._serialize_json_field(entry["content_json"]) metadata_json = self._serialize_json_field(entry["metadata_json"]) params = { "id": entry["id"], "session_id": entry["session_id"], "app_name": entry["app_name"], "user_id": entry["user_id"], "scope": entry.get("scope", "user"), "event_id": entry["event_id"], "author": entry["author"], "timestamp": entry["timestamp"], "content_json": content_json, "content_text": entry["content_text"], "metadata_json": metadata_json, "inserted_at": entry["inserted_at"], } if self._owner_id_column_name: params["owner_id"] = str(owner_id) if owner_id is not None else None if self._execute_insert_entry(cursor, sql, params): inserted_count += 1 conn.commit() return inserted_count def search_entries( self, query: str, app_name: str, user_id: str, limit: "int | None" = None, scope_filter: Literal["all", "user", "app"] = "all", embedding: "Sequence[float] | None" = None, ) -> "list[StoredMemory]": """Search memory entries by text query.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) effective_limit = limit if limit is not None else self._max_results try: if self._use_fts: return self._search_entries_fts(query, app_name, user_id, effective_limit, scope_filter) return self._search_entries_simple(query, app_name, user_id, effective_limit, scope_filter) except OracleDatabaseError as exc: error_obj = exc.args[0] if exc.args else None if error_obj and error_obj.code == ORACLE_TABLE_NOT_FOUND_ERROR: return [] raise def delete_entries_by_session(self, session_id: str) -> int: """Delete all memory entries for a specific session.""" sql = f"DELETE FROM {self._memory_table} WHERE session_id = :session_id" with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, {"session_id": session_id}) conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 def delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: """Delete memory entries older than specified days.""" clauses = ["inserted_at < SYSTIMESTAMP - NUMTODSINTERVAL(:days, 'DAY')"] params: dict[str, Any] = {"days": days} if app_name is not None: clauses.append("app_name = :app_name") params["app_name"] = app_name if scope is not None: clauses.append("scope = :scope") params["scope"] = scope where_sql = " AND ".join(clauses) sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) conn.commit() return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 def _detect_json_storage_type(self) -> "JSONStorageType": return storage_type_from_version(self._get_version_info()) def _get_version_info(self) -> "OracleVersionInfo | None": cache = self._config._oracle_version_cache if cache.resolved: return cache.version with self._config.provide_session() as driver: version_info = driver.data_dictionary.get_version(driver) if version_info is None: logger.warning("Could not detect Oracle version, defaulting to BLOB_JSON storage") return version_info def _serialize_json_field(self, value: Any) -> "str | bytes | None": if value is None: return None storage_type = self._detect_json_storage_type() if storage_type == JSONStorageType.JSON_NATIVE: return to_json(value) return to_json(value, as_bytes=True) def _deserialize_json_field(self, data: Any) -> "dict[str, Any] | None": if data is None: return None if is_readable(data): data = _read_lob_sync(data) return _extract_json_value(data) def _memory_table_ddl(self) -> str: storage_type = self._detect_json_storage_type() return self._memory_table_ddl_for_type(storage_type) def _memory_table_ddl_for_type(self, storage_type: "JSONStorageType") -> str: if storage_type == JSONStorageType.JSON_NATIVE: json_columns = """ content_json JSON, metadata_json JSON """ elif storage_type == JSONStorageType.BLOB_JSON: json_columns = """ content_json BLOB CHECK (content_json IS JSON), metadata_json BLOB CHECK (metadata_json IS JSON) """ else: json_columns = """ content_json BLOB, metadata_json BLOB """ owner_id_line = ( _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl) if self._owner_id_column_ddl else "" ) table_clauses = _adk_table_feature_clause( self._config, "memory", in_memory=self._in_memory, hash_partition_key="id", range_partition_key="inserted_at", ) fts_index = "" if self._use_fts: fts_index = _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE_2.format(self._memory_table, self._memory_table) return _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE_3.format( self._memory_table, owner_id_line, json_columns, table_clauses, self._memory_table, self._memory_table, self._memory_table, self._memory_table, self._memory_table, self._memory_table, fts_index, ) def _drop_memory_table_sql(self) -> "list[str]": return [ f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_session'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_app_user_time'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END; """, f""" BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._memory_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END; """, ] def _execute_insert_entry(self, cursor: Any, sql: str, params: "dict[str, Any]") -> bool: """Execute an insert and skip duplicate key errors.""" try: cursor.execute(sql, params) except OracleDatabaseError as exc: error_obj = exc.args[0] if exc.args else None if error_obj and error_obj.code == ORACLE_DUPLICATE_KEY_ERROR: return False raise return True def _search_entries_fts( self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" ) -> "list[StoredMemory]": where_scope, scope_params = _build_oracle_scope_where(app_name, user_id, scope_filter) sql = f""" SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM ( SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at, SCORE(1) AS score FROM {self._memory_table} WHERE {where_scope} AND CONTAINS(content_text, :query, 1) > 0 ORDER BY score DESC, timestamp DESC ) WHERE ROWNUM <= :limit """ params = {**scope_params, "query": query, "limit": limit} with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) rows = cursor.fetchall() return self._rows_to_records(rows) def _search_entries_simple( self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" ) -> "list[StoredMemory]": where_scope, scope_params = _build_oracle_scope_where(app_name, user_id, scope_filter) sql = f""" SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM ( SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM {self._memory_table} WHERE {where_scope} AND LOWER(content_text) LIKE :pattern ORDER BY timestamp DESC ) WHERE ROWNUM <= :limit """ pattern = f"%{query.lower()}%" params = {**scope_params, "pattern": pattern, "limit": limit} with self._config.provide_connection() as conn, conn.cursor() as cursor: cursor.execute(sql, params) rows = cursor.fetchall() return self._rows_to_records(rows) def _rows_to_records(self, rows: "list[Any]") -> "list[StoredMemory]": records: list[StoredMemory] = [] for row in rows: content_json = self._deserialize_json_field(row[8]) if row[8] is not None else {} metadata_json = self._deserialize_json_field(row[10]) content_text = row[9] if is_readable(content_text): content_text = _read_lob_sync(content_text) records.append({ "id": row[0], "session_id": row[1], "app_name": row[2], "user_id": row[3], "scope": row[4], "event_id": row[5], "author": row[6], "timestamp": row[7], "content_json": cast("dict[str, Any]", content_json), "content_text": str(content_text), "metadata_json": metadata_json, "inserted_at": row[11], "embedding": None, }) return records def _configure_oracle_adk_session_tables(store: Any, config: Any) -> None: """Apply Oracle clean-break ADK table names independent of shared-base drift.""" adk_config = _adk_config(config) table_names = { "_session_table": str(adk_config.get("session_table") or ORACLE_DEFAULT_SESSION_TABLE), "_events_table": str(adk_config.get("events_table") or ORACLE_DEFAULT_EVENTS_TABLE), "_app_state_table": str(adk_config.get("app_state_table") or ORACLE_DEFAULT_APP_STATE_TABLE), "_user_state_table": str(adk_config.get("user_state_table") or ORACLE_DEFAULT_USER_STATE_TABLE), "_metadata_table": str(adk_config.get("metadata_table") or ORACLE_DEFAULT_METADATA_TABLE), } for attribute_name, table_name in table_names.items(): validate_oracle_identifier(table_name, "table name") setattr(store, attribute_name, table_name) def _normalize_event_data_for_storage(event_data: Any) -> Any: """Return event data without ADK 2.2-invalid durable ``actions: null``.""" if isinstance(event_data, dict) and event_data.get("actions") is None: normalized = dict(event_data) normalized.pop("actions", None) return normalized return event_data def _raise_session_not_found(session_id: str) -> NoReturn: msg = f"Session {session_id} not found during append_event_and_update_state." raise ValueError(msg) def _coerce_decimal_values(value: Any) -> Any: if isinstance(value, Decimal): return float(value) if isinstance(value, dict): return {key: _coerce_decimal_values(val) for key, val in value.items()} if isinstance(value, list): return [_coerce_decimal_values(item) for item in value] if isinstance(value, tuple): return tuple(_coerce_decimal_values(item) for item in value) if isinstance(value, set): return {_coerce_decimal_values(item) for item in value} if isinstance(value, frozenset): return frozenset(_coerce_decimal_values(item) for item in value) return value def _oracle_text_value(value: Any) -> str: """Normalize Oracle VARCHAR2 values back to Python strings. Oracle stores empty strings as ``NULL``. The ADK event contract allows empty strings for fields like ``invocation_id``, so reads coerce ``NULL`` back to ``""``. """ return "" if value is None else str(value) def _extract_json_value(data: Any) -> "dict[str, Any]": """Extract and coerce a dictionary from a database JSON payload.""" if isinstance(data, dict): return cast("dict[str, Any]", coerce_decimal_values(data)) if isinstance(data, bytes): return cast("dict[str, Any]", from_json(data)) if isinstance(data, str): return cast("dict[str, Any]", from_json(data)) return cast("dict[str, Any]", from_json(str(data))) def _event_data_column_ddl(storage_type: JSONStorageType) -> str: """Return the DDL fragment for the event_data column.""" if storage_type == JSONStorageType.JSON_NATIVE: return "event_data JSON NOT NULL" if storage_type == JSONStorageType.BLOB_JSON: return "event_data BLOB CHECK (event_data IS JSON) NOT NULL" return "event_data BLOB NOT NULL" def _json_column_ddl(column_name: str, storage_type: JSONStorageType) -> str: """Return an Oracle JSON column DDL fragment for the configured storage type.""" if storage_type == JSONStorageType.JSON_NATIVE: return _ADK_JSON_COLUMN_DDL_TEMPLATE.format(column_name) if storage_type == JSONStorageType.BLOB_JSON: return _ADK_JSON_COLUMN_DDL_TEMPLATE_2.format(column_name, column_name) return _ADK_JSON_COLUMN_DDL_TEMPLATE_3.format(column_name) def _bare_table_name(name: str) -> str: """Return the case-folded, schema-stripped table name for membership checks.""" return name.rsplit(".", 1)[-1].casefold() def _existing_table_names(rows: "list[Any]") -> "set[str]": """Collapse ``data_dictionary.get_tables`` rows into a case-folded name set.""" return {str(row.get("table_name", "")).rsplit(".", 1)[-1].casefold() for row in rows} def _adk_config(config: Any) -> OracleADKConfig: extension_config = getattr(config, "extension_config", {}) if not isinstance(extension_config, dict): return {} adk_config = extension_config.get("adk", {}) if isinstance(adk_config, dict): return cast("OracleADKConfig", adk_config) return {} def _adk_table_feature_clause( config: Any, table_kind: str, *, in_memory: bool, hash_partition_key: str, range_partition_key: str ) -> str: report = oracle_table_feature_report( config, "adk", _adk_config(config), table_kind, in_memory=in_memory, hash_partition_key=hash_partition_key, range_partition_key=range_partition_key, ) return report["clause"] async def _read_lob_async(data: Any) -> Any: if is_async_readable(data): return await data.read() if is_readable(data): return data.read() return data def _read_lob_sync(data: Any) -> Any: if is_readable(data): return data.read() return data def _build_oracle_scope_where( app_name: str, user_id: str, scope_filter: Literal["all", "user", "app"] ) -> tuple[str, dict[str, Any]]: if scope_filter == "all": return "app_name = :app_name AND ((scope = 'user' AND user_id = :user_id) OR scope = 'app')", { "app_name": app_name, "user_id": user_id, } if scope_filter == "user": return "app_name = :app_name AND scope = 'user' AND user_id = :user_id", { "app_name": app_name, "user_id": user_id, } return "app_name = :app_name AND scope = 'app'", {"app_name": app_name} def _session_list_query( session_table: str, app_name: str, user_id: "str | None", column: str, direction: str, limit: "int | None", offset: int, ) -> "tuple[str, dict[str, Any]]": """Return the bounded session-list query and its named binds.""" params: dict[str, Any] = {"app_name": app_name} where_clause = "app_name = :app_name" if user_id is not None: params["user_id"] = user_id where_clause = f"{where_clause} AND user_id = :user_id" page_clause = "" if limit is not None: params["page_limit"] = limit params["page_offset"] = offset page_clause = "\n OFFSET :page_offset ROWS FETCH NEXT :page_limit ROWS ONLY" sql = f""" SELECT id, app_name, user_id, state, create_time, update_time FROM {session_table} WHERE {where_clause} ORDER BY {column} {direction}, id {direction}{page_clause} """ return sql, params