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 与技能文档的两个生成脚本都跑不起来。
133 lines
4.8 KiB
Python
133 lines
4.8 KiB
Python
"""插件实例日志等级覆盖列 Alembic 迁移测试。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib
|
||
from datetime import datetime, timezone
|
||
|
||
import sqlalchemy as sa
|
||
from alembic.migration import MigrationContext
|
||
from alembic.operations import Operations
|
||
|
||
from app.db.models.plugininstance import PluginInstance
|
||
|
||
MIGRATION_MODULE = "database.versions.487f7e681955_3_0_36"
|
||
|
||
|
||
def _bind_migration(monkeypatch, connection):
|
||
"""把迁移绑定到隔离数据库连接。"""
|
||
migration = importlib.import_module(MIGRATION_MODULE)
|
||
context = MigrationContext.configure(connection)
|
||
monkeypatch.setattr(migration, "op", Operations(context))
|
||
return migration
|
||
|
||
|
||
def _create_legacy_table(connection: sa.engine.Connection) -> None:
|
||
"""建出加列前的表结构(3.0.35 的落点),模拟迁移前的存量数据库。"""
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
table = sa.Table(
|
||
"plugininstance",
|
||
sa.MetaData(),
|
||
sa.Column("id", sa.Integer(), primary_key=True),
|
||
sa.Column("instance_id", sa.String(length=128), nullable=False),
|
||
sa.Column("source_plugin_id", sa.String(length=128), nullable=False),
|
||
sa.Column("plugin_name", sa.String(length=255)),
|
||
sa.Column("plugin_desc", sa.String(length=255)),
|
||
sa.Column("plugin_icon", sa.String(length=255)),
|
||
sa.Column("config_data", sa.JSON()),
|
||
sa.Column("created_at", sa.String(length=40), nullable=False),
|
||
sa.Column("updated_at", sa.String(length=40), nullable=False),
|
||
)
|
||
table.create(connection)
|
||
connection.execute(
|
||
table.insert().values(
|
||
instance_id="DemoPluginWork",
|
||
source_plugin_id="DemoPlugin",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
)
|
||
|
||
|
||
def test_plugin_instance_log_level_migration_adds_columns_and_keeps_existing_rows(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""新增列必须可空,且不得影响已有行;重复升级与完整回滚都要幂等。"""
|
||
engine = sa.create_engine("sqlite://")
|
||
with engine.begin() as connection:
|
||
_create_legacy_table(connection)
|
||
migration = _bind_migration(monkeypatch, connection)
|
||
|
||
migration.upgrade()
|
||
migration.upgrade()
|
||
|
||
columns = {
|
||
column["name"]: column
|
||
for column in sa.inspect(connection).get_columns("plugininstance")
|
||
}
|
||
# 与该迁移自身的落点比对,而不是与持续演进的当前模型比对:后续迁移
|
||
# 还会继续给该表加列,本断言不应该随之跟着变红。
|
||
assert columns.keys() == {
|
||
"id",
|
||
"instance_id",
|
||
"source_plugin_id",
|
||
"plugin_name",
|
||
"plugin_desc",
|
||
"plugin_icon",
|
||
"config_data",
|
||
"log_level",
|
||
"log_expires_at",
|
||
"created_at",
|
||
"updated_at",
|
||
}
|
||
assert columns["log_level"]["nullable"] is True
|
||
assert columns["log_expires_at"]["nullable"] is True
|
||
|
||
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
|
||
row = connection.execute(sa.select(table)).mappings().one()
|
||
assert row["instance_id"] == "DemoPluginWork"
|
||
assert row["log_level"] is None
|
||
assert row["log_expires_at"] is None
|
||
|
||
migration.downgrade()
|
||
remaining = {
|
||
column["name"] for column in sa.inspect(connection).get_columns("plugininstance")
|
||
}
|
||
assert "log_level" not in remaining
|
||
assert "log_expires_at" not in remaining
|
||
|
||
migration.upgrade()
|
||
restored = {
|
||
column["name"] for column in sa.inspect(connection).get_columns("plugininstance")
|
||
}
|
||
assert {"log_level", "log_expires_at"}.issubset(restored)
|
||
|
||
|
||
def test_plugin_instance_log_level_migration_accepts_fresh_current_schema(
|
||
monkeypatch,
|
||
) -> None:
|
||
"""create_all 已建当前表时重复升级不得因列已存在而报错。"""
|
||
engine = sa.create_engine("sqlite://")
|
||
with engine.begin() as connection:
|
||
PluginInstance.__table__.create(connection)
|
||
migration = _bind_migration(monkeypatch, connection)
|
||
|
||
migration.upgrade()
|
||
migration.upgrade()
|
||
|
||
assert {
|
||
column["name"]
|
||
for column in sa.inspect(connection).get_columns("plugininstance")
|
||
} == {column.name for column in PluginInstance.__table__.columns}
|
||
|
||
|
||
def test_plugin_instance_log_level_migration_skips_when_table_absent(monkeypatch) -> None:
|
||
"""表尚未建出时升级要安静跳过,不得因反射不到表而炸掉整条迁移链。"""
|
||
engine = sa.create_engine("sqlite://")
|
||
with engine.begin() as connection:
|
||
migration = _bind_migration(monkeypatch, connection)
|
||
|
||
migration.upgrade()
|
||
|
||
assert "plugininstance" not in set(sa.inspect(connection).get_table_names())
|