mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-12 00:30:28 +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 与技能文档的两个生成脚本都跑不起来。
96 lines
4.3 KiB
Python
96 lines
4.3 KiB
Python
"""共享源码插件的实例描述符与配置持久化模型。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, List, Optional, Self, cast
|
||
|
||
from sqlalchemy import JSON, Index, String, UniqueConstraint, select
|
||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||
|
||
from app.db.base import Base, get_id_column
|
||
|
||
|
||
class PluginInstance(Base):
|
||
"""持久化一个共享源码插件的运行实例,一实例一行。
|
||
|
||
``instance_id`` 既是分身的实例 ID,也可以等于 ``source_plugin_id`` 表示源插件
|
||
本体自身;两者相等即本体,不等即分身,不再另设模式列——该列会是从这个等式复制
|
||
出来的冗余副本,两者一旦失步就会让同一行在不同读取口被判成不同角色。
|
||
|
||
身份(``instance_id`` 与 ``source_plugin_id``)之外的列都是这个实例的设置:
|
||
展示信息与业务参数同属一个生命周期,应当随卸载一起消失、随重建一起重来,因此
|
||
存放在同一行而非分表协调。
|
||
|
||
业务参数存进 ``config_data`` 而不是 ``systemconfig`` 的 ``plugin.<实例ID>`` 单键:
|
||
那个键由插件 ID 决定、条目数随安装量增长,混在系统设置里会把主程序自己的设置项
|
||
淹掉;而且它存的其实是实例 ID,表里没有任何一列说得出它属于哪个插件,想列出
|
||
某个插件的全部实例配置就只能靠字符串前缀去猜。
|
||
|
||
``log_level`` 为空表示该实例跟随全局日志等级;非空且未过期时覆盖全局等级,
|
||
``log_expires_at`` 为空表示覆盖不过期。过期判定在读取时惰性执行,实现见
|
||
``app.runtime.log``,库里只存原样设置,不存已折算的结果。
|
||
|
||
表名由 ``Base`` 按类名自动派生为小写 ``plugininstance``。
|
||
"""
|
||
|
||
id = get_id_column()
|
||
instance_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||
source_plugin_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||
plugin_name: Mapped[Optional[str]] = mapped_column(String(255))
|
||
plugin_desc: Mapped[Optional[str]] = mapped_column(String(255))
|
||
plugin_icon: Mapped[Optional[str]] = mapped_column(String(255))
|
||
log_level: Mapped[Optional[str]] = mapped_column(String(16))
|
||
log_expires_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||
config_data: Mapped[Optional[Any]] = mapped_column(JSON)
|
||
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("instance_id", name="uq_plugininstance_instance_id"),
|
||
Index("ix_plugininstance_source_plugin_id", "source_plugin_id"),
|
||
)
|
||
|
||
@property
|
||
def is_host(self) -> bool:
|
||
"""该行是否为源插件本体自身,而非共享其源码的分身。"""
|
||
return bool(self.instance_id == self.source_plugin_id)
|
||
|
||
@property
|
||
def carries_only_identity(self) -> bool:
|
||
"""除一对身份列外是否已不承载任何设置。
|
||
|
||
本体行会在插件首次存配置时被隐式建出,配置被删掉后若各列皆空就只剩身份列,
|
||
留着会让「有哪些插件登记过本体设置」的枚举逐步失真,因而可以回收。分身行不
|
||
适用:分身的存在本身就由这一行表达,清空设置不等于删除分身。
|
||
"""
|
||
return not any(
|
||
(
|
||
self.config_data is not None,
|
||
self.log_level,
|
||
self.log_expires_at,
|
||
self.plugin_name,
|
||
self.plugin_desc,
|
||
self.plugin_icon,
|
||
)
|
||
)
|
||
|
||
@classmethod
|
||
def get_by_instance_id(cls, db: Session, instance_id: str) -> Optional[Self]:
|
||
"""在调用方 Session 中按实例 ID 查询单行。"""
|
||
return cast(
|
||
Optional[Self],
|
||
db.execute(select(cls).where(cls.instance_id == instance_id)).scalars().first(),
|
||
)
|
||
|
||
@classmethod
|
||
def list_by_source_plugin_id(cls, db: Session, source_plugin_id: str) -> List[Self]:
|
||
"""在调用方 Session 中列出某个源插件名下的全部实例,含本体与各个分身。"""
|
||
return cast(
|
||
List[Self],
|
||
list(
|
||
db.execute(
|
||
select(cls).where(cls.source_plugin_id == source_plugin_id)
|
||
).scalars()
|
||
),
|
||
)
|