blob: e07aadddafb8b44d11787f176ee8828c4efb7830 [file]
from __future__ import annotations
import copy
from contextlib import contextmanager
import os
from pathlib import Path
import time
from typing import Any, Iterator, Mapping
from iotdb.Session import Session
from iotdb.table_session import TableSession, TableSessionConfig
from iotdb_mcp_server.config import Config
from iotdb_mcp_server.session_manager import (
create_table_session_pool,
create_tree_session_pool,
)
from iotdb_mcp_server.target_registry import IoTDBTargetRegistry
from iotdb_mcp_server.services.json_response import (
reset_iotdb_target_context,
set_iotdb_target_context,
)
TargetSelector = str | Mapping[str, Any] | None
_AINODE_PROBE_CACHE: dict[str, dict[str, Any]] = {}
_DEFAULT_AINODE_PROBE_TTL_SECONDS = 10.0
def _copy_target_fields(config: Config, source: Config) -> None:
for field_name in (
"host",
"port",
"user",
"password",
"database",
"sql_dialect",
"timezone",
"export_path",
"target_id",
"display_name",
"target_kind",
"node_urls",
"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",
"target_fingerprint",
"policy",
"last_known_good_credential",
"verified_at",
):
setattr(config, field_name, getattr(source, field_name))
def apply_target_registry(
config: Config,
registry: IoTDBTargetRegistry,
*,
active_target_id: str | None = None,
targets_file_mtime_ns: int | None = None,
close_pools: bool = True,
) -> Config:
if not registry.list_targets(include_secret=False):
config.target_registry = registry
config.target_id = ""
config.display_name = ""
config.target_kind = ""
config.host = ""
config.port = 0
config.node_urls = ()
config.user = ""
config.password = ""
config.database = ""
config.target_fingerprint = ""
config.policy = {}
config.last_known_good_credential = {}
config.verified_at = ""
if config.session_manager is not None:
config.session_manager.update_registry(registry, close_pools=close_pools)
return config
active_target = registry.resolve(active_target_id or registry.default_target_id)
selected = Config.from_target(
active_target,
target_registry=registry,
session_manager=config.session_manager,
)
_copy_target_fields(config, selected)
config.target_registry = registry
if targets_file_mtime_ns is not None:
config.targets_file_mtime_ns = targets_file_mtime_ns
if config.session_manager is not None:
update = getattr(config.session_manager, "update_registry", None)
if update is not None:
update(registry, close_pools=close_pools)
else:
config.session_manager.registry = registry
return config
def maybe_reload_target_registry(config: Config) -> bool:
targets_file = (config.targets_file or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "")).strip()
if not targets_file:
return False
path = Path(targets_file).expanduser()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
return False
if config.targets_file_mtime_ns == mtime_ns:
return False
registry = IoTDBTargetRegistry.from_env(
targets_file=str(path),
targets_json=config.targets_json or os.getenv("TIMESEEK_IOTDB_TARGETS_JSON", "") or None,
)
apply_target_registry(
config,
registry,
targets_file_mtime_ns=mtime_ns,
close_pools=True,
)
return True
def _string_fields(row: Any) -> list[str]:
fields = row.get_fields() if hasattr(row, "get_fields") else row
if isinstance(fields, (list, tuple)):
return [str(field) for field in fields]
return [str(fields)]
def _row_dict(columns: list[str], row: list[str]) -> dict[str, str] | dict[str, list[str]]:
if len(columns) == len(row):
return dict(zip(columns, row))
return {"values": row}
def _has_running_ainode(columns: list[str], rows: list[list[str]]) -> bool:
status_index = next(
(index for index, column in enumerate(columns) if column.strip().lower() == "status"),
None,
)
if status_index is not None:
return any(
len(row) > status_index and row[status_index].strip().lower() == "running"
for row in rows
)
return any(any(value.strip().lower() == "running" for value in row) for row in rows)
def _read_dataset(res: Any) -> tuple[list[str], list[list[str]]]:
columns = [str(column) for column in res.get_column_names()]
rows: list[list[str]] = []
while res.has_next():
rows.append(_string_fields(res.next()))
return columns, rows
def _ainode_probe_ttl_seconds() -> float:
raw = os.getenv("TIMESEEK_AINODE_PROBE_TTL_SECONDS", "").strip()
if not raw:
return _DEFAULT_AINODE_PROBE_TTL_SECONDS
try:
return max(0.0, float(raw))
except ValueError:
return _DEFAULT_AINODE_PROBE_TTL_SECONDS
def _ainode_probe_cache_key(config: Config) -> str:
try:
return config.to_target().auth_fingerprint()
except Exception:
return config.target_fingerprint or f"{config.target_id}:{config.host}:{config.port}"
def _cached_ainode_probe_result(
entry: dict[str, Any], age_seconds: float, ttl_seconds: float
) -> dict[str, Any]:
result = copy.deepcopy(entry["result"])
source = result.get("ainode_availability_source")
if isinstance(source, dict):
source["cached"] = True
source["cache_age_ms"] = int(max(0.0, age_seconds) * 1000)
source["cache_ttl_seconds"] = ttl_seconds
return result
def _last_success_for_stale_result(
cache_key: str, error_source: dict[str, Any]
) -> dict[str, Any] | None:
cached = _AINODE_PROBE_CACHE.get(cache_key)
if not cached:
return None
age_seconds = time.monotonic() - float(cached["monotonic"])
cached_result = cached["result"]
source = {
**error_source,
"stale_from_last_success": True,
"cache_age_ms": int(max(0.0, age_seconds) * 1000),
"last_success": copy.deepcopy(
cached_result.get("ainode_availability_source")
),
}
return {
"ainode_available": cached_result.get("ainode_available", "unknown"),
"ainode_availability_source": source,
}
def _open_ainode_probe_session(config: Config):
"""Open a short-lived direct session for AINode discovery.
This intentionally avoids the shared SessionPool used by business tools.
The IoTDB Python SessionPool requires `put_back()`, while many call sites
close raw sessions; a one-session probe pool can therefore time out under
repeated response annotation. A direct session keeps probe health isolated.
"""
if config.sql_dialect == "tree":
session = Session.init_from_node_urls(
list(config.node_urls),
config.user,
config.password,
config.fetch_size,
config.timezone,
enable_redirection=config.enable_redirection,
use_ssl=config.use_ssl,
ca_certs=config.ca_certs or None,
connection_timeout_in_ms=config.connection_timeout_in_ms,
)
session.sql_dialect = "tree"
session.database = config.database
session.open(config.enable_compression)
return session
return TableSession(
TableSessionConfig(
node_urls=list(config.node_urls),
username=config.user,
password=config.password,
database=config.database or None,
fetch_size=config.fetch_size,
time_zone=config.timezone,
enable_redirection=config.enable_redirection,
enable_compression=config.enable_compression,
use_ssl=config.use_ssl,
ca_certs=config.ca_certs or None,
connection_timeout_in_ms=config.connection_timeout_in_ms,
)
)
def _probe_ainode_availability(config: Config) -> dict[str, Any]:
session = None
cache_key = _ainode_probe_cache_key(config)
ttl_seconds = _ainode_probe_ttl_seconds()
cached = _AINODE_PROBE_CACHE.get(cache_key)
if cached and ttl_seconds > 0:
age_seconds = time.monotonic() - float(cached["monotonic"])
if age_seconds <= ttl_seconds:
return _cached_ainode_probe_result(cached, age_seconds, ttl_seconds)
try:
session = _open_ainode_probe_session(config)
res = session.execute_query_statement("SHOW AINODES")
columns, raw_rows = _read_dataset(res)
running_count = sum(
1
for row in raw_rows
if _has_running_ainode(columns, [row])
)
result = {
"ainode_available": "yes" if running_count > 0 else "no",
"ainode_availability_source": {
"method": "SHOW AINODES",
"ok": True,
"columns": columns,
"rows": [_row_dict(columns, row) for row in raw_rows],
"row_count": len(raw_rows),
"running_count": running_count,
},
}
_AINODE_PROBE_CACHE[cache_key] = {
"monotonic": time.monotonic(),
"result": copy.deepcopy(result),
}
return result
except Exception as exc:
error_source = {
"method": "SHOW AINODES",
"ok": False,
"error_type": type(exc).__name__,
"error": str(exc),
}
stale = _last_success_for_stale_result(cache_key, error_source)
if stale:
return stale
return {
"ainode_available": "unknown",
"ainode_availability_source": error_source,
}
finally:
if session is not None:
close = getattr(session, "close", None)
if close is not None:
close()
def _target_payload(config: Config) -> dict[str, Any]:
payload = {
"target_id": config.target_id,
"display_name": config.display_name,
"kind": config.target_kind,
"host": config.host,
"port": config.port,
"node_urls": list(config.node_urls),
"database": config.database,
"sql_dialect": config.sql_dialect,
"timezone": config.timezone,
"policy": dict(config.policy),
"fingerprint": config.target_fingerprint,
"ainode_available": "unknown",
"ainode_availability_source": {"method": "not_probed", "ok": None},
}
return payload
def select_target_config(
config: Config,
target_id: str | None = None,
target: TargetSelector = None,
required_sql_dialect: str | None = None,
tool_name: str | None = None,
) -> Config:
maybe_reload_target_registry(config)
registry = config.target_registry
if registry is None:
if target_id and target_id != config.target_id:
raise KeyError(
f"IoTDB target '{target_id}' is not registered in this MCP server."
)
if isinstance(target, str) and target != config.target_id:
raise KeyError(
f"IoTDB target '{target}' is not registered in this MCP server."
)
if isinstance(target, Mapping):
requested_id = target.get("target_id") or target.get("id") or target.get("name")
if requested_id and str(requested_id) != config.target_id:
raise KeyError(
f"IoTDB target '{requested_id}' is not registered in this MCP server."
)
candidate = _target_payload(config)
candidate["target_kind"] = candidate["kind"]
candidate["dialect"] = candidate["sql_dialect"]
for key in (
"display_name",
"kind",
"target_kind",
"host",
"port",
"database",
"sql_dialect",
"dialect",
):
expected = target.get(key)
if expected is not None and str(candidate[key]) != str(expected):
raise KeyError(
"IoTDB target selector does not match the active target."
)
endpoint = target.get("node_url") or target.get("endpoint")
if endpoint and str(endpoint).strip() not in config.node_urls:
raise KeyError(
"IoTDB target selector does not match the active target."
)
raw_node_urls = (
target.get("node_urls")
or target.get("endpoints")
or target.get("nodes")
)
if raw_node_urls is not None:
if isinstance(raw_node_urls, (list, tuple)):
requested_node_urls = [
str(item).strip() for item in raw_node_urls if str(item).strip()
]
else:
requested_node_urls = [
item.strip()
for item in str(raw_node_urls).split(",")
if item.strip()
]
if not all(item in config.node_urls for item in requested_node_urls):
raise KeyError(
"IoTDB target selector does not match the active target."
)
selected = config
else:
selected_target = registry.resolve_selector(
target,
target_id=target_id or (None if target is not None else config.target_id),
)
selected = Config.from_target(
selected_target,
target_registry=config.target_registry,
session_manager=config.session_manager,
)
# Nested pool helpers select again. Preserve the reload watermark so a
# derived explicit-target config is not reset to the registry default.
selected.targets_file = config.targets_file
selected.targets_json = config.targets_json
selected.targets_file_mtime_ns = config.targets_file_mtime_ns
if required_sql_dialect and selected.sql_dialect != required_sql_dialect:
owner = f"{tool_name} " if tool_name else ""
raise ValueError(
f"{owner}requires {required_sql_dialect} SQL dialect, but IoTDB target "
f"'{selected.target_id}' uses {selected.sql_dialect}."
)
return selected
def tree_session_pool(
config: Config,
target_id: str | None = None,
target: TargetSelector = None,
max_pool_size: int | None = None,
wait_timeout_in_ms: int | None = None,
tool_name: str | None = None,
):
selected = select_target_config(
config,
target_id=target_id,
target=target,
required_sql_dialect="tree",
tool_name=tool_name,
)
if selected.session_manager is not None:
return selected, selected.session_manager.tree_pool(
selected.target_id,
max_pool_size=max_pool_size,
wait_timeout_in_ms=wait_timeout_in_ms,
)
return selected, create_tree_session_pool(
selected,
max_pool_size=max_pool_size,
wait_timeout_in_ms=wait_timeout_in_ms,
)
def table_session_pool(
config: Config,
target_id: str | None = None,
target: TargetSelector = None,
max_pool_size: int | None = None,
database: str | None = None,
wait_timeout_in_ms: int | None = None,
tool_name: str | None = None,
):
selected = select_target_config(
config,
target_id=target_id,
target=target,
required_sql_dialect="table",
tool_name=tool_name,
)
if selected.session_manager is not None:
return selected, selected.session_manager.table_pool(
selected.target_id,
max_pool_size=max_pool_size,
database=database,
wait_timeout_in_ms=wait_timeout_in_ms,
)
return selected, create_table_session_pool(
selected,
max_pool_size=max_pool_size,
database=database,
wait_timeout_in_ms=wait_timeout_in_ms,
)
@contextmanager
def iotdb_target_response_context(config: Config) -> Iterator[None]:
token = set_iotdb_target_context(_target_payload(config))
try:
yield
finally:
reset_iotdb_target_context(token)