"""IBM Db2 connection pools: thread-local sync connections and a bounded asyncio pool."""
import asyncio
import contextlib
import inspect
import logging
import threading
import time
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any
from sqlspec.adapters.db2.core import build_dsn_string
from sqlspec.exceptions import ConnectionTimeoutError, DatabaseConnectionError, MissingDependencyError
from sqlspec.utils.logging import POOL_LOGGER_NAME, get_logger, log_with_context
from sqlspec.utils.module_loader import import_optional
from sqlspec.utils.uuids import uuid4
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Generator
from types import TracebackType
__all__ = ("Db2AsyncConnectionPool", "Db2AsyncPoolConnectionContext", "Db2SyncConnectionPool")
logger = get_logger(POOL_LOGGER_NAME)
_ADAPTER_NAME = "db2"
_IBM_DB_DBI: "Any | None" = None
def _require_ibm_db_dbi() -> Any:
"""Return the ``ibm_db_dbi`` module, importing it on first use.
Returns:
Any: The ``ibm_db_dbi`` module.
Raises:
MissingDependencyError: When ibm_db is not installed.
"""
global _IBM_DB_DBI
if _IBM_DB_DBI is None:
_IBM_DB_DBI = import_optional("ibm_db_dbi")
if _IBM_DB_DBI is None:
raise MissingDependencyError(package="ibm_db", install_package="db2")
return _IBM_DB_DBI
[docs]
class Db2SyncConnectionPool:
"""Thread-local connection manager for IBM Db2."""
__slots__ = (
"_autocommit",
"_connection_parameters",
"_connection_registry",
"_dsn",
"_generation",
"_health_check_interval",
"_on_connection_create",
"_pool_id",
"_recycle_seconds",
"_registry_lock",
"_thread_local",
)
[docs]
def __init__(
self,
connection_parameters: "dict[str, Any]",
recycle_seconds: int = 86400,
health_check_interval: float = 30.0,
on_connection_create: "Callable[[Any], None] | None" = None,
) -> None:
"""Initialize the thread-local connection manager.
Args:
connection_parameters: Normalized Db2 connection parameters. The CLI connection
string is rendered from them once, here; ``autocommit`` (default True) sets the
autocommit mode every new connection opens in.
recycle_seconds: Connection recycle time in seconds (default 24h).
health_check_interval: Seconds of idle time before running health check.
on_connection_create: Callback executed when connection is created.
"""
self._connection_parameters = connection_parameters
self._dsn = build_dsn_string(connection_parameters)
self._autocommit = bool(connection_parameters.get("autocommit", True))
self._thread_local = threading.local()
self._connection_registry: set[Any] = set()
self._generation = 0
self._registry_lock = threading.Lock()
self._recycle_seconds = recycle_seconds
self._health_check_interval = health_check_interval
self._on_connection_create = on_connection_create
self._pool_id = str(uuid4())[:8]
@property
def _database_name(self) -> str:
"""Get sanitized database name for logging."""
return str(self._connection_parameters.get("database", "unknown"))
def _create_connection(self) -> Any:
"""Open a new connection and register it in the shutdown registry."""
connection = self.new_connection()
with self._registry_lock:
self._connection_registry.add(connection)
return connection
[docs]
def new_connection(self) -> Any:
"""Open a standalone connection configured like a pooled one.
The connection opens in the pool's autocommit mode.
The result is owned by the caller: it is not thread-local and is not
tracked for pool shutdown.
Returns:
Any: A newly opened, fully configured Db2 connection.
Raises:
MissingDependencyError: When ibm_db is not installed.
"""
ibm_db_dbi = _require_ibm_db_dbi()
autocommit_mode = ibm_db_dbi.SQL_AUTOCOMMIT_ON if self._autocommit else ibm_db_dbi.SQL_AUTOCOMMIT_OFF
connection = ibm_db_dbi.connect(self._dsn, "", "", "", "", {ibm_db_dbi.SQL_ATTR_AUTOCOMMIT: autocommit_mode})
if self._on_connection_create is not None:
self._on_connection_create(connection)
return connection
def _is_connection_alive(self, connection: Any) -> bool:
"""Perform a lightweight ping to verify that the physical connection is alive.
Uses SYSIBM.SYSDUMMY1 as standard dummy table across IBM Db2 platforms.
"""
try:
cursor = connection.cursor()
try:
cursor.execute("SELECT 1 FROM SYSIBM.SYSDUMMY1")
cursor.fetchone()
finally:
cursor.close()
except Exception:
return False
return True
def _get_thread_connection(self) -> Any:
"""Retrieve, recycle, or validate the connection assigned to the calling thread."""
thread_state = self._thread_local.__dict__
if thread_state.get("generation") != self._generation:
stale = thread_state.pop("connection", None)
if stale is not None:
self._retire_connection(stale)
thread_state.pop("created_at", None)
thread_state.pop("last_used", None)
self._thread_local.generation = self._generation
if "connection" not in thread_state:
self._thread_local.connection = self._create_connection()
self._thread_local.created_at = time.time()
self._thread_local.last_used = time.time()
return self._thread_local.connection
if self._recycle_seconds > 0 and time.time() - self._thread_local.created_at > self._recycle_seconds:
log_with_context(
logger,
logging.DEBUG,
"pool.connection.recycle",
adapter=_ADAPTER_NAME,
pool_id=self._pool_id,
database=self._database_name,
recycle_seconds=self._recycle_seconds,
reason="exceeded_recycle_time",
)
self._retire_connection(self._thread_local.connection)
self._thread_local.connection = self._create_connection()
self._thread_local.created_at = time.time()
self._thread_local.last_used = time.time()
return self._thread_local.connection
idle_time = time.time() - thread_state.get("last_used", 0)
if idle_time > self._health_check_interval and not self._is_connection_alive(self._thread_local.connection):
log_with_context(
logger,
logging.DEBUG,
"pool.connection.recycle",
adapter=_ADAPTER_NAME,
pool_id=self._pool_id,
database=self._database_name,
idle_seconds=round(idle_time, 1),
reason="failed_health_check",
)
self._retire_connection(self._thread_local.connection)
self._thread_local.connection = self._create_connection()
self._thread_local.created_at = time.time()
self._thread_local.last_used = time.time()
return self._thread_local.connection
def _retire_connection(self, connection: Any) -> None:
"""Close a pool-owned connection and drop it from the shutdown registry."""
with self._registry_lock:
self._connection_registry.discard(connection)
with contextlib.suppress(Exception):
connection.close()
def _close_thread_connection(self) -> None:
"""Close and detach the calling thread's dedicated connection."""
thread_state = self._thread_local.__dict__
if "connection" in thread_state:
self._retire_connection(self._thread_local.connection)
del self._thread_local.connection
if "created_at" in thread_state:
del self._thread_local.created_at
if "last_used" in thread_state:
del self._thread_local.last_used
[docs]
@contextmanager
def get_connection(self) -> "Generator[Any, None, None]":
"""Context manager to yield a thread-local connection.
Yields:
A thread-local Db2 database connection.
"""
connection = self._get_thread_connection()
try:
yield connection
except Exception:
with contextlib.suppress(Exception):
self._close_thread_connection()
raise
[docs]
def close(self) -> None:
"""Close every connection this pool opened across all threads."""
self._close_thread_connection()
with self._registry_lock:
orphaned = list(self._connection_registry)
self._connection_registry.clear()
self._generation += 1
for connection in orphaned:
with contextlib.suppress(Exception):
connection.close()
[docs]
def acquire(self) -> Any:
"""Acquire a thread-local connection."""
return self._get_thread_connection()
[docs]
def release(self, connection: Any) -> None:
"""Release connection back to the thread-local pool."""
_ = connection
[docs]
def size(self) -> int:
"""Return the count of active connections allocated to the current thread."""
try:
_ = self._thread_local.connection
except AttributeError:
return 0
else:
return 1
[docs]
def checked_out(self) -> int:
"""Return the number of checked out connections from the perspective of this thread."""
return 0
class _Db2PooledConnection:
"""Idle-stack record for one pooled async connection."""
__slots__ = ("connection", "created_at", "last_used")
def __init__(self, connection: Any, created_at: float, last_used: float) -> None:
self.connection = connection
self.created_at = created_at
self.last_used = last_used
class Db2AsyncPoolConnectionContext:
"""Async context manager that checks a connection out of a ``Db2AsyncConnectionPool``."""
__slots__ = ("_connection", "_pool")
def __init__(self, pool: "Db2AsyncConnectionPool") -> None:
"""Initialize the context manager.
Args:
pool: Pool to acquire from and release to.
"""
self._pool = pool
self._connection: Any = None
async def __aenter__(self) -> Any:
"""Acquire a pooled connection.
Returns:
Any: The checked-out ``ibm_db_dbi.AsyncConnection``.
"""
self._connection = await self._pool.acquire()
return self._connection
async def __aexit__(
self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None"
) -> None:
"""Release the connection back to the pool."""
connection = self._connection
self._connection = None
await self._pool.release(connection)
[docs]
class Db2AsyncConnectionPool:
"""Bounded asyncio pool of ``ibm_db_dbi.AsyncConnection`` objects.
At most ``max_size`` connections are checked out or being opened at once; callers waiting
longer than ``acquire_timeout`` get ``ConnectionTimeoutError``. Idle connections are reused
most-recently-released first, replaced once older than ``recycle_seconds``, and pinged when
idle for longer than ``health_check_interval``. Closing the pool closes idle connections
immediately and checked-out connections when they are released.
"""
__slots__ = (
"_acquire_timeout",
"_autocommit",
"_checked_out",
"_closed",
"_connection_parameters",
"_dsn",
"_health_check_interval",
"_idle",
"_max_size",
"_on_connection_create",
"_pool_id",
"_recycle_seconds",
"_semaphore_instance",
)
[docs]
def __init__(
self,
connection_parameters: "dict[str, Any]",
*,
max_size: int = 10,
acquire_timeout: float = 30.0,
recycle_seconds: int = 86400,
health_check_interval: float = 30.0,
on_connection_create: "Callable[[Any], Awaitable[None] | None] | None" = None,
) -> None:
"""Initialize the pool.
Args:
connection_parameters: Normalized Db2 connection parameters. The CLI connection
string is rendered from them once, here; ``autocommit`` (default True) sets the
autocommit mode every new connection opens in.
max_size: Maximum number of connections checked out or being opened at once.
acquire_timeout: Seconds to wait for a free slot before raising.
recycle_seconds: Connection age in seconds after which it is replaced (0 disables).
health_check_interval: Seconds of idle time before a connection is pinged on reuse.
on_connection_create: Callback run on every new connection; awaited when it returns
an awaitable.
"""
self._connection_parameters = connection_parameters
self._dsn = build_dsn_string(connection_parameters)
self._autocommit = bool(connection_parameters.get("autocommit", True))
self._max_size = max_size
self._acquire_timeout = acquire_timeout
self._recycle_seconds = recycle_seconds
self._health_check_interval = health_check_interval
self._on_connection_create = on_connection_create
self._idle: list[_Db2PooledConnection] = []
self._checked_out: dict[int, _Db2PooledConnection] = {}
self._closed = False
self._semaphore_instance: asyncio.Semaphore | None = None
self._pool_id = str(uuid4())[:8]
@property
def _semaphore(self) -> asyncio.Semaphore:
"""Return the capacity semaphore, creating it on first use inside the running loop."""
if self._semaphore_instance is None:
self._semaphore_instance = asyncio.Semaphore(self._max_size)
return self._semaphore_instance
@property
def _database_name(self) -> str:
"""Get sanitized database name for logging."""
return str(self._connection_parameters.get("database", "unknown"))
[docs]
async def new_connection(self) -> Any:
"""Open a standalone connection configured like a pooled one.
The connection opens in the pool's autocommit mode and the creation hook runs on it. The
result is owned by the caller and is not tracked by the pool.
Returns:
Any: A newly opened ``ibm_db_dbi.AsyncConnection``.
Raises:
MissingDependencyError: When ibm_db is not installed.
"""
ibm_db_dbi = _require_ibm_db_dbi()
autocommit_mode = ibm_db_dbi.SQL_AUTOCOMMIT_ON if self._autocommit else ibm_db_dbi.SQL_AUTOCOMMIT_OFF
connection = await ibm_db_dbi.AsyncConnection.connect(
self._dsn, "", "", "", "", {ibm_db_dbi.SQL_ATTR_AUTOCOMMIT: autocommit_mode}
)
if self._on_connection_create is not None:
try:
result = self._on_connection_create(connection)
if inspect.isawaitable(result):
await result
except BaseException:
await self._close_connection(connection)
raise
return connection
[docs]
async def acquire(self) -> Any:
"""Check a connection out of the pool.
Returns:
Any: A pooled ``ibm_db_dbi.AsyncConnection``.
Raises:
DatabaseConnectionError: When the pool is closed.
ConnectionTimeoutError: When no slot frees up within ``acquire_timeout`` seconds.
"""
if self._closed:
msg = "Db2 async connection pool is closed"
raise DatabaseConnectionError(msg)
semaphore = self._semaphore
try:
await asyncio.wait_for(semaphore.acquire(), self._acquire_timeout)
except asyncio.TimeoutError as exc:
msg = f"Timed out after {self._acquire_timeout}s waiting for a Db2 connection"
raise ConnectionTimeoutError(msg) from exc
if self._closed:
semaphore.release()
msg = "Db2 async connection pool is closed"
raise DatabaseConnectionError(msg)
try:
record = await self._checkout()
except BaseException:
semaphore.release()
raise
self._checked_out[id(record.connection)] = record
return record.connection
[docs]
async def release(self, connection: Any) -> None:
"""Return a checked-out connection to the pool.
Connections the pool did not hand out are ignored. After ``close()`` the connection is
closed instead of being kept.
Args:
connection: Connection previously returned by ``acquire()``.
"""
record = self._checked_out.pop(id(connection), None)
if record is None:
log_with_context(
logger,
logging.DEBUG,
"pool.connection.release.unknown",
adapter=_ADAPTER_NAME,
pool_id=self._pool_id,
database=self._database_name,
)
return
try:
if self._closed:
await self._close_connection(connection)
else:
record.last_used = time.monotonic()
self._idle.append(record)
finally:
self._semaphore.release()
[docs]
def get_connection(self) -> "Db2AsyncPoolConnectionContext":
"""Return an async context manager that acquires and releases a pooled connection.
Returns:
Db2AsyncPoolConnectionContext: The connection context manager.
"""
return Db2AsyncPoolConnectionContext(self)
[docs]
async def close(self) -> None:
"""Close the pool and every idle connection.
Checked-out connections are closed when they are released.
"""
self._closed = True
idle = self._idle
self._idle = []
for record in idle:
await self._close_connection(record.connection)
[docs]
def size(self) -> int:
"""Return the number of open connections owned by the pool."""
return len(self._idle) + len(self._checked_out)
[docs]
def checked_out(self) -> int:
"""Return the number of connections currently checked out."""
return len(self._checked_out)
async def _checkout(self) -> _Db2PooledConnection:
"""Pop a reusable idle connection, or open a new one when none is left."""
while self._idle:
record = self._idle.pop()
if await self._is_reusable(record):
return record
await self._close_connection(record.connection)
connection = await self.new_connection()
now = time.monotonic()
return _Db2PooledConnection(connection, now, now)
async def _is_reusable(self, record: _Db2PooledConnection) -> bool:
"""Apply the recycle and idle health-check rules to an idle connection."""
now = time.monotonic()
if self._recycle_seconds > 0 and now - record.created_at > self._recycle_seconds:
log_with_context(
logger,
logging.DEBUG,
"pool.connection.recycle",
adapter=_ADAPTER_NAME,
pool_id=self._pool_id,
database=self._database_name,
recycle_seconds=self._recycle_seconds,
reason="exceeded_recycle_time",
)
return False
idle_time = now - record.last_used
if idle_time > self._health_check_interval and not await self._is_connection_alive(record.connection):
log_with_context(
logger,
logging.DEBUG,
"pool.connection.recycle",
adapter=_ADAPTER_NAME,
pool_id=self._pool_id,
database=self._database_name,
idle_seconds=round(idle_time, 1),
reason="failed_health_check",
)
return False
return True
async def _is_connection_alive(self, connection: Any) -> bool:
"""Ping the connection with ``SELECT 1 FROM SYSIBM.SYSDUMMY1``."""
try:
cursor = await connection.cursor()
try:
await cursor.execute("SELECT 1 FROM SYSIBM.SYSDUMMY1")
await cursor.fetchone()
finally:
await cursor.close()
except Exception:
return False
return True
async def _close_connection(self, connection: Any) -> None:
"""Close a connection, suppressing driver errors."""
with contextlib.suppress(Exception):
await connection.close()