Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/runtime/application.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

570 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
import time
from collections.abc import Awaitable, Callable, Mapping
from functools import cache
from fastapi import FastAPI
from common.constant import LockConstant
from plugins.core.runtime.diagnostics_store import PluginDiagnosticsReporter, diagnostics_namespace
from plugins.core.runtime.explicit import ExplicitPluginRuntime
from plugins.core.runtime.startup import PluginRuntimeStartupManager
from plugins.core.runtime.startup_coordination import PluginStartupGenerationResolver
from utils.log_util import logger
from .service.lifecycle_lock import NoopPluginLifecycleLock, PluginLifecycleLock
class PluginApplicationRuntime:
"""
应用入口侧插件运行时适配器。
server.py 只依赖该适配器提供的高层扩展点,插件实体导入、二次建表、启动资源安装、
多 worker ready barrier 和生命周期钩子等细节留在插件运行时内部。
"""
def __init__(
self,
startup_manager: PluginRuntimeStartupManager | None = None,
*,
ready_key: str = LockConstant.PLUGIN_STARTUP_READY_KEY,
ready_expire_seconds: int = LockConstant.PLUGIN_STARTUP_READY_EXPIRE_SECONDS,
failed_expire_seconds: int = LockConstant.PLUGIN_STARTUP_FAILED_EXPIRE_SECONDS,
ready_wait_timeout_seconds: int = LockConstant.PLUGIN_STARTUP_READY_WAIT_TIMEOUT_SECONDS,
ready_wait_interval_seconds: int = LockConstant.PLUGIN_STARTUP_READY_WAIT_INTERVAL_SECONDS,
lifecycle_lock: PluginLifecycleLock | None = None,
startup_generation: str | None = None,
release_worker: object | None = None,
) -> None:
"""
初始化插件应用运行时适配器。
:param startup_manager: 插件启动协调器
:param ready_key: 插件启动 ready 标记 key
:param ready_expire_seconds: ready 标记过期时间
:param failed_expire_seconds: 失败标记过期时间
:param ready_wait_timeout_seconds: 等待 ready 超时时间
:param ready_wait_interval_seconds: 等待 ready 轮询间隔
:param lifecycle_lock: 插件生命周期全局锁
:param startup_generation: 测试或部署显式注入的启动代际
:param release_worker: 维护当前发布快照和 worker 心跳的可选协调器
:return: None
"""
self.startup_manager = startup_manager or PluginRuntimeStartupManager()
self.ready_key = ready_key
self.ready_expire_seconds = ready_expire_seconds
self.failed_expire_seconds = failed_expire_seconds
self.ready_wait_timeout_seconds = ready_wait_timeout_seconds
self.ready_wait_interval_seconds = ready_wait_interval_seconds
self.lifecycle_lock = lifecycle_lock or NoopPluginLifecycleLock()
self.startup_generation = startup_generation
self.release_worker = release_worker
def bind_app(self, app: FastAPI) -> None:
"""
绑定插件运行时到 FastAPI app。
:param app: FastAPI对象
:return: None
"""
app.state.plugin_application_runtime = self
self.startup_manager.bind_app(app)
def prepare_metadata(self, app: FastAPI) -> None:
"""
准备插件平台自身元数据。
:param app: FastAPI对象
:return: None
"""
self._ensure_bound(app)
self.startup_manager.import_builtin_entities()
if self.release_worker is not None:
from plugins.core.management.entity.do import release_models # noqa: F401, PLC0415
async def startup(
self,
app: FastAPI,
*,
create_tables: Callable[[], Awaitable[None]],
) -> None:
"""
启动插件运行时。
同一发布代际只有一个 worker 能在全局生命周期锁内执行写入;其他 worker 等待
当前代际 ready 后仅执行进程本地激活。插件启动不再依赖长期持有的调度器主节点锁。
:param app: FastAPI对象
:param create_tables: 数据库建表回调
:return: None
"""
try:
if self.release_worker is not None:
await self.release_worker.start(
app, self.startup_manager.builder, self.lifecycle_lock, timeout=self.ready_wait_timeout_seconds
)
self.startup_generation = self.release_worker.generation(self.resolve_startup_generation())
app.state.plugin_release_worker = self.release_worker
await self._startup_coordinated(app, create_tables=create_tables)
if self.release_worker is not None:
await self.release_worker.ready()
runtime = getattr(app.state, 'plugin_explicit_runtime', None)
if isinstance(runtime, ExplicitPluginRuntime):
if self.release_worker is not None:
# 即使全部显式插件准备失败,空运行时也属于已有发布 worker。
runtime.metrics.worker_id = self.release_worker.worker_id
runtime.metrics_reporter.start(getattr(app.state, 'redis', None), f'{self.ready_key}:metrics')
reporter = PluginDiagnosticsReporter(runtime.diagnostic_snapshot, runtime.metrics.worker_id)
app.state.plugin_diagnostics_reporter = reporter
reporter.start(getattr(app.state, 'redis', None), diagnostics_namespace(self.ready_key))
except BaseException:
# 激活后的 ready 发布或锁释放也可能失败,此时 server 尚未标记启动完成。
# 显式资源需在这里释放,不能依赖正常 shutdown 分支。
runtime = getattr(app.state, 'plugin_explicit_runtime', None)
try:
if isinstance(runtime, ExplicitPluginRuntime):
await runtime.shutdown()
finally:
await self._stop_diagnostics(app)
if self.release_worker is not None:
try:
await self.release_worker.stop(failed=True)
except Exception:
logger.exception('插件启动失败后的 worker 状态上报失败;旧心跳将按 TTL 失效')
raise
async def _startup_coordinated(
self,
app: FastAPI,
*,
create_tables: Callable[[], Awaitable[None]],
) -> None:
"""
执行 writer/reader 协调,包括激活后的 ready 发布及锁释放。
:param app: 宿主 FastAPI 应用
:param create_tables: 在允许写入的 worker 中创建数据库表的异步回调
:return: None
"""
self._ensure_bound(app)
if self.release_worker is not None:
await self.release_worker.assert_snapshot_current()
generation = self.resolve_startup_generation()
app.state.plugin_startup_generation = generation
app.state.plugin_startup_write_enabled = False
ready_state = await self.get_startup_state(app, generation)
logger.bind(
startup_generation=generation,
plugin_startup_role='reader',
ready_status=ready_state.get('status', 'missing'),
).debug('🔎 开始检查插件启动代际 ready 状态')
activated, stale_ready_reported = await self._process_startup_state(
app,
generation,
ready_state,
stale_ready_reported=False,
ready_message='复用当前代际插件 ready,开始本地激活',
failed_message='检测到当前代际插件启动 failed marker',
)
if activated:
return
deadline = time.monotonic() + self.ready_wait_timeout_seconds
wait_reported = False
while time.monotonic() < deadline:
async with self.lifecycle_lock.lock('__runtime__', f'startup:{generation}') as lock_result:
if lock_result.acquired:
ready_state = await self.get_startup_state(app, generation)
activated, stale_ready_reported = await self._process_startup_state(
app,
generation,
ready_state,
stale_ready_reported=stale_ready_reported,
ready_message='锁内复用当前代际插件 ready,开始本地激活',
failed_message='锁内检测到当前代际插件启动 failed marker',
)
if activated:
return
logger.bind(
startup_generation=generation,
plugin_startup_role='writer',
ready_status=ready_state.get('status', 'missing'),
).info('🎯 当前 worker 成为插件启动 writer')
await self._run_startup_writer(app, generation, create_tables)
return
ready_state = await self.get_startup_state(app, generation)
activated, stale_ready_reported = await self._process_startup_state(
app,
generation,
ready_state,
stale_ready_reported=stale_ready_reported,
ready_message='等待结束,复用当前代际插件 ready',
failed_message='等待期间检测到当前代际插件启动 failed marker',
)
if activated:
return
if not wait_reported:
logger.bind(
startup_generation=generation,
plugin_startup_role='reader',
ready_status=ready_state.get('status', 'missing'),
).debug('⏳ 等待插件启动 writer 发布当前代际 ready')
wait_reported = True
await asyncio.sleep(self.ready_wait_interval_seconds)
logger.bind(
startup_generation=generation,
plugin_startup_role='reader',
ready_status=ready_state.get('status', 'missing'),
).error('❌ 等待插件启动 ready 超时')
raise TimeoutError(f'等待插件启动代际 {generation} ready 超时')
async def _process_startup_state(
self,
app: FastAPI,
generation: str,
ready_state: Mapping[str, str],
*,
stale_ready_reported: bool,
ready_message: str,
failed_message: str,
) -> tuple[bool, bool]:
"""
处理一次插件启动状态读取结果。
:param app: FastAPI对象
:param generation: 插件启动代际
:param ready_state: 当前ready状态
:param stale_ready_reported: 是否已记录过期ready告警
:param ready_message: 复用ready时的日志
:param failed_message: 发现failed marker时的日志
:return: 是否已完成reader激活、是否已记录过期ready告警
"""
ready_status = ready_state.get('status', 'missing')
reader_logger = logger.bind(
startup_generation=generation,
plugin_startup_role='reader',
ready_status=ready_status,
)
if ready_status == 'success':
if await self.requires_startup_write():
if not stale_ready_reported:
logger.bind(
startup_generation=generation,
plugin_startup_role='reader',
ready_status=ready_status,
stale_ready_ignored=True,
startup_write_required=True,
startup_write_reason='missing_default_plugin_state',
).warning('⚠️ 数据库默认插件状态缺失,忽略当前代际旧 ready 标记')
return False, True
logger.bind(
startup_generation=generation,
plugin_startup_role='reader',
ready_status=ready_status,
plugin_install_lifecycle='skipped',
plugin_resource_sync='skipped',
plugin_entity_table_sync='skipped',
).info(
'⏭️ 复用插件 ready 状态,跳过启动期全局写入:'
'plugin_install_lifecycle=skipped,'
'plugin_resource_sync=skipped,'
'plugin_entity_table_sync=skipped'
)
reader_logger.debug(f'🔄 {ready_message}')
await self._activate_startup_reader(app, generation)
return True, stale_ready_reported
if ready_status == 'failed':
reader_logger.error(f'❌ {failed_message}')
raise RuntimeError(self._build_startup_failure_message(ready_state))
return False, stale_ready_reported
async def requires_startup_write(self) -> bool:
"""
校验 ready 标记对应的数据库状态是否仍然完整。
测试替身或旧适配器未实现该能力时保持原有行为;生产启动管理器会检查
默认启用插件是否仍具备安装状态。
:return: 是否必须重新执行启动期写入
"""
checker = getattr(type(self.startup_manager), 'requires_startup_write', None)
if checker is None:
return False
return bool(await self.startup_manager.requires_startup_write())
async def _run_startup_writer(
self,
app: FastAPI,
generation: str,
create_tables: Callable[[], Awaitable[None]],
) -> None:
"""
执行当前代际唯一的插件启动写入。
:param app: FastAPI对象
:param generation: 插件启动代际
:param create_tables: 数据库建表回调
:return: None
"""
app.state.plugin_startup_write_enabled = True
writer_logger = logger.bind(
startup_generation=generation,
plugin_startup_role='writer',
ready_status='initializing',
)
writer_logger.info('🔄 开始同步插件全局启动资源')
await self.clear_startup_ready(app, generation)
try:
await self.startup_manager.prepare_enabled_plugins(app, startup_write_enabled=True)
await create_tables()
await self.startup_manager.activate_enabled_plugins(app, startup_write_enabled=True)
except Exception as exc:
await self.mark_startup_failed(app, generation, exc)
logger.bind(
startup_generation=generation,
plugin_startup_role='writer',
ready_status='failed',
).exception('❌ 插件全局启动资源同步失败,已写入 failed marker')
raise
await self.mark_startup_ready(app, generation)
logger.bind(
startup_generation=generation,
plugin_startup_role='writer',
ready_status='success',
).info('✅ 插件全局启动资源同步完成')
self._log_startup_completed(app, generation, role='writer')
async def _activate_startup_reader(self, app: FastAPI, generation: str) -> None:
"""
在当前 worker 执行无全局写入的本地插件激活。
:param app: FastAPI对象
:param generation: 插件启动代际
:return: None
"""
reader_logger = logger.bind(
startup_generation=generation,
plugin_startup_role='reader',
ready_status='success',
)
reader_logger.debug('🔄 开始执行插件本地实体、Hook和路由激活')
await self.startup_manager.prepare_enabled_plugins(app, startup_write_enabled=False)
await self.startup_manager.activate_enabled_plugins(app, startup_write_enabled=False)
reader_logger.debug('✅ 插件本地实体、Hook和路由激活完成')
self._log_startup_completed(app, generation, role='reader')
@staticmethod
def _log_startup_completed(app: FastAPI, generation: str, *, role: str) -> None:
"""
为每个成功启动的worker输出一条稳定可见的插件运行时摘要。
writer和reader内部过程仍按原有级别记录;该INFO摘要用于避免命中ready后
只有DEBUG日志而表现为插件日志随机缺失。
:param app: FastAPI对象
:param generation: 插件启动代际
:param role: 当前worker的插件启动角色
:return: None
"""
plugin_registry = getattr(app.state, 'plugin_registry', None)
enabled_plugin_ids = (
sorted(str(plugin.plugin_id) for plugin in plugin_registry.list_enabled_plugins())
if plugin_registry is not None
else []
)
enabled_plugins = ','.join(enabled_plugin_ids) or 'none'
logger.bind(
startup_generation=generation,
plugin_startup_role=role,
ready_status='success',
enabled_plugin_ids=enabled_plugin_ids,
enabled_plugin_count=len(enabled_plugin_ids),
).info(f'✅ 插件运行时启动完成:role={role},generation={generation[:8]},enabled={enabled_plugins}')
async def shutdown(self, app: FastAPI) -> None:
"""
关闭插件运行时。
:param app: FastAPI对象
:return: None
"""
self._ensure_bound(app)
startup_write_enabled = bool(getattr(app.state, 'plugin_startup_write_enabled', False))
try:
await self.startup_manager.shutdown(app, startup_write_enabled=startup_write_enabled)
finally:
await self._stop_diagnostics(app)
if self.release_worker is not None:
await self.release_worker.stop()
@staticmethod
async def _stop_diagnostics(app: FastAPI) -> None:
"""
关闭诊断采样,观测清理失败不阻止发布 worker 上报最终状态。
:param app: 宿主 FastAPI 应用
:return: None
"""
reporter = getattr(app.state, 'plugin_diagnostics_reporter', None)
if isinstance(reporter, PluginDiagnosticsReporter):
try:
await reporter.stop()
except Exception:
logger.warning('插件运行诊断上报关闭失败,远程快照将按 TTL 过期')
async def clear_startup_ready(self, app: FastAPI, generation: str | None = None) -> None:
"""
清除指定代际的插件启动 ready 标记。
:param app: FastAPI对象
:param generation: 插件启动代际
:return: None
"""
resolved_generation = generation or self.resolve_startup_generation()
await app.state.redis.delete(self.build_ready_key(resolved_generation))
async def mark_startup_ready(self, app: FastAPI, generation: str | None = None) -> None:
"""
标记指定代际已完成插件启动写入。
:param app: FastAPI对象
:param generation: 插件启动代际
:return: None
"""
resolved_generation = generation or self.resolve_startup_generation()
await app.state.redis.set(
self.build_ready_key(resolved_generation),
json.dumps({'generation': resolved_generation, 'status': 'success'}, ensure_ascii=False),
ex=self.ready_expire_seconds,
)
writer_logger = logger.bind(
startup_generation=resolved_generation,
plugin_startup_role='writer',
ready_status='success',
)
failed_plugin_ids = getattr(app.state, 'plugin_dependency_failed_plugin_ids', set())
if failed_plugin_ids:
writer_logger.warning(
f'⚠️ 插件启动协调已完成,依赖检查失败插件已隔离:{"、".join(sorted(failed_plugin_ids))}'
)
return
writer_logger.info('✅ 插件启动资源已就绪')
async def mark_startup_failed(self, app: FastAPI, generation: str, error: Exception) -> None:
"""
标记指定插件启动代际初始化失败。
:param app: FastAPI对象
:param generation: 插件启动代际
:param error: 启动异常
:return: None
"""
await app.state.redis.set(
self.build_ready_key(generation),
json.dumps(
{
'generation': generation,
'status': 'failed',
'error': str(error)[:1000],
},
ensure_ascii=False,
),
ex=self.failed_expire_seconds,
)
async def get_startup_state(self, app: FastAPI, generation: str) -> dict[str, str]:
"""
读取指定代际的插件启动状态。
:param app: FastAPI对象
:param generation: 插件启动代际
:return: 启动状态
"""
raw_state = await app.state.redis.get(self.build_ready_key(generation))
if not raw_state:
return {}
try:
state = json.loads(raw_state)
except (TypeError, json.JSONDecodeError):
return {}
if not isinstance(state, dict) or state.get('generation') != generation:
return {}
return {str(key): str(value) for key, value in state.items()}
def resolve_startup_generation(self) -> str:
"""
解析当前插件启动代际。
:return: 插件启动代际
"""
if self.startup_generation:
return self.startup_generation
generation_resolver = PluginStartupGenerationResolver(self.startup_manager.builder.backend_root)
self.startup_generation = generation_resolver.resolve()
return self.startup_generation
def build_ready_key(self, generation: str) -> str:
"""
构建代际隔离的 ready key。
:param generation: 插件启动代际
:return: Redis key
"""
return f'{self.ready_key}:{generation}'
@staticmethod
def _build_startup_failure_message(state: Mapping[str, str]) -> str:
"""
构建启动失败错误消息。
:param state: 启动状态
:return: 错误消息
"""
error_message = state.get('error') or '未知错误'
return f'插件启动代际 {state.get("generation", "")} 初始化失败:{error_message}'
def _ensure_bound(self, app: FastAPI) -> None:
"""
确保插件运行时已绑定到 app。
:param app: FastAPI对象
:return: None
"""
if getattr(app.state, 'plugin_application_runtime', None) is not self:
self.bind_app(app)
@cache
def get_plugin_application_runtime() -> PluginApplicationRuntime:
"""
获取应用插件运行时适配器。
:return: 应用插件运行时适配器
"""
from plugins.core.deployment.config import PluginDeploymentConfig # noqa: PLC0415
from plugins.core.management.service.startup_gateway import ( # noqa: PLC0415
PluginManagementRouteStateGateway,
PluginManagementStartupGateway,
)
from plugins.core.runtime.service.lifecycle_lock import RedisPluginLifecycleLock # noqa: PLC0415
startup_manager = PluginRuntimeStartupManager(
management_gateway=PluginManagementStartupGateway(),
route_state_gateway=PluginManagementRouteStateGateway(),
)
deployment_config = PluginDeploymentConfig.from_settings(startup_manager.builder.backend_root)
release_worker = None
if deployment_config.enabled:
from plugins.core.deployment.worker import PluginReleaseWorker # noqa: PLC0415
release_worker = PluginReleaseWorker(deployment_config)
return PluginApplicationRuntime(
startup_manager=startup_manager,
lifecycle_lock=RedisPluginLifecycleLock(),
release_worker=release_worker,
)