mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-10 07:39:24 +08:00
全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行 日志会淹没在其他几十个插件与宿主自身的 DEBUG 输出里,找不出来;不下调又什么都 看不见。插件实例数量只会继续增长,这个矛盾不会自己消失。 因此把等级下放到实例:plugininstance 行加 log_level / log_expires_at 两列,本体与 分身共用同一张表、同一套语义。等级落在实例自己那一行而不是另起一张按实例 ID 索引 的表——它和业务参数一样是用户按实例设置的东西,同一个生命周期,实例被删时应当随行 一起消失。清除覆盖后若本体行只剩一对身份列,整行按 carries_only_identity 回收,与 清空业务参数同一规则。 覆盖带失效时间,因为它是调试设置不是长期配置:开了 DEBUG 忘记关,下次谁也想不起 某个插件为什么一直在刷日志。过期判定在读取时惰性执行并就地清理,不另起后台线程去 扫一张最多几十条的表。expires_at 在写入侧先归一到 UTC 再分发给缓存与落盘两处:客户端 提交的时间可能不带时区,两处各按进程时区折算一次,就会与读取口返回的 UTC 值差出一个 时区,表现为「提交的失效时刻和读回来的对不上」。 运行期用 ContextVar 在宿主自己控制的四类调用点绑定当前实例——构造与 init_plugin、 事件处理器、定时服务回调、插件声明的 HTTP 端点——而不是沿用 logger() 已有的栈帧内省。 内省认的是栈顶的模块与类,插件通过宿主公共方法转发调用时栈顶是宿主而不是发起调用的 插件,会把日志记到错误的来源上;分身更甚:分身共享源码,类的 __qualname__ 保持源类名 不变,内省根本分不出是哪一个分身在打日志。内省结果继续只用于文件路由,不参与等级判定。 等级过滤必须排在全局等级判定之前。官方现状是先按全局等级短路、之后才做内省,先短路 就会把实例调低的等级挡在门外,后面再怎么识别来源都看不到这条日志。 回调在注册时被捕获、稍后才由调度器或 FastAPI 调用,那时已经不在任何绑定作用域内, 故这两处改用 wrap_for_plugin_instance 把绑定包进回调自身;同一实例重复包装直接返回 原对象,避免插件缓存声明时包装链随重载次数无限增长。 进程内覆盖表按实例 ID 常驻,删分身时必须一并清掉:只删库里那一行的话,同一进程内用 相同后缀重建的分身会继承上一个分身的等级,界面显示「跟随全局」而实际仍按旧等级输出。 进程重启后反向从库预热缓存。 迁移链在 F1 之后追加:c4e1a7b9d2f6(3.0.35)-> 487f7e681955(3.0.36 加两列)。 顺带补了 agent 策略里缺失的 media.classification.policy.get 的 template 字段说明: 少了它,api_mcp_schema.json 与技能文档的两个生成脚本都跑不起来。
138 lines
6.0 KiB
Python
138 lines
6.0 KiB
Python
"""插件实例日志等级查询与设置。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Callable
|
||
from datetime import datetime
|
||
from typing import Any, Optional
|
||
|
||
from app.runtime.log import (
|
||
clear_plugin_instance_log_level,
|
||
get_effective_plugin_instance_log_level,
|
||
get_plugin_instance_log_level_override,
|
||
normalize_log_expiry,
|
||
set_plugin_instance_log_level,
|
||
)
|
||
from app.schemas.plugin import PluginInstance
|
||
|
||
|
||
class PluginLogLevelControl:
|
||
"""组装插件全部实例(含本体)的日志等级设置,并同步落盘与进程内缓存。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
plugin_exists: Callable[[str], bool],
|
||
get_instance: Callable[[str], Optional[PluginInstance]],
|
||
instances_for_source: Callable[[str], list[PluginInstance]],
|
||
read_log_level: Callable[[str], tuple[Optional[str], Optional[datetime]]],
|
||
write_log_level: Callable[[str, Optional[str], Optional[datetime]], None],
|
||
) -> None:
|
||
"""保存插件存在性判定、实例枚举端口与日志等级的配置侧读写端口。
|
||
|
||
实例存储只回答「有哪些分身」;日志等级是用户按实例设置、带失效时间的配置,
|
||
与业务参数同一生命周期,因此落在插件实例配置表而不是实例描述符上。
|
||
"""
|
||
self._plugin_exists = plugin_exists
|
||
self._get_instance = get_instance
|
||
self._instances_for_source = instances_for_source
|
||
self._read_log_level = read_log_level
|
||
self._write_log_level = write_log_level
|
||
|
||
@staticmethod
|
||
def _host_instance(plugin_id: str) -> PluginInstance:
|
||
"""构造源插件本体自身的实例视图。
|
||
|
||
本体不需要从实例表里读:这里只用到它的实例 ID,而本体的实例 ID 恒等于
|
||
插件 ID。去查表反而会把「本体从未登记过任何设置」这件事变成查不到、
|
||
进而列不出本体一项。
|
||
"""
|
||
return PluginInstance(
|
||
instance_id=plugin_id,
|
||
source_plugin_id=plugin_id,
|
||
)
|
||
|
||
def _resolve(self, plugin_id: str, instance_id: str) -> PluginInstance:
|
||
"""按插件 ID 和实例 ID 定位实例描述,实例须真实归属该插件。
|
||
|
||
:raise LookupError: 插件不存在,``plugin_id`` 实为某个分身自身的实例 ID,
|
||
或实例不存在/不归属该插件
|
||
"""
|
||
if not self._plugin_exists(plugin_id):
|
||
raise LookupError(f"插件 {plugin_id} 不存在")
|
||
if self._get_instance(plugin_id) is not None:
|
||
raise LookupError(f"{plugin_id} 是分身实例,请使用源插件 ID 管理日志等级")
|
||
if instance_id == plugin_id:
|
||
return self._host_instance(plugin_id)
|
||
instance = self._get_instance(instance_id)
|
||
if instance is None or instance.source_plugin_id != plugin_id:
|
||
raise LookupError(f"插件实例 {instance_id} 不存在")
|
||
return instance
|
||
|
||
@staticmethod
|
||
def _describe(instance: PluginInstance) -> dict[str, Any]:
|
||
"""把一个实例的日志等级覆盖投影为查询响应条目。"""
|
||
override = get_plugin_instance_log_level_override(instance.instance_id)
|
||
configured_level, expires_at = override if override is not None else (None, None)
|
||
return {
|
||
"instance_id": instance.instance_id,
|
||
"configured_level": configured_level,
|
||
"expires_at": expires_at,
|
||
"effective_level": get_effective_plugin_instance_log_level(instance.instance_id),
|
||
}
|
||
|
||
def list_levels(self, plugin_id: str) -> list[dict[str, Any]]:
|
||
"""列出插件全部实例(含本体)当前的日志等级设置。
|
||
|
||
:param plugin_id: 插件 ID
|
||
:return: 每个实例的等级设置条目列表,首项固定是本体自身
|
||
:raise LookupError: 插件不存在,或 ``plugin_id`` 实为某个分身自身的实例 ID
|
||
"""
|
||
if not self._plugin_exists(plugin_id):
|
||
raise LookupError(f"插件 {plugin_id} 不存在")
|
||
if self._get_instance(plugin_id) is not None:
|
||
raise LookupError(f"{plugin_id} 是分身实例,请使用源插件 ID 查询日志等级")
|
||
instances = [
|
||
self._host_instance(plugin_id),
|
||
*self._instances_for_source(plugin_id),
|
||
]
|
||
return [self._describe(instance) for instance in instances]
|
||
|
||
def set_level(
|
||
self,
|
||
plugin_id: str,
|
||
instance_id: str,
|
||
level: str,
|
||
expires_at: Optional[datetime] = None,
|
||
) -> None:
|
||
"""设置指定实例的日志等级覆盖,写入进程内缓存后立即落盘。
|
||
|
||
失效时间先归一到 UTC 再分发给缓存与落盘两处:客户端提交的时间可能不带
|
||
时区,两处各自按本地时区折算一次就会和读取口返回的 UTC 值对不上。
|
||
|
||
:param plugin_id: 插件 ID
|
||
:param instance_id: 实例 ID
|
||
:param level: 目标日志等级
|
||
:param expires_at: 覆盖失效时间,None 表示不过期
|
||
:raise LookupError: 插件不存在,或实例不存在/不归属该插件
|
||
:raise ValueError: level 不是受支持的等级名
|
||
"""
|
||
self._resolve(plugin_id, instance_id)
|
||
expiry = normalize_log_expiry(expires_at)
|
||
set_plugin_instance_log_level(instance_id, level, expiry)
|
||
self._write_log_level(instance_id, level.strip().upper(), expiry)
|
||
|
||
def clear_level(self, plugin_id: str, instance_id: str) -> None:
|
||
"""清除指定实例的日志等级覆盖,运行期立即回落全局等级;重复清除保持幂等。
|
||
|
||
:param plugin_id: 插件 ID
|
||
:param instance_id: 实例 ID
|
||
:raise LookupError: 插件不存在,或实例不存在/不归属该插件
|
||
"""
|
||
self._resolve(plugin_id, instance_id)
|
||
clear_plugin_instance_log_level(instance_id)
|
||
level, expires_at = self._read_log_level(instance_id)
|
||
if level is None and expires_at is None:
|
||
return
|
||
self._write_log_level(instance_id, None, None)
|