Source code for sqlspec.adapters.aiosqlite.pool

"""Multi-connection pool for aiosqlite."""

import asyncio
import logging
import time
from contextlib import suppress
from inspect import isawaitable
from threading import Thread
from typing import TYPE_CHECKING, Any, Final

from sqlspec.adapters.aiosqlite._typing import aiosqlite_module as aiosqlite
from sqlspec.adapters.aiosqlite._typing import aiosqlite_sqlite_module as sqlite3
from sqlspec.adapters.aiosqlite.core import end_transaction, run_on_worker_thread
from sqlspec.exceptions import ImproperConfigurationError, SQLSpecError
from sqlspec.utils.logging import POOL_LOGGER_NAME, get_logger, log_with_context
from sqlspec.utils.uuids import uuid4

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable, Sequence
    from types import TracebackType

    from sqlspec.adapters.aiosqlite._typing import AiosqliteConnection

__all__ = (
    "AiosqliteConnectTimeoutError",
    "AiosqliteConnectionPool",
    "AiosqlitePoolClosedError",
    "AiosqlitePoolConnection",
    "AiosqlitePoolConnectionContext",
)

logger = get_logger(POOL_LOGGER_NAME)

_ADAPTER_NAME = "aiosqlite"
SQLITE_BUSY_TIMEOUT: Final = 5000
SQLITE_DEFAULT_ENABLE_FOREIGN_KEYS: Final = False
SQLITE_DEFAULT_ENABLE_OPTIMIZATIONS: Final = True
SQLITE_MEMORY_CACHE_SIZE: Final = -16000
SQLITE_WAL_SWITCH_ATTEMPTS: Final = 50
SQLITE_WAL_SWITCH_DELAY: Final = 0.01


async def _attempt_wal_switch(connection: "AiosqliteConnection", attempt: int) -> bool:
    """Attempt a single WAL mode switch, returning True on success."""
    try:
        await connection.execute("PRAGMA journal_mode = WAL")
    except sqlite3.OperationalError as exc:
        if "locked" not in str(exc) or attempt == SQLITE_WAL_SWITCH_ATTEMPTS - 1:
            raise
        await asyncio.sleep(SQLITE_WAL_SWITCH_DELAY)
        return False
    return True


async def _enable_wal(connection: "AiosqliteConnection") -> None:
    """Retry database and table locks briefly while switching to WAL mode."""
    for attempt in range(SQLITE_WAL_SWITCH_ATTEMPTS):
        if await _attempt_wal_switch(connection, attempt):
            return


def _dict_row_factory(cursor: Any, row: "tuple[Any, ...]") -> "dict[str, Any]":
    return {description[0]: row[index] for index, description in enumerate(cursor.description)}


def _resolve_row_factory(row_factory: Any) -> Any:
    if row_factory == "row":
        return sqlite3.Row
    if row_factory == "dict":
        return _dict_row_factory
    if row_factory == "tuple":
        return None
    return row_factory


def _has_active_transaction(connection: "AiosqliteConnection") -> bool:
    return bool(getattr(connection, "in_transaction", False))


def _register_runtime_objects(
    connection: "AiosqliteConnection",
    aggregates: "Sequence[dict[str, Any]]",
    collations: "Sequence[dict[str, Any]]",
    window_functions: "Sequence[dict[str, Any]]" = (),
) -> None:
    """Register custom aggregates, collations, and window functions on the worker thread."""
    raw_connection = connection._conn
    for aggregate_config in aggregates:
        raw_connection.create_aggregate(
            aggregate_config["name"], aggregate_config["narg"], aggregate_config["aggregate_class"]
        )
    for collation_config in collations:
        raw_connection.create_collation(collation_config["name"], collation_config["func"])
    create_window_fn = getattr(raw_connection, "create_window_function", None)
    if window_functions and create_window_fn is None:
        msg = "Custom SQLite window functions require Python 3.11 or later"
        raise ImproperConfigurationError(msg)
    if create_window_fn is not None:
        for window_config in window_functions:
            create_window_fn(window_config["name"], window_config["narg"], window_config["window_class"])


async def _apply_runtime_setup(connection: "AiosqliteConnection", runtime_setup: "dict[str, Any]") -> None:
    pragmas = runtime_setup.get("pragmas", ())
    if pragmas:
        pragma_script = "\n".join(f"PRAGMA {pragma_name} = {pragma_value};" for pragma_name, pragma_value in pragmas)
        await connection.executescript(pragma_script)

    extensions = runtime_setup.get("extensions")
    if extensions:
        await connection.enable_load_extension(True)
        try:
            for extension_path in extensions:
                await connection.load_extension(extension_path)
        finally:
            await connection.enable_load_extension(False)

    for function_config in runtime_setup.get("custom_functions", ()):
        await connection.create_function(
            function_config["name"],
            function_config["narg"],
            function_config["func"],
            deterministic=function_config.get("deterministic", False),
        )

    aggregates = runtime_setup.get("custom_aggregates", ())
    collations = runtime_setup.get("custom_collations", ())
    window_functions = runtime_setup.get("custom_window_functions", ())
    if aggregates or collations or window_functions:
        await run_on_worker_thread(
            connection, _register_runtime_objects, connection, aggregates, collations, window_functions
        )

    authorizer_callback = runtime_setup.get("authorizer_callback")
    if authorizer_callback is not None:
        await connection.set_authorizer(authorizer_callback)

    trace_callback = runtime_setup.get("trace_callback")
    if trace_callback is not None:
        await connection.set_trace_callback(trace_callback)

    progress_handler = runtime_setup.get("progress_handler")
    if progress_handler is not None:
        await connection.set_progress_handler(progress_handler, runtime_setup.get("progress_handler_interval", 1000))

    if "row_factory" in runtime_setup:
        connection.row_factory = _resolve_row_factory(runtime_setup["row_factory"])

    if "text_factory" in runtime_setup:
        connection.text_factory = runtime_setup["text_factory"]


class AiosqlitePoolClosedError(SQLSpecError):
    """Pool has been closed and cannot accept new operations."""


class AiosqliteConnectTimeoutError(SQLSpecError):
    """Connection could not be established within the specified timeout period."""


class AiosqlitePoolConnection:
    """Wrapper for database connections in the pool."""

    __slots__ = ("_closed", "_healthy", "connection", "id", "idle_since")

    def __init__(self, connection: "AiosqliteConnection") -> None:
        """Initialize pool connection wrapper.

        Args:
            connection: The raw aiosqlite connection
        """
        self.id = uuid4().hex
        self.connection = connection
        self.idle_since: float | None = None
        self._closed = False
        self._healthy = True

    @property
    def idle_time(self) -> float:
        """Get idle time in seconds.

        Returns:
            Idle time in seconds, 0.0 if connection is in use
        """
        if self.idle_since is None:
            return 0.0
        return time.time() - self.idle_since

    @property
    def is_closed(self) -> bool:
        """Check if connection is closed.

        Returns:
            True if connection is closed
        """
        return self._closed

    @property
    def is_healthy(self) -> bool:
        """Check if connection was healthy on last check.

        Returns:
            True if connection is presumed healthy
        """
        return self._healthy and not self._closed

    def mark_as_in_use(self) -> None:
        """Mark connection as in use."""
        self.idle_since = None

    def mark_as_idle(self) -> None:
        """Mark connection as idle."""
        self.idle_since = time.time()

    def mark_unhealthy(self) -> None:
        """Mark connection as unhealthy."""
        self._healthy = False

    async def is_alive(self) -> bool:
        """Check if connection is alive and functional.

        Returns:
            True if connection is healthy
        """
        if self._closed:
            self._healthy = False
            return False
        try:
            await self.connection.execute("SELECT 1")
        except Exception:
            self._healthy = False
            return False
        else:
            self._healthy = True
            return True

    async def reset(self) -> None:
        """Reset connection to clean state."""
        if self._closed:
            return
        if not _has_active_transaction(self.connection):
            return
        with suppress(Exception):
            await end_transaction(self.connection, commit=False)

    async def close(self) -> None:
        """Close the connection."""
        if self._closed:
            return
        try:
            if _has_active_transaction(self.connection):
                with suppress(Exception):
                    await end_transaction(self.connection, commit=False)
            await self.connection.close()
        except Exception:
            log_with_context(
                logger, logging.DEBUG, "pool.connection.close.error", adapter=_ADAPTER_NAME, connection_id=self.id
            )
        finally:
            self._closed = True


class AiosqlitePoolConnectionContext:
    """Async context manager for pooled aiosqlite connections."""

    __slots__ = ("_connection", "_pool")

    def __init__(self, pool: "AiosqliteConnectionPool") -> None:
        """Initialize the context manager.

        Args:
            pool: Connection pool instance.
        """
        self._pool = pool
        self._connection: AiosqlitePoolConnection | None = None

    async def __aenter__(self) -> "AiosqliteConnection":
        self._connection = await self._pool.acquire()
        return self._connection.connection

    async def __aexit__(
        self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None"
    ) -> "bool | None":
        if self._connection is None:
            return False
        await self._pool.release(self._connection)
        self._connection = None
        return False


[docs] class AiosqliteConnectionPool: """Multi-connection pool for aiosqlite.""" __slots__ = ( "_closed_event", "_connect_timeout", "_connection_parameters", "_connection_registry", "_enable_foreign_keys", "_enable_optimizations", "_health_check_interval", "_idle_timeout", "_lock", "_min_size", "_on_connection_create", "_operation_timeout", "_pool_id", "_pool_size", "_queue", "_runtime_setup", "_warmed", )
[docs] def __init__( self, connection_parameters: "dict[str, Any]", pool_size: int = 5, min_size: int = 0, connect_timeout: float = 30.0, idle_timeout: float = 24 * 60 * 60, operation_timeout: float = 10.0, health_check_interval: float = 30.0, enable_optimizations: bool = SQLITE_DEFAULT_ENABLE_OPTIMIZATIONS, enable_foreign_keys: bool = SQLITE_DEFAULT_ENABLE_FOREIGN_KEYS, on_connection_create: "Callable[[AiosqliteConnection], Awaitable[None]] | None" = None, runtime_setup: "dict[str, Any] | None" = None, ) -> None: """Initialize connection pool. Args: connection_parameters: SQLite connection parameters pool_size: Maximum number of connections in the pool min_size: Minimum connections to pre-create (pool warming) connect_timeout: Maximum time to wait for connection acquisition idle_timeout: Maximum time a connection can remain idle operation_timeout: Maximum time for connection operations health_check_interval: Seconds of idle time before running health check enable_optimizations: Whether to apply performance PRAGMAs enable_foreign_keys: Whether to enable foreign-key enforcement on_connection_create: Async callback executed when connection is created runtime_setup: Runtime feature setup to apply to new connections """ self._connection_parameters = connection_parameters self._pool_size = pool_size self._min_size = min(min_size, pool_size) self._connect_timeout = connect_timeout self._idle_timeout = idle_timeout self._operation_timeout = operation_timeout self._health_check_interval = health_check_interval self._enable_optimizations = enable_optimizations self._enable_foreign_keys = enable_foreign_keys self._on_connection_create = on_connection_create self._runtime_setup = runtime_setup self._connection_registry: dict[str, AiosqlitePoolConnection] = {} self._warmed = False self._pool_id = uuid4().hex[:8] self._queue: asyncio.Queue[AiosqlitePoolConnection] = asyncio.Queue(maxsize=self._pool_size) self._lock: asyncio.Lock = asyncio.Lock() self._closed_event: asyncio.Event = asyncio.Event()
@property def is_closed(self) -> bool: """Check if pool is closed. Returns: True if pool is closed """ return self._closed_event.is_set() @property def _database_name(self) -> str: """Get sanitized database name for logging.""" db = self._connection_parameters.get("database", "unknown") return str(db).split("/")[-1] if db else "unknown" def _set_connect_proxy_daemon(self, connect_proxy: Any) -> None: """Set daemon mode on aiosqlite worker thread before await.""" try: if isinstance(connect_proxy, Thread): connect_proxy.daemon = True return worker_thread = getattr(connect_proxy, "_thread", None) if isinstance(worker_thread, Thread): worker_thread.daemon = True except Exception: log_with_context( logger, logging.DEBUG, "pool.connection.daemon.configure.error", adapter=_ADAPTER_NAME, pool_id=self._pool_id, database=self._database_name, ) async def _force_stop_connection(self, connection: AiosqlitePoolConnection, *, reason: str) -> None: """Force-stop aiosqlite worker thread when graceful close times out.""" with suppress(Exception): raw_conn = getattr(connection.connection, "_conn", None) if raw_conn is not None: raw_conn.interrupt() try: stop_method = getattr(connection.connection, "stop", None) if stop_method is None: log_with_context( logger, logging.DEBUG, "pool.connection.force_stop.unavailable", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, reason=reason, ) return except Exception: log_with_context( logger, logging.DEBUG, "pool.connection.force_stop.unavailable", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, reason=reason, ) return try: stop_result = stop_method() if isawaitable(stop_result): await asyncio.wait_for(stop_result, timeout=self._operation_timeout) log_with_context( logger, logging.DEBUG, "pool.connection.force_stop.success", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, reason=reason, ) except asyncio.TimeoutError: log_with_context( logger, logging.WARNING, "pool.connection.force_stop.timeout", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, timeout_seconds=self._operation_timeout, reason=reason, ) except Exception as e: log_with_context( logger, logging.WARNING, "pool.connection.force_stop.error", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, reason=reason, error=str(e), )
[docs] def size(self) -> int: """Get total number of connections in pool. Returns: Total connection count """ return len(self._connection_registry)
[docs] def checked_out(self) -> int: """Get number of checked out connections. Returns: Number of connections currently in use """ return len(self._connection_registry) - self._queue.qsize()
async def _create_connection(self) -> AiosqlitePoolConnection: """Create a new pooled connection and register it. Returns: New pool connection instance """ connection = await self.new_connection() try: pool_connection = AiosqlitePoolConnection(connection) pool_connection.mark_as_idle() async with self._lock: self._connection_registry[pool_connection.id] = pool_connection except BaseException: with suppress(BaseException): await connection.close() raise return pool_connection
[docs] async def new_connection(self) -> "AiosqliteConnection": """Open a standalone connection configured exactly like a pooled one. The result is owned by the caller: the pool neither tracks nor closes it. Returns: A newly opened, fully configured connection. """ connect_proxy = aiosqlite.connect(**self._connection_parameters) self._set_connect_proxy_daemon(connect_proxy) connection = await connect_proxy pragma_lines: list[str] = [] try: database_path = str(self._connection_parameters.get("database", "")) is_shared_cache = "cache=shared" in database_path is_memory_db = ":memory:" in database_path or "mode=memory" in database_path try: if self._enable_optimizations: if is_memory_db: pragma_lines.extend([ "PRAGMA journal_mode = MEMORY", "PRAGMA synchronous = OFF", "PRAGMA temp_store = MEMORY", f"PRAGMA cache_size = {SQLITE_MEMORY_CACHE_SIZE}", ]) else: await _enable_wal(connection) pragma_lines.append("PRAGMA synchronous = NORMAL") pragma_lines.append(f"PRAGMA busy_timeout = {SQLITE_BUSY_TIMEOUT}") if self._enable_foreign_keys: pragma_lines.append("PRAGMA foreign_keys = ON") if is_shared_cache and is_memory_db: pragma_lines.append("PRAGMA read_uncommitted = ON") if pragma_lines: await connection.executescript(";\n".join(pragma_lines) + ";") await connection.commit() except sqlite3.OperationalError: raise except Exception: log_with_context( logger, logging.WARNING, "pool.connection.configure.error", adapter=_ADAPTER_NAME, pool_id=self._pool_id, database=self._database_name, ) for pragma_statement in pragma_lines: if pragma_statement == "PRAGMA foreign_keys = ON": continue with suppress(Exception): await connection.execute(pragma_statement) if self._enable_foreign_keys: await connection.execute("PRAGMA foreign_keys = ON") await connection.commit() if self._runtime_setup is not None: await _apply_runtime_setup(connection, self._runtime_setup) if self._on_connection_create is not None: await self._on_connection_create(connection) except BaseException: with suppress(BaseException): await connection.close() raise return connection
async def _claim_if_healthy(self, connection: AiosqlitePoolConnection) -> bool: """Check if connection is healthy and claim it. Uses passive health checks: connections idle less than health_check_interval are assumed healthy based on their last known state. Active health checks (SELECT 1) are only performed on long-idle connections. Args: connection: Connection to check and claim Returns: True if connection was claimed """ if connection.idle_time > self._idle_timeout: await self._retire_connection(connection, reason="idle_timeout") return False if not connection.is_healthy: await self._retire_connection(connection, reason="unhealthy") return False if connection.idle_time > self._health_check_interval: try: is_alive = await asyncio.wait_for(connection.is_alive(), timeout=self._operation_timeout) if not is_alive: await self._retire_connection(connection, reason="health_check_failed") return False except asyncio.TimeoutError: await self._retire_connection(connection, reason="health_check_timeout") return False connection.mark_as_in_use() return True async def _retire_connection(self, connection: AiosqlitePoolConnection, *, reason: str | None = None) -> None: """Retire a connection from the pool. Args: connection: Connection to retire reason: Optional reason for retirement """ if reason: log_with_context( logger, logging.DEBUG, "pool.connection.retire", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, reason=reason, ) async with self._lock: self._connection_registry.pop(connection.id, None) try: await asyncio.wait_for(connection.close(), timeout=self._operation_timeout) except asyncio.TimeoutError: log_with_context( logger, logging.WARNING, "pool.connection.close.timeout", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, timeout_seconds=self._operation_timeout, ) await self._force_stop_connection(connection, reason="retire_close_timeout") async def _try_provision_new_connection(self) -> "AiosqlitePoolConnection | None": """Try to create a new connection if under capacity. Returns: New connection if successful, None if at capacity """ async with self._lock: if len(self._connection_registry) >= self._pool_size: return None try: connection = await self._create_connection() except Exception: log_with_context( logger, logging.WARNING, "pool.connection.create.error", adapter=_ADAPTER_NAME, pool_id=self._pool_id, database=self._database_name, pool_size=len(self._connection_registry), max_size=self._pool_size, ) raise else: connection.mark_as_in_use() return connection async def _wait_for_healthy_connection(self) -> AiosqlitePoolConnection: """Wait for a healthy connection to become available. Returns: Available healthy connection Raises: AiosqlitePoolClosedError: If pool is closed while waiting """ while True: get_connection_task = asyncio.create_task(self._queue.get()) pool_closed_task = asyncio.create_task(self._closed_event.wait()) done, pending = await asyncio.wait( {get_connection_task, pool_closed_task}, return_when=asyncio.FIRST_COMPLETED ) try: if pool_closed_task in done: msg = "Pool closed during connection acquisition" raise AiosqlitePoolClosedError(msg) connection = get_connection_task.result() if await self._claim_if_healthy(connection): return connection finally: for task in pending: task.cancel() with suppress(asyncio.CancelledError): await task async def _warm_pool(self) -> None: """Pre-create minimum connections for pool warming. Creates connections up to min_size to avoid cold-start latency on first requests. """ if self._warmed or self._min_size <= 0: return self._warmed = True connections_needed = self._min_size - len(self._connection_registry) if connections_needed <= 0: return log_with_context( logger, logging.DEBUG, "pool.warmup.start", adapter=_ADAPTER_NAME, pool_id=self._pool_id, database=self._database_name, connections_needed=connections_needed, min_size=self._min_size, ) tasks = [self._create_connection() for _ in range(connections_needed)] results = await asyncio.gather(*tasks, return_exceptions=True) for result in results: if isinstance(result, AiosqlitePoolConnection): self._queue.put_nowait(result) elif isinstance(result, Exception): log_with_context( logger, logging.WARNING, "pool.warmup.connection.error", adapter=_ADAPTER_NAME, pool_id=self._pool_id, error=str(result), ) async def _get_connection(self) -> AiosqlitePoolConnection: """Run the three-phase connection acquisition cycle. Returns: Available connection Raises: AiosqlitePoolClosedError: If pool is closed """ if self._closed_event.is_set(): msg = "Cannot acquire connection from closed pool" raise AiosqlitePoolClosedError(msg) if not self._warmed and self._min_size > 0: await self._warm_pool() while not self._queue.empty(): connection = self._queue.get_nowait() if connection.idle_since is not None: idle_time = time.time() - connection.idle_since if idle_time <= self._health_check_interval and connection.is_healthy: connection.idle_since = None return connection if await self._claim_if_healthy(connection): return connection if len(self._connection_registry) < self._pool_size: new_connection = await self._try_provision_new_connection() if new_connection is not None: return new_connection return await self._wait_for_healthy_connection()
[docs] async def acquire(self) -> AiosqlitePoolConnection: """Acquire a connection from the pool. Returns: Available connection Raises: AiosqliteConnectTimeoutError: If acquisition times out """ try: connection = await asyncio.wait_for(self._get_connection(), timeout=self._connect_timeout) except AiosqlitePoolClosedError: raise except asyncio.TimeoutError as e: msg = f"Connection acquisition timed out after {self._connect_timeout}s" raise AiosqliteConnectTimeoutError(msg) from e return connection
[docs] async def release(self, connection: AiosqlitePoolConnection) -> None: """Release a connection back to the pool. Args: connection: Connection to release """ if self._closed_event.is_set(): await self._retire_connection(connection) return if connection.id not in self._connection_registry: log_with_context( logger, logging.WARNING, "pool.connection.release.unknown", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, ) return try: if _has_active_transaction(connection.connection): with suppress(Exception): await end_transaction(connection.connection, commit=False) connection.idle_since = time.time() self._queue.put_nowait(connection) except Exception as e: log_with_context( logger, logging.WARNING, "pool.connection.reset.error", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connection.id, error=str(e), ) connection.mark_unhealthy() await self._retire_connection(connection)
[docs] def get_connection(self) -> "AiosqlitePoolConnectionContext": """Get a connection with automatic release.""" return AiosqlitePoolConnectionContext(self)
[docs] async def close(self) -> None: """Close the connection pool.""" if self.is_closed: return self._closed_event.set() while not self._queue.empty(): self._queue.get_nowait() async with self._lock: connections = list(self._connection_registry.values()) self._connection_registry.clear() if connections: for conn in connections: with suppress(Exception): raw_conn = getattr(conn.connection, "_conn", None) if raw_conn is not None: raw_conn.interrupt() close_tasks = [asyncio.wait_for(conn.close(), timeout=self._operation_timeout) for conn in connections] results = await asyncio.gather(*close_tasks, return_exceptions=True) for i, result in enumerate(results): if isinstance(result, Exception): if isinstance(result, asyncio.TimeoutError): await self._force_stop_connection(connections[i], reason="pool_close_timeout") log_with_context( logger, logging.WARNING, "pool.close.connection.error", adapter=_ADAPTER_NAME, pool_id=self._pool_id, connection_id=connections[i].id, error=str(result), )