mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +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 与技能文档的两个生成脚本都跑不起来。
134 lines
4.2 KiB
Python
134 lines
4.2 KiB
Python
"""插件实例日志等级覆盖列的持久化读写测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlalchemy import delete
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.db.models.plugininstance import PluginInstance
|
|
from app.db.oper.plugininstance import PluginInstanceOper
|
|
from app.db.uow import run_sync_transaction
|
|
|
|
_INSTANCE_IDS = ("LogLevelHost", "LogLevelHost@clone1", "LogLevelNeverSet")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def purge_written_rows():
|
|
"""删除用例写入的行,测试库在整个会话内共享,残留会干扰后续用例。"""
|
|
yield
|
|
|
|
def purge(session: Session) -> None:
|
|
"""按本文件使用的固定标识精确删除,不触碰其他用例的数据。"""
|
|
session.execute(
|
|
delete(PluginInstance).where(
|
|
PluginInstance.instance_id.in_(_INSTANCE_IDS)
|
|
)
|
|
)
|
|
|
|
run_sync_transaction(purge)
|
|
|
|
|
|
def test_set_log_level_creates_host_row_when_instance_has_no_row_yet():
|
|
"""只设过等级、从没存过参数的插件也要有一行,否则覆盖无处落盘。"""
|
|
oper = PluginInstanceOper()
|
|
|
|
assert oper.set_log_level(
|
|
instance_id="LogLevelHost",
|
|
log_level="DEBUG",
|
|
log_expires_at="2026-01-01T00:00:00+00:00",
|
|
) is True
|
|
|
|
record = oper.get("LogLevelHost")
|
|
assert record is not None
|
|
assert record.is_host is True
|
|
assert record.log_level == "DEBUG"
|
|
assert record.log_expires_at == "2026-01-01T00:00:00+00:00"
|
|
|
|
|
|
def test_set_log_level_keeps_existing_config_and_identity_on_a_clone_row():
|
|
"""在分身行上写等级只动两列,业务参数与归属原样保留。"""
|
|
oper = PluginInstanceOper()
|
|
oper.save(instance_id="LogLevelHost@clone1", source_plugin_id="LogLevelHost")
|
|
oper.save_config_data(
|
|
instance_id="LogLevelHost@clone1",
|
|
source_plugin_id="LogLevelHost",
|
|
config_data={"enable": True},
|
|
)
|
|
|
|
assert oper.set_log_level(
|
|
instance_id="LogLevelHost@clone1",
|
|
log_level="ERROR",
|
|
log_expires_at=None,
|
|
) is True
|
|
|
|
record = oper.get("LogLevelHost@clone1")
|
|
assert record is not None
|
|
assert record.log_level == "ERROR"
|
|
assert record.log_expires_at is None
|
|
assert record.config_data == {"enable": True}
|
|
assert record.source_plugin_id == "LogLevelHost"
|
|
|
|
|
|
def test_clearing_log_level_recycles_a_host_row_that_carries_nothing_else():
|
|
"""清除覆盖后只剩一对身份列的本体行要回收,不留空行堆积。"""
|
|
oper = PluginInstanceOper()
|
|
oper.set_log_level(
|
|
instance_id="LogLevelHost",
|
|
log_level="WARNING",
|
|
log_expires_at=None,
|
|
)
|
|
|
|
assert oper.set_log_level(
|
|
instance_id="LogLevelHost",
|
|
log_level=None,
|
|
log_expires_at=None,
|
|
) is True
|
|
|
|
assert oper.get("LogLevelHost") is None
|
|
|
|
|
|
def test_clearing_log_level_keeps_a_clone_row_and_a_host_row_with_config():
|
|
"""分身行与仍承载配置的本体行不因清除等级而消失。"""
|
|
oper = PluginInstanceOper()
|
|
oper.save(instance_id="LogLevelHost@clone1", source_plugin_id="LogLevelHost")
|
|
oper.set_log_level(
|
|
instance_id="LogLevelHost@clone1",
|
|
log_level="DEBUG",
|
|
log_expires_at=None,
|
|
)
|
|
oper.save_config_data(
|
|
instance_id="LogLevelHost",
|
|
source_plugin_id="LogLevelHost",
|
|
config_data={"enable": False},
|
|
)
|
|
oper.set_log_level(
|
|
instance_id="LogLevelHost",
|
|
log_level="DEBUG",
|
|
log_expires_at=None,
|
|
)
|
|
|
|
oper.set_log_level(
|
|
instance_id="LogLevelHost@clone1", log_level=None, log_expires_at=None
|
|
)
|
|
oper.set_log_level(instance_id="LogLevelHost", log_level=None, log_expires_at=None)
|
|
|
|
clone = oper.get("LogLevelHost@clone1")
|
|
assert clone is not None
|
|
assert clone.log_level is None
|
|
host = oper.get("LogLevelHost")
|
|
assert host is not None
|
|
assert host.config_data == {"enable": False}
|
|
|
|
|
|
def test_clearing_log_level_for_an_unknown_instance_creates_nothing():
|
|
"""清除一个从未落盘过的实例不得凭空建出一行。"""
|
|
oper = PluginInstanceOper()
|
|
|
|
assert oper.set_log_level(
|
|
instance_id="LogLevelNeverSet",
|
|
log_level=None,
|
|
log_expires_at=None,
|
|
) is False
|
|
assert oper.get("LogLevelNeverSet") is None
|