2026-11-05 05:47:13 -05:00
|
|
|
"""跨平台托管循环;单次任务在独立线程执行,停止时等待其完成。"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import platform
|
|
|
|
|
import signal
|
|
|
|
|
import socket
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from urllib.error import URLError
|
|
|
|
|
from urllib.parse import urlsplit
|
2026-11-05 20:42:07 -05:00
|
|
|
from urllib.request import HTTPRedirectHandler, build_opener
|
2026-11-05 05:47:13 -05:00
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import create_engine, select, text, update
|
|
|
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
|
|
|
|
|
|
from app.core.tplus_db import resolve_tplus_settings
|
|
|
|
|
from app.models import TPlusRuntimeControl
|
|
|
|
|
from app.services.tplus.automation import local_now, tick
|
|
|
|
|
from app.services.tplus.worker import run_once
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
LOCK_NAME = "ys:tplus:supervisor"
|
|
|
|
|
|
|
|
|
|
|
2026-11-05 20:42:07 -05:00
|
|
|
class _LocalOnlyRedirectHandler(HTTPRedirectHandler):
|
|
|
|
|
"""拒绝健康检查从本机地址跳转到外部地址。"""
|
|
|
|
|
|
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, new):
|
|
|
|
|
parsed = urlsplit(new)
|
|
|
|
|
if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
|
|
|
|
|
return None
|
|
|
|
|
return super().redirect_request(req, fp, code, msg, headers, new)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_health_opener = build_opener(_LocalOnlyRedirectHandler)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _open_health(url, *, timeout):
|
|
|
|
|
return _health_opener.open(url, timeout=timeout)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-05 05:47:13 -05:00
|
|
|
def health_error(settings):
|
|
|
|
|
"""只访问本机健康端点,禁止通过配置探测外部网络。"""
|
|
|
|
|
try:
|
|
|
|
|
url = settings.t_plus_supervisor_health_url
|
|
|
|
|
parsed = urlsplit(url)
|
|
|
|
|
if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
|
|
|
|
|
return "后端健康检查地址必须是本机 HTTP 地址"
|
2026-11-05 20:42:07 -05:00
|
|
|
with _open_health(url, timeout=3) as response:
|
2026-11-05 05:47:13 -05:00
|
|
|
body = json.load(response)
|
|
|
|
|
data = body.get("data") if isinstance(body, dict) else None
|
|
|
|
|
if not isinstance(data, dict) or not all(data.get(key) is True for key in ("app", "db", "redis")):
|
|
|
|
|
return "系统或依赖尚未就绪"
|
|
|
|
|
source = resolve_tplus_settings(settings)
|
|
|
|
|
try:
|
|
|
|
|
import pyodbc
|
|
|
|
|
except ImportError:
|
|
|
|
|
return "未安装 T+ 数据库连接组件"
|
|
|
|
|
if source.t_plus_odbc_driver not in pyodbc.drivers():
|
|
|
|
|
return "T+ 数据库驱动不可用,请联系维护人员"
|
|
|
|
|
if settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists():
|
|
|
|
|
return "旧版停止标记存在,请联系维护人员处理"
|
|
|
|
|
except (OSError, URLError, ValueError, RuntimeError):
|
|
|
|
|
return "系统健康检查或 T+ 连接配置未就绪"
|
|
|
|
|
engine = None
|
|
|
|
|
try:
|
|
|
|
|
engine = create_engine(
|
|
|
|
|
source.t_plus_database_url, poolclass=NullPool, hide_parameters=True,
|
|
|
|
|
connect_args={"timeout": source.t_plus_connect_timeout},
|
|
|
|
|
)
|
|
|
|
|
with engine.connect() as connection:
|
|
|
|
|
connection.connection.driver_connection.timeout = source.t_plus_query_timeout
|
|
|
|
|
connection.exec_driver_sql("SELECT 1")
|
|
|
|
|
except (SQLAlchemyError, OSError, ValueError, RuntimeError):
|
|
|
|
|
return "T+ 数据库连接不可用,请检查连接配置"
|
|
|
|
|
finally:
|
|
|
|
|
if engine is not None:
|
|
|
|
|
engine.dispose()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def may_claim(session):
|
|
|
|
|
"""与页面更新同一行锁,确认停止后不再领取新任务。"""
|
|
|
|
|
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
|
|
|
|
|
.with_for_update().execution_options(populate_existing=True))
|
|
|
|
|
return row is not None and row.desired_state == "RUNNING"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run(factory, settings, *, interval=5, stop_event=None):
|
|
|
|
|
"""持有数据库命名锁期间,轮询页面设置并完成已领取的工作。"""
|
|
|
|
|
stopped = stop_event or threading.Event()
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
worker = None
|
|
|
|
|
failures = []
|
|
|
|
|
retry_after = 0.0
|
|
|
|
|
last_maintenance = 0.0
|
|
|
|
|
handlers = {}
|
|
|
|
|
if threading.current_thread() is threading.main_thread():
|
|
|
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
|
|
|
handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
|
|
|
|
|
connection = None
|
|
|
|
|
acquired = False
|
|
|
|
|
|
|
|
|
|
def execute():
|
|
|
|
|
try:
|
|
|
|
|
with factory() as session:
|
|
|
|
|
run_once(session, worker_owner=owner, may_claim=may_claim)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error("T+ 读取任务执行异常,异常类型=%s", type(exc).__name__)
|
|
|
|
|
failures.append("读取服务执行失败,请检查服务日志")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
connection = factory.kw["bind"].connect()
|
|
|
|
|
if connection.dialect.name != "mysql":
|
|
|
|
|
raise RuntimeError("T+ 托管服务需要 MySQL 命名锁")
|
|
|
|
|
acquired = connection.execute(text("SELECT GET_LOCK(:name, 0)"), {"name": LOCK_NAME}).scalar() == 1
|
|
|
|
|
if not acquired:
|
|
|
|
|
logger.error("T+ 托管服务已在运行")
|
|
|
|
|
return 1
|
|
|
|
|
while not stopped.is_set():
|
|
|
|
|
try:
|
|
|
|
|
if connection.execute(text("SELECT IS_USED_LOCK(:name) = CONNECTION_ID()"),
|
|
|
|
|
{"name": LOCK_NAME}).scalar() != 1:
|
|
|
|
|
raise RuntimeError("托管服务锁已失效")
|
|
|
|
|
with factory() as session:
|
|
|
|
|
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
|
|
|
|
|
.execution_options(populate_existing=True))
|
|
|
|
|
if row is None:
|
|
|
|
|
raise RuntimeError("读取服务控制尚未初始化")
|
|
|
|
|
desired = row.desired_state
|
|
|
|
|
error = health_error(settings) if desired == "RUNNING" else None
|
|
|
|
|
active = worker is not None and worker.is_alive()
|
|
|
|
|
if failures:
|
|
|
|
|
error = failures.pop(0)
|
|
|
|
|
retry_after = time.monotonic() + 30
|
|
|
|
|
elif time.monotonic() < retry_after:
|
|
|
|
|
error = "读取服务异常,等待重试"
|
|
|
|
|
if desired == "RUNNING" and not error and not active:
|
|
|
|
|
if time.monotonic() - last_maintenance >= 60:
|
|
|
|
|
tick(session, settings=settings)
|
|
|
|
|
last_maintenance = time.monotonic()
|
|
|
|
|
worker = threading.Thread(target=execute, name="tplus-worker")
|
|
|
|
|
worker.start()
|
|
|
|
|
active = True
|
|
|
|
|
state = ("STOPPING" if active else "STOPPED") if desired == "STOPPED" else (
|
|
|
|
|
"DEGRADED" if error else "RUNNING")
|
|
|
|
|
session.execute(update(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1).values(
|
|
|
|
|
actual_state=state, worker_phase="RUNNING" if active else "IDLE" if state == "RUNNING" else "STOPPED",
|
|
|
|
|
heartbeat_at=local_now(), last_error=error, host=socket.gethostname()[:128],
|
|
|
|
|
platform=platform.system()[:32], owner=owner))
|
|
|
|
|
session.commit()
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error("T+ 托管服务检查失败,异常类型=%s", type(exc).__name__)
|
|
|
|
|
# 锁或数据库状态不明时退出,绝不启动新的读取任务。
|
|
|
|
|
break
|
|
|
|
|
stopped.wait(interval)
|
|
|
|
|
finally:
|
|
|
|
|
if worker is not None:
|
|
|
|
|
worker.join()
|
|
|
|
|
if acquired:
|
|
|
|
|
try:
|
|
|
|
|
with factory() as session:
|
|
|
|
|
session.execute(update(TPlusRuntimeControl).where(
|
|
|
|
|
TPlusRuntimeControl.id == 1, TPlusRuntimeControl.owner == owner
|
|
|
|
|
).values(actual_state="STOPPED", worker_phase="STOPPED", heartbeat_at=local_now()))
|
|
|
|
|
session.commit()
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.warning("无法记录 T+ 托管服务停止状态")
|
|
|
|
|
try:
|
|
|
|
|
released = connection.execute(
|
|
|
|
|
text("SELECT RELEASE_LOCK(:name)"), {"name": LOCK_NAME}
|
|
|
|
|
).scalar()
|
|
|
|
|
if released != 1:
|
|
|
|
|
connection.invalidate()
|
|
|
|
|
except Exception:
|
|
|
|
|
connection.invalidate()
|
|
|
|
|
if connection is not None:
|
|
|
|
|
connection.close()
|
|
|
|
|
for sig, handler in handlers.items():
|
|
|
|
|
signal.signal(sig, handler)
|
|
|
|
|
return 0 if stopped.is_set() else 1
|