| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| # |
| |
| from __future__ import annotations |
| |
| from typing import Any, Callable, Mapping |
| |
| from iotdb.SessionPool import PoolConfig, SessionPool |
| from iotdb.table_session import TableSession |
| from iotdb.table_session_pool import TableSessionPoolConfig |
| from iotdb.utils.exception import IoTDBConnectionException |
| |
| from iotdb_mcp_server.target_registry import ( |
| IoTDBTarget, |
| IoTDBTargetRegistry, |
| merge_target, |
| target_from_mapping, |
| ) |
| |
| |
| def target_from_config(config: Any) -> IoTDBTarget: |
| if isinstance(config, IoTDBTarget): |
| return config |
| if hasattr(config, "to_target"): |
| return config.to_target() |
| |
| keys = ( |
| "target_id", |
| "display_name", |
| "target_kind", |
| "kind", |
| "host", |
| "port", |
| "node_urls", |
| "user", |
| "password", |
| "database", |
| "sql_dialect", |
| "timezone", |
| "export_path", |
| "iotdb_home", |
| "use_ssl", |
| "ca_certs", |
| "connection_timeout_in_ms", |
| "enable_redirection", |
| "enable_compression", |
| "fetch_size", |
| "max_retry", |
| "max_pool_size", |
| "tree_wait_timeout_in_ms", |
| "table_wait_timeout_in_ms", |
| "policy", |
| "last_known_good_credential", |
| "verified_at", |
| ) |
| payload: dict[str, Any] = {} |
| for key in keys: |
| if hasattr(config, key): |
| payload[key] = getattr(config, key) |
| if "target_kind" in payload and "kind" not in payload: |
| payload["kind"] = payload["target_kind"] |
| return target_from_mapping(payload) |
| |
| |
| def _target_with_overrides( |
| config_or_target: Any, |
| overrides: Mapping[str, Any] | None = None, |
| ) -> IoTDBTarget: |
| target = target_from_config(config_or_target) |
| clean_overrides = { |
| key: value for key, value in (overrides or {}).items() if value is not None |
| } |
| if not clean_overrides: |
| return target |
| return merge_target(target, clean_overrides) |
| |
| |
| def make_tree_pool_config(target: IoTDBTarget) -> PoolConfig: |
| return PoolConfig( |
| host=target.host, |
| port=target.port, |
| node_urls=None, |
| user_name=target.user, |
| password=target.password, |
| fetch_size=target.fetch_size, |
| time_zone=target.timezone, |
| max_retry=target.max_retry, |
| enable_compression=target.enable_compression, |
| enable_redirection=target.enable_redirection, |
| use_ssl=target.use_ssl, |
| ca_certs=target.ca_certs or None, |
| connection_timeout_in_ms=target.connection_timeout_in_ms, |
| ) |
| |
| |
| def make_table_pool_config( |
| target: IoTDBTarget, database: str | None = None |
| ) -> TableSessionPoolConfig: |
| resolved_database = database if database is not None else target.database |
| return TableSessionPoolConfig( |
| node_urls=list(target.node_urls), |
| username=target.user, |
| password=target.password, |
| max_pool_size=target.max_pool_size, |
| database=resolved_database or None, |
| fetch_size=target.fetch_size, |
| time_zone=target.timezone, |
| enable_redirection=target.enable_redirection, |
| enable_compression=target.enable_compression, |
| wait_timeout_in_ms=target.table_wait_timeout_in_ms, |
| max_retry=target.max_retry, |
| use_ssl=target.use_ssl, |
| ca_certs=target.ca_certs or None, |
| connection_timeout_in_ms=target.connection_timeout_in_ms, |
| ) |
| |
| |
| def create_tree_session_pool( |
| config_or_target: Any, |
| max_pool_size: int | None = None, |
| wait_timeout_in_ms: int | None = None, |
| ) -> SessionPool: |
| target = _target_with_overrides( |
| config_or_target, |
| { |
| "max_pool_size": max_pool_size, |
| "tree_wait_timeout_in_ms": wait_timeout_in_ms, |
| }, |
| ) |
| return SessionPool( |
| make_tree_pool_config(target), |
| target.max_pool_size, |
| target.tree_wait_timeout_in_ms, |
| ) |
| |
| |
| def create_table_session_pool( |
| config_or_target: Any, |
| max_pool_size: int | None = None, |
| database: str | None = None, |
| wait_timeout_in_ms: int | None = None, |
| ) -> Any: |
| target = _target_with_overrides( |
| config_or_target, |
| { |
| "max_pool_size": max_pool_size, |
| "database": database, |
| "table_wait_timeout_in_ms": wait_timeout_in_ms, |
| }, |
| ) |
| return _SingleEndpointTableSessionPool(target) |
| |
| |
| class _SingleEndpointTableSessionPool: |
| """TableSessionPool equivalent that opens only the target's primary endpoint.""" |
| |
| def __init__(self, target: IoTDBTarget) -> None: |
| self.database = target.database or None |
| self._session_pool = SessionPool( |
| make_tree_pool_config(target), |
| target.max_pool_size, |
| target.table_wait_timeout_in_ms, |
| ) |
| self._session_pool.sql_dialect = "table" |
| self._session_pool.database = self.database |
| |
| def get_session(self) -> TableSession: |
| return TableSession(None, session_pool=self._session_pool) |
| |
| @staticmethod |
| def discard_session(table_session: TableSession) -> None: |
| """Close a borrowed raw Session without returning it to its pool.""" |
| raw_session = getattr(table_session, "_TableSession__session", None) |
| if raw_session is None: |
| raise RuntimeError( |
| "Cannot discard TableSession: underlying Session is unavailable." |
| ) |
| raw_session.close() |
| |
| def close(self) -> None: |
| self._session_pool.close() |
| |
| |
| def is_iotdb_connection_error(error: BaseException) -> bool: |
| current: BaseException | None = error |
| seen: set[int] = set() |
| while current is not None and id(current) not in seen: |
| seen.add(id(current)) |
| if isinstance(current, TimeoutError): |
| return False |
| if isinstance(current, (IoTDBConnectionException, ConnectionError, OSError)): |
| return True |
| message = str(current).lower() |
| if any( |
| marker in message |
| for marker in ( |
| "801: authentication failed", |
| "status code 801", |
| "822:", |
| "status code 822", |
| ) |
| ): |
| return True |
| current = current.__cause__ or current.__context__ |
| return False |
| |
| |
| class _ManagedSession: |
| def __init__( |
| self, |
| session: Any, |
| on_connection_error: Callable[[BaseException], None], |
| release_session: Callable[[Any], None] | None = None, |
| discard_session: Callable[[Any], None] | None = None, |
| ): |
| self._session = session |
| self._on_connection_error = on_connection_error |
| self._release_session = release_session |
| self._discard_session = discard_session |
| self._broken = False |
| self._closed = False |
| |
| def _discard(self) -> bool: |
| if self._discard_session is None: |
| return False |
| try: |
| self._discard_session(self._session) |
| except Exception: |
| # Discard is best-effort cleanup. It must not replace either the |
| # database error or a successful result from another pool lease. |
| pass |
| return True |
| |
| def close(self) -> None: |
| if self._closed: |
| return |
| self._closed = True |
| |
| if self._broken and self._discard(): |
| return |
| |
| if self._release_session is None: |
| try: |
| self._session.close() |
| except ConnectionError: |
| if not self._discard(): |
| raise |
| return |
| |
| if not self._broken: |
| try: |
| self._release_session(self._session) |
| except ConnectionError: |
| if not self._discard(): |
| raise |
| return |
| |
| try: |
| self._session.close() |
| finally: |
| try: |
| self._release_session(self._session) |
| except ConnectionError: |
| # The connection-error callback may already have closed and |
| # evicted the pool. The broken session itself is closed above. |
| pass |
| |
| def __getattr__(self, name: str) -> Any: |
| attribute = getattr(self._session, name) |
| if not callable(attribute): |
| return attribute |
| |
| def managed_call(*args: Any, **kwargs: Any) -> Any: |
| try: |
| return attribute(*args, **kwargs) |
| except Exception as exc: |
| if is_iotdb_connection_error(exc): |
| self._broken = True |
| self._on_connection_error(exc) |
| raise |
| |
| return managed_call |
| |
| |
| class _ManagedPool: |
| def __init__( |
| self, |
| pool: Any, |
| on_connection_error: Callable[[BaseException], None], |
| release_session: Callable[[Any], None] | None = None, |
| discard_session: Callable[[Any], None] | None = None, |
| ): |
| self._pool = pool |
| self._on_connection_error = on_connection_error |
| self._release_session = release_session |
| self._discard_session = discard_session |
| |
| def get_session(self) -> _ManagedSession: |
| try: |
| session = self._pool.get_session() |
| except Exception as exc: |
| if is_iotdb_connection_error(exc): |
| self._on_connection_error(exc) |
| raise |
| return _ManagedSession( |
| session, |
| self._on_connection_error, |
| release_session=self._release_session, |
| discard_session=self._discard_session, |
| ) |
| |
| def close(self) -> None: |
| self._pool.close() |
| |
| def __getattr__(self, name: str) -> Any: |
| return getattr(self._pool, name) |
| |
| |
| class IoTDBSessionManager: |
| """Connection-pool manager keyed by IoTDB target and auth fingerprint.""" |
| |
| def __init__(self, registry: IoTDBTargetRegistry) -> None: |
| self.registry = registry |
| self._tree_pools: dict[tuple[str, str, int, int], Any] = {} |
| self._table_pools: dict[tuple[str, str, int, int, str], Any] = {} |
| self._connection_error_callback: Callable[ |
| [str, BaseException, IoTDBTargetRegistry], None |
| ] | None = None |
| |
| @classmethod |
| def from_env(cls) -> "IoTDBSessionManager": |
| return cls(IoTDBTargetRegistry.from_env()) |
| |
| def tree_pool( |
| self, |
| target_id: str | None = None, |
| max_pool_size: int | None = None, |
| wait_timeout_in_ms: int | None = None, |
| ) -> SessionPool: |
| target = self.registry.resolve(target_id) |
| target = _target_with_overrides( |
| target, |
| { |
| "max_pool_size": max_pool_size, |
| "tree_wait_timeout_in_ms": wait_timeout_in_ms, |
| }, |
| ) |
| key = ( |
| target.target_id, |
| target.auth_fingerprint(), |
| target.max_pool_size, |
| target.tree_wait_timeout_in_ms, |
| ) |
| if key not in self._tree_pools: |
| pool: Any = create_tree_session_pool(target) |
| if self._connection_error_callback is not None: |
| pool = _ManagedPool( |
| pool, |
| lambda error: self._evict_failed_target(target.target_id, error), |
| release_session=pool.put_back, |
| discard_session=lambda session: session.close(), |
| ) |
| self._tree_pools[key] = pool |
| return self._tree_pools[key] |
| |
| def table_pool( |
| self, |
| target_id: str | None = None, |
| max_pool_size: int | None = None, |
| database: str | None = None, |
| wait_timeout_in_ms: int | None = None, |
| ) -> Any: |
| target = self.registry.resolve(target_id) |
| target = _target_with_overrides( |
| target, |
| { |
| "max_pool_size": max_pool_size, |
| "database": database, |
| "table_wait_timeout_in_ms": wait_timeout_in_ms, |
| }, |
| ) |
| key = ( |
| target.target_id, |
| target.auth_fingerprint(), |
| target.max_pool_size, |
| target.table_wait_timeout_in_ms, |
| target.database, |
| ) |
| if key not in self._table_pools: |
| pool = create_table_session_pool(target) |
| if self._connection_error_callback is not None: |
| pool = _ManagedPool( |
| pool, |
| lambda error: self._evict_failed_target(target.target_id, error), |
| discard_session=pool.discard_session, |
| ) |
| self._table_pools[key] = pool |
| return self._table_pools[key] |
| |
| def close(self) -> None: |
| for pool in [*self._tree_pools.values(), *self._table_pools.values()]: |
| close = getattr(pool, "close", None) |
| if close is not None: |
| close() |
| self._tree_pools.clear() |
| self._table_pools.clear() |
| |
| def update_registry( |
| self, registry: IoTDBTargetRegistry, *, close_pools: bool = True |
| ) -> None: |
| if close_pools: |
| self.close() |
| self.registry = registry |
| |
| def close_target_pools(self, target_id: str) -> None: |
| tree_pools = [ |
| pool for key, pool in self._tree_pools.items() if key[0] == target_id |
| ] |
| table_pools = [ |
| pool for key, pool in self._table_pools.items() if key[0] == target_id |
| ] |
| self._tree_pools = { |
| key: pool for key, pool in self._tree_pools.items() if key[0] != target_id |
| } |
| self._table_pools = { |
| key: pool for key, pool in self._table_pools.items() if key[0] != target_id |
| } |
| for pool in [*tree_pools, *table_pools]: |
| try: |
| pool.close() |
| except Exception: |
| pass |
| |
| def set_connection_error_callback( |
| self, |
| callback: Callable[[str, BaseException, IoTDBTargetRegistry], None] | None, |
| ) -> None: |
| self._connection_error_callback = callback |
| |
| def _evict_failed_target(self, target_id: str, error: BaseException) -> None: |
| try: |
| updated = self.registry.without_target(target_id) |
| except KeyError: |
| return |
| self.registry = updated |
| self.close_target_pools(target_id) |
| if self._connection_error_callback is not None: |
| self._connection_error_callback(target_id, error, updated) |