Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics.py
T
insistence 4bd83bc7cd feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
2026-10-09 23:49:45 +08:00

252 lines
11 KiB
Python

import time
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, ValidationInfo, field_validator, model_validator
from pydantic.alias_generators import to_camel
from plugins.core.runtime.task_cleanup import pending_plugin_tasks
if TYPE_CHECKING:
from plugins.core.runtime.explicit import ExplicitPluginRuntime, LoadedExplicitPlugin
from plugins.core.runtime.health import PluginHealthResult
from plugins.core.runtime.metrics import PluginMetricSeries
MAX_DIAGNOSTIC_PLUGINS = 1024
class _DiagnosticModel(BaseModel):
"""只接收有界的宿主观测字段,禁止混入插件业务对象或额外字段。"""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra='forbid', strict=True)
@model_validator(mode='before')
@classmethod
def require_complete_wire_fields(cls, value: Any, info: ValidationInfo) -> Any:
"""
读取远端快照时要求每个已声明字段显式存在,不把缺失观测补成默认零值。
:param value: 当前层级待校验的输入,远端协议只接受 JSON 对象
:param info: 校验上下文,仅 require_complete_snapshot 开启时要求完整协议字段
:return: 字段完整的原始输入;本地构造仍保留模型默认值
"""
if not isinstance(info.context, Mapping) or not info.context.get('require_complete_snapshot'):
return value
if not isinstance(value, Mapping):
raise ValueError('远端运行诊断必须使用完整 JSON 对象')
missing = [
field.alias or name for name, field in cls.model_fields.items() if (field.alias or name) not in value
]
if missing:
raise ValueError(f'远端运行诊断缺少协议字段:{", ".join(missing)}')
return value
class PluginActivationHealth(_DiagnosticModel):
"""最近一次激活健康检查摘要,不表示持续健康且不保存插件返回的描述或详情。"""
status: Literal['unknown', 'healthy', 'unhealthy', 'error', 'timeout'] = 'unknown'
checked_at: float | None = Field(default=None, ge=0, allow_inf_nan=False)
duration_ms: float | None = Field(default=None, ge=0, allow_inf_nan=False)
class PluginLifespanDiagnostic(_DiagnosticModel):
"""宿主管理的生命周期状态;正常存活的任务不等于等待取消的任务。"""
managed: bool
started: bool
ready: bool
task_active: bool
class PluginConnectionDiagnostic(_DiagnosticModel):
"""已经接受的 WebSocket 和已开始响应的 SSE 连接数量。"""
sse: NonNegativeInt = 0
websocket: NonNegativeInt = 0
class PluginConfigDiagnostic(_DiagnosticModel):
"""不透明配置版本;按需读取完成不代表插件业务已经应用新配置。"""
startup_revision: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$')
last_read_revision: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$')
last_read_at: float | None = Field(default=None, ge=0, allow_inf_nan=False)
class PluginDiagnosticError(_DiagnosticModel):
"""宿主指标中最近错误的关联字段,不包含异常消息或请求内容。"""
error_type: str = Field(min_length=1, max_length=80)
request_id: str | None = Field(default=None, max_length=128)
at: float = Field(ge=0, allow_inf_nan=False)
class PluginRuntimeDiagnostic(_DiagnosticModel):
"""一个 worker 已加载的显式插件状态;身份始终来自实际加载对象。"""
plugin_id: str = Field(pattern=r'^[a-z][a-z0-9_]{1,63}$')
version: str = Field(max_length=128)
digest: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$')
generation: str | None = Field(default=None, pattern=r'^[0-9a-f]{32}$')
active: bool
ready: bool
closing: bool
lifespan: PluginLifespanDiagnostic | None = None
connections: PluginConnectionDiagnostic = Field(default_factory=PluginConnectionDiagnostic)
active_jobs: NonNegativeInt = 0
pending_connection_tasks: NonNegativeInt = 0
pending_job_tasks: NonNegativeInt = 0
activation_health: PluginActivationHealth = Field(default_factory=PluginActivationHealth)
config: PluginConfigDiagnostic = Field(default_factory=PluginConfigDiagnostic)
last_error: PluginDiagnosticError | None = None
class PluginRuntimeDiagnosticSnapshot(_DiagnosticModel):
"""
当前进程的只读诊断快照,与已有运行指标协议独立。
worker_pending_cleanup_tasks 是宿主通用回收器与全部插件连接回收器的去重总数,
包含各插件的待回收子项,不能与它们再次相加。未受宿主跟踪的业务任务不在此范围内。
"""
schema_version: Literal[1] = 1
worker_id: str = Field(pattern=r'^[0-9a-f]{32}$')
collected_at: float = Field(ge=0, allow_inf_nan=False)
plugins: list[PluginRuntimeDiagnostic] = Field(default_factory=list, max_length=MAX_DIAGNOSTIC_PLUGINS)
worker_pending_cleanup_tasks: NonNegativeInt = 0
dropped_plugins: NonNegativeInt = 0
@field_validator('schema_version', mode='before')
@classmethod
def validate_schema_version(cls, value: Any) -> int:
"""
要求显式协议版本为整数 1,拒绝布尔值和数值相等的浮点值。
:param value: 显式提供的协议版本
:return: 通过严格类型与版本检查的整数
"""
if type(value) is not int or value != 1:
raise ValueError('schemaVersion 必须为整数 1')
return value
@model_validator(mode='after')
def validate_unique_plugins(self) -> 'PluginRuntimeDiagnosticSnapshot':
"""
拒绝单个 worker 在同一快照中重复报告同一插件。
:return: 插件身份唯一的诊断快照
"""
if len({plugin.plugin_id for plugin in self.plugins}) != len(self.plugins):
raise ValueError('插件运行诊断快照包含重复插件 ID')
return self
def activation_health_observation(result: 'PluginHealthResult') -> PluginActivationHealth:
"""
将激活检查结果转换为固定状态集合,丢弃插件提供的任意文本及详情。
:param result: 激活期间由宿主完成的健康检查结果
:return: 带完成时间和耗时的安全摘要
"""
status = 'healthy' if result.ok else result.status if result.status in {'timeout', 'error'} else 'unhealthy'
return PluginActivationHealth(status=status, checked_at=time.time(), duration_ms=result.duration_ms)
def _recent_errors(runtime: 'ExplicitPluginRuntime') -> dict[str, PluginDiagnosticError]:
"""
从已有指标中选取每个插件最近的错误,不读取异常正文或业务内容。
:param runtime: 当前 worker 的显式插件运行时
:return: 按插件 ID 索引的错误关联摘要
"""
latest: dict[str, PluginMetricSeries] = {}
for series in runtime.metrics.series.values():
if series.last_error_at is None or not series.last_error_type:
continue
previous = latest.get(series.plugin_id)
if previous is None or series.last_error_at > previous.last_error_at:
latest[series.plugin_id] = series
return {
plugin_id: PluginDiagnosticError(
error_type=series.last_error_type,
request_id=series.last_error_request_id,
at=series.last_error_at,
)
for plugin_id, series in latest.items()
}
def _plugin_observation(
runtime: 'ExplicitPluginRuntime', loaded: 'LoadedExplicitPlugin', last_error: PluginDiagnosticError | None
) -> PluginRuntimeDiagnostic:
"""
复制单个已加载插件的宿主状态,不执行插件代码或外部查询。
:param runtime: 当前 worker 的显式插件运行时
:param loaded: 已加载的插件资源记录
:param last_error: 已从宿主指标选取的最近错误
:return: 不含任务对象、配置明文或请求数据的插件诊断
"""
plugin = loaded.plugin.discovered_plugin
manager = loaded.gateway.connections if loaded.gateway is not None else None
binding = loaded.jobs
job_tasks = frozenset(task for task in binding.tasks if not task.done()) if binding is not None else frozenset()
closing = runtime._closing or bool(manager and manager.closing) or bool(binding and binding.closing)
lifespan = loaded.lifespan
observation = runtime.metrics.configurations.get(loaded.plugin.plugin_id)
return PluginRuntimeDiagnostic(
plugin_id=loaded.plugin.plugin_id,
version=plugin.manifest.version,
digest=plugin.artifact_digest,
generation=plugin.artifact_generation,
active=loaded.active,
ready=loaded.active and not closing and (lifespan is None or lifespan.ready),
closing=closing,
lifespan=(
PluginLifespanDiagnostic(
managed=lifespan.managed,
started=lifespan.started,
ready=lifespan.ready,
task_active=lifespan.has_pending_task,
)
if lifespan is not None
else None
),
connections=PluginConnectionDiagnostic(**manager.connection_counts())
if manager
else PluginConnectionDiagnostic(),
active_jobs=len(job_tasks),
pending_connection_tasks=len(manager.pending_tasks()) if manager else 0,
pending_job_tasks=len(pending_plugin_tasks(job_tasks)),
activation_health=loaded.activation_health.model_copy(deep=True),
config=PluginConfigDiagnostic(
startup_revision=observation.startup_revision if observation else loaded.host.config_revision,
last_read_revision=observation.last_read_revision if observation else None,
last_read_at=observation.last_read_at if observation else None,
),
last_error=last_error,
)
def collect_runtime_diagnostics(runtime: 'ExplicitPluginRuntime') -> PluginRuntimeDiagnosticSnapshot:
"""
在当前事件循环采样宿主已跟踪的插件资源,读取期间不触发回调或改变取消状态。
:param runtime: 当前 worker 的显式插件运行时
:return: 有界且独立于后续资源变化的诊断快照
"""
pending = set(pending_plugin_tasks())
for loaded in runtime.loaded.values():
if loaded.gateway is not None:
pending.update(loaded.gateway.connections.pending_tasks())
errors = _recent_errors(runtime)
selected = sorted(runtime.loaded.items())[:MAX_DIAGNOSTIC_PLUGINS]
return PluginRuntimeDiagnosticSnapshot(
worker_id=runtime.metrics.worker_id,
collected_at=time.time(),
plugins=[_plugin_observation(runtime, loaded, errors.get(plugin_id)) for plugin_id, loaded in selected],
worker_pending_cleanup_tasks=len(pending),
dropped_plugins=max(0, len(runtime.loaded) - len(selected)),
)