runtime_bridge.py 2.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. from __future__ import annotations
  2. import importlib
  3. import os
  4. import sys
  5. from functools import lru_cache
  6. from pathlib import Path
  7. from typing import Any
  8. from sqlalchemy import create_engine, event
  9. from sqlalchemy.orm import sessionmaker
  10. def runtime_dir() -> Path:
  11. configured = os.getenv("PATTERN_RUNTIME_DIR")
  12. if configured:
  13. return Path(configured).expanduser().resolve()
  14. return Path(__file__).resolve().parents[3]
  15. def database_host_override() -> str | None:
  16. """Return a visualization-only DB host override.
  17. The value is deliberately host-only: credentials, database name and driver
  18. continue to come from the parent runtime's existing SQLAlchemy URL.
  19. """
  20. value = os.getenv("PATTERN_DB_HOST_OVERRIDE", "").strip()
  21. if not value:
  22. return None
  23. if any(token in value for token in ("/", "@", "?", "#", ":")) or any(char.isspace() for char in value):
  24. raise ValueError("PATTERN_DB_HOST_OVERRIDE 只能是不含端口的主机名或 IPv4 地址")
  25. return value
  26. def load_runtime_modules() -> tuple[Any, Any]:
  27. """Load only the parent's DB manager and ORM models.
  28. Importing pattern_service would initialize unrelated mining/config modules, so
  29. the visualization intentionally avoids it.
  30. """
  31. root = str(runtime_dir())
  32. if root not in sys.path:
  33. sys.path.insert(0, root)
  34. return importlib.import_module("db_manager"), importlib.import_module("models")
  35. def new_session():
  36. """Return a session backed by a pool that is read-only at MySQL level.
  37. V8 does not borrow the parent application's ordinary session because that
  38. pool is intentionally write-capable. The visualization always owns a
  39. separate pool, even when no host override is configured.
  40. """
  41. return _read_only_session_factory(
  42. str(runtime_dir()), database_host_override() or ""
  43. )()
  44. @lru_cache(maxsize=4)
  45. def _read_only_session_factory(runtime_path: str, host: str):
  46. """Build one cached visualization-only read-only connection pool."""
  47. del runtime_path # Included in the cache key so runtime switches cannot reuse a stale pool.
  48. db_manager, _ = load_runtime_modules()
  49. parent_manager = db_manager.DatabaseManager()
  50. parent_engine = parent_manager.engine
  51. try:
  52. override_url = parent_engine.url.set(host=host) if host else parent_engine.url
  53. finally:
  54. # The parent engine is lazy and has not connected; do not retain a second pool.
  55. parent_engine.dispose()
  56. engine = create_engine(override_url, pool_pre_ping=True, pool_recycle=3600)
  57. event.listen(engine, "connect", _set_mysql_session_read_only)
  58. return sessionmaker(bind=engine, autoflush=False, autocommit=False)
  59. def _set_mysql_session_read_only(dbapi_connection, _connection_record) -> None:
  60. """Make every transaction on the visualization pool read-only at MySQL level."""
  61. cursor = dbapi_connection.cursor()
  62. try:
  63. cursor.execute("SET SESSION TRANSACTION READ ONLY")
  64. finally:
  65. cursor.close()