Files
xiaobai-review/xiaobai-datahub/datahub/hub.py
T
abd4d22a67 HEL-543 返工: 补充 observability 运行时可关闭开关 (DATAHUB_OBSERVABILITY)
总工复核 🔴:安全边界要求新增观测功能必须可关闭、关闭后现有功能完全照旧,
但此前实现没有任何运行时开关。修复:

- settings.py: 新增 Settings.observability_enabled 字段,沿用既有
  DATAHUB_SCHEDULER 的环境变量模式,读取 DATAHUB_OBSERVABILITY
  (0/false/off 关闭,默认开启)。
- hub.py: Hub.__init__ 把 settings.observability_enabled 挂到
  self.db 上,让 pipeline/realtime_serve/steward/admin_api 已经
  在传的 db 参数直接带上开关,零额外改造。
- observability.py: 新增 is_enabled(db),缺失该属性时默认按启用处理
  (向后兼容裸 HubDB 用例/测试)。关闭时 observe() 变成纯
  透传(不计时、不分类、不碰数据库),record_call() 直接 no-op。
- admin_api.py: 4 个新只读端点关闭时返回明确的
  {"enabled": false, ...空结构} 而不是静默返回旧数据。
- 新增 10 个测试:开关默认值/环境变量解析、关闭后 observe() 的透传语义
  (含异常原样重新抛出)、关闭后 record_call() 零写入、关闭后重新开启恢复
  记录、4 个 admin 端点在关闭态的响应结构。

全量测试 183/183 通过(新增 10 个,含此前 173 个零回归)。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-14 01:10:58 +08:00

83 lines
3.8 KiB
Python

from __future__ import annotations
from pathlib import Path
from typing import Any
from datahub.adapters.ifind import IfindAdapter
from datahub.adapters.tushare import TushareAdapter
from datahub.admin_api import AdminAPI
from datahub.auth import AuthService
from datahub.crypto import SecretVault
from datahub.db import HubDB
from datahub.governance.circuit import CircuitBreaker
from datahub.governance.lkg import LastKnownGood
from datahub.governance.ratelimit import TokenBucket
from datahub.pipeline import Pipeline
from datahub.scheduler import Scheduler
from datahub.serving import V1API
from datahub.settings import Settings, load_settings
class Hub:
def __init__(self, settings: Settings, adapter: TushareAdapter | None = None) -> None:
if not settings.encryption_key:
raise SystemExit("DATAHUB_ENCRYPTION_KEY 未配置")
self.settings = settings
self.db = HubDB(settings.db_path)
# HEL-543: carry the observability kill switch on the db handle so
# every call site that already threads `db` through (pipeline,
# realtime_serve, steward, admin_api) picks it up for free with no
# extra plumbing. Missing this attribute (e.g. a bare HubDB built
# directly in tests) defaults to enabled — see observability.is_enabled.
self.db.observability_enabled = settings.observability_enabled
self.vault = SecretVault(settings.encryption_key)
self.auth = AuthService(self.db, self.vault, settings.api_token, settings.admin_password)
token = settings.tushare_token or self.auth.load_credential("tushare_token")
if settings.tushare_token:
self.auth.store_credential("tushare_token", settings.tushare_token)
token = settings.tushare_token
refresh = settings.ifind_refresh_token or self.auth.load_credential("ifind_refresh_token") or ""
access = settings.ifind_access_token or self.auth.load_credential("ifind_access_token") or ""
if settings.ifind_refresh_token:
self.auth.store_credential("ifind_refresh_token", settings.ifind_refresh_token)
refresh = settings.ifind_refresh_token
if settings.ifind_access_token:
self.auth.store_credential("ifind_access_token", settings.ifind_access_token)
access = settings.ifind_access_token
self.adapter = adapter or TushareAdapter(token)
self.ifind = IfindAdapter(refresh, access)
self.pipeline = Pipeline(
self.db,
self.adapter,
settings,
bucket=TokenBucket(settings.tushare_rate_per_minute),
breaker=CircuitBreaker(),
)
self.lkg = LastKnownGood(self.db)
self.scheduler = Scheduler(self.db, self.pipeline)
self.api = V1API(self.db, self.pipeline, settings, ifind=self.ifind)
self.admin = AdminAPI(self.db, self.pipeline, self.scheduler, self.auth, ifind=self.ifind)
self.static_dir = Path(__file__).resolve().parents[1] / "admin"
def put_ifind_credentials(self, body: dict[str, Any] | None) -> dict[str, Any]:
from datahub.serving import envelope
payload = dict(body or {})
refresh = str(payload.get("ifind_refresh_token") or "").strip()
access = str(payload.get("ifind_access_token") or "").strip()
self.auth.store_credential("ifind_refresh_token", refresh)
self.auth.store_credential("ifind_access_token", access)
self.ifind.set_credentials(refresh, access)
return envelope(self.ifind.status(), {"source": "ifind"})
def start(self) -> None:
if self.settings.scheduler_enabled:
self.scheduler.start()
def stop(self) -> None:
self.scheduler.stop()
def build_hub(settings: Settings | None = None) -> Hub:
return Hub(settings or load_settings())