mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 22:04:57 +08:00
fix(plugin): 旧实例键回滚往返新增的分身要能合并进独立表
`PluginInstances` 旧键刻意保留作回滚依据、从不清理,旧版本只认它,因此它在
迁移之后仍可能长出新条目或被改写。原判据是「一条一次性的永久标记」加「整表
是否为空」:标记落下且表非空之后,回滚到旧版本新建的分身会被永久跳过,保留
旧键作回滚依据的承诺随之落空。两个条件都表达不出「旧载荷相对已导入内容有没有
新增或变化」。
改为按条目记住导入时的内容指纹(`PluginInstancesImported` 的载荷从布尔改为
`{实例ID: 摘要}`):指纹对得上就跳过,独立表里那行用户怎么改都不会被旧副本盖
回去;指纹对不上说明旧版本改写过,合并;指纹没记过且表里也没有该行说明旧版本
新增过,导入;指纹没记过但表里已有该行(alembic 刚搬过去的那一批)只认领指纹
不覆盖。指纹取自原始载荷而非模型 dump,模型字段将来增减才不会让全部指纹一起
失配、把用户改动整批盖回去。指纹表与旧键内容对齐后无变化的启动不再产生写入,
也不额外查表。
This commit is contained in:
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, NamedTuple, Optional
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
@@ -273,6 +275,26 @@ class PluginInstanceDirectory:
|
||||
return self._delete(instance_id)
|
||||
|
||||
|
||||
class _LegacyInstanceEntry(NamedTuple):
|
||||
"""旧 systemconfig 单键里的一条实例描述,连同其原始载荷的内容指纹。"""
|
||||
|
||||
instance: PluginInstance
|
||||
fingerprint: str
|
||||
|
||||
|
||||
def _payload_fingerprint(payload: dict[str, Any]) -> str:
|
||||
"""为旧键里的一条原始载荷算内容指纹,用于判定旧版本此后有没有改动过它。
|
||||
|
||||
指纹取自原始载荷而非模型 dump:模型字段将来增减会让全部指纹一起失配,把用户在
|
||||
独立表里的改动当成「旧载荷变化」整批盖回去。键排序后再序列化,旧版本重写整个键
|
||||
造成的字段顺序变化不会被误判成内容变化。
|
||||
:param payload: 旧键里的单条原始载荷
|
||||
:return: 十六进制摘要
|
||||
"""
|
||||
canonical = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=False)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class PluginInstanceStore:
|
||||
"""管理共享源码的分身实例描述,并把源插件本体自身的那一行隔离在视图之外。
|
||||
|
||||
@@ -292,13 +314,18 @@ class PluginInstanceStore:
|
||||
self._bootstrap_checked = False
|
||||
|
||||
def _ensure_bootstrapped(self) -> None:
|
||||
"""旧 systemconfig 单键的内容向独立表兜底导入一次,且只导入一次。
|
||||
"""把旧 systemconfig 单键里新增或变化的实例描述合并进独立表,每进程判定一次。
|
||||
|
||||
alembic 迁移已经搬过一轮,这里兜的是「库结构升级后又用旧版本写过分身」这类
|
||||
回滚往返。导入完成后落一个持久化标记,判据不能是「表当前为空」:旧键刻意保留
|
||||
作回滚依据、从不清理,用户把分身全部删光后表就会重新变空,下次进程启动会把
|
||||
已删除的分身整批导回来,删一次复活一次。进程内另外维护一个已检查标志,避免
|
||||
每次访问都为此多打一次查询。
|
||||
回滚往返:旧键刻意保留作回滚依据、从不清理,旧版本只认它,因此它在迁移之后
|
||||
仍可能长出新条目或被改写。判据既不能是「表当前为空」——用户删光分身后表会重新
|
||||
变空,下次启动会把删掉的分身整批导回来;也不能是一条一次性的永久标记——标记
|
||||
落下之后旧版本新增的分身就永远看不见。
|
||||
|
||||
因此按条目记住导入时的内容指纹:指纹对得上就跳过,表里那行用户怎么改都不会被
|
||||
旧副本盖回去;指纹对不上说明旧版本改写过该条目,合并进来;指纹没记过且表里也
|
||||
没有该行说明旧版本新增过它,导入。指纹没记过但表里已有该行(迁移刚搬过去的那
|
||||
一批),只认领指纹而不覆盖。指纹表始终与旧键当前内容对齐,重复启动不再产生写入。
|
||||
|
||||
:raise Exception: 导入失败时向上抛出,不吞掉持久化层错误
|
||||
"""
|
||||
@@ -306,15 +333,28 @@ class PluginInstanceStore:
|
||||
return
|
||||
self._bootstrap_checked = True
|
||||
storage = self._storage()
|
||||
if storage.read(SystemConfigKey.PluginInstancesImported):
|
||||
return
|
||||
directory = self._directory()
|
||||
if not directory.list_all():
|
||||
for instance in self._legacy_instances().values():
|
||||
directory.save(instance)
|
||||
storage.write(SystemConfigKey.PluginInstancesImported, True)
|
||||
recorded = storage.read(SystemConfigKey.PluginInstancesImported)
|
||||
imported: dict[str, str] = dict(recorded) if isinstance(recorded, dict) else {}
|
||||
legacy = self._legacy_instances()
|
||||
pending = {
|
||||
instance_id: entry
|
||||
for instance_id, entry in legacy.items()
|
||||
if imported.get(instance_id) != entry.fingerprint
|
||||
}
|
||||
if pending:
|
||||
directory = self._directory()
|
||||
# 只在确有待定条目时扫一次现有行,让无变化的启动不额外查表
|
||||
present = {record.instance_id for record in directory.list_all()}
|
||||
for instance_id, entry in pending.items():
|
||||
if instance_id in imported or instance_id not in present:
|
||||
directory.save(entry.instance)
|
||||
fingerprints = {
|
||||
instance_id: entry.fingerprint for instance_id, entry in legacy.items()
|
||||
}
|
||||
if fingerprints != imported:
|
||||
storage.write(SystemConfigKey.PluginInstancesImported, fingerprints)
|
||||
|
||||
def _legacy_instances(self) -> dict[str, PluginInstance]:
|
||||
def _legacy_instances(self) -> dict[str, _LegacyInstanceEntry]:
|
||||
"""解析旧 systemconfig 单键里的实例描述,兼容历史字典与列表两种载荷形态。"""
|
||||
raw_instances = self._storage().read(SystemConfigKey.PluginInstances) or {}
|
||||
if isinstance(raw_instances, list):
|
||||
@@ -328,13 +368,16 @@ class PluginInstanceStore:
|
||||
else:
|
||||
return {}
|
||||
|
||||
instances: dict[str, PluginInstance] = {}
|
||||
instances: dict[str, _LegacyInstanceEntry] = {}
|
||||
for instance_id, raw_instance in entries.items():
|
||||
try:
|
||||
payload = dict(raw_instance) if isinstance(raw_instance, dict) else {}
|
||||
payload.setdefault("instance_id", instance_id)
|
||||
instance = PluginInstance.model_validate(payload)
|
||||
instances[instance.instance_id] = instance
|
||||
instances[instance.instance_id] = _LegacyInstanceEntry(
|
||||
instance=instance,
|
||||
fingerprint=_payload_fingerprint(payload),
|
||||
)
|
||||
except (TypeError, ValidationError):
|
||||
continue
|
||||
return instances
|
||||
|
||||
@@ -386,7 +386,8 @@ class SystemConfigKey(Enum):
|
||||
UserInstalledPlugins = "UserInstalledPlugins"
|
||||
# 共享源码插件的虚拟运行实例(已迁移到独立表,本键保留作回滚依据)
|
||||
PluginInstances = "PluginInstances"
|
||||
# 上面那个旧键是否已完成向独立表的兜底导入,避免用户删光分身后被重新导回
|
||||
# 上面那个旧键各条目导入独立表时的内容指纹 {实例ID: 摘要},据此识别回滚到旧版本
|
||||
# 后新增或改写过的条目,同时避免把用户删掉的分身重新导回
|
||||
PluginInstancesImported = "PluginInstancesImported"
|
||||
# 插件文件夹分组配置
|
||||
PluginFolders = "PluginFolders"
|
||||
|
||||
@@ -37,6 +37,23 @@ def _make_directory() -> tuple[PluginInstanceDirectory, dict[str, PluginInstance
|
||||
return directory, records
|
||||
|
||||
|
||||
def _make_storage(values: dict) -> tuple[PluginStorage, list]:
|
||||
"""构造进程内配置存储,并记录写入过的键,供幂等性断言使用。"""
|
||||
written: list = []
|
||||
|
||||
def _write(key, value):
|
||||
"""记录写入键并落进内存配置字典。"""
|
||||
written.append(key)
|
||||
values[key] = value
|
||||
|
||||
return PluginStorage(read=values.get, write=_write), written
|
||||
|
||||
|
||||
def _legacy_values(**entries) -> dict:
|
||||
"""构造只含旧 systemconfig 单键的配置字典。"""
|
||||
return {SystemConfigKey.PluginInstances: dict(entries)}
|
||||
|
||||
|
||||
def _logger() -> SimpleNamespace:
|
||||
"""提供加载器测试所需的最小日志对象。"""
|
||||
return SimpleNamespace(
|
||||
@@ -152,6 +169,134 @@ def test_legacy_instances_are_not_reimported_after_being_deleted():
|
||||
assert values[SystemConfigKey.PluginInstances] is not None
|
||||
|
||||
|
||||
def test_legacy_entries_added_by_an_older_version_are_merged_in():
|
||||
"""回滚到旧版本新增的分身,切回新版本后要能合并进独立表。
|
||||
|
||||
旧版本只认 `PluginInstances` 旧键,在那里新建的分身既不在独立表里,也不在
|
||||
已导入指纹里。若判据只有「一条永久标记」加「整表是否为空」,标记落下且表非空
|
||||
之后这些分身会被永久跳过,保留旧键作回滚依据的承诺就落空了。
|
||||
"""
|
||||
values = _legacy_values(
|
||||
DemoPluginWork={
|
||||
"instance_id": "DemoPluginWork",
|
||||
"source_plugin_id": "DemoPlugin",
|
||||
}
|
||||
)
|
||||
storage, _written = _make_storage(values)
|
||||
directory, records = _make_directory()
|
||||
|
||||
# 新版本首次启动:旧键内容导入独立表,落下已导入指纹
|
||||
first = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
assert set(first.all()) == {"DemoPluginWork"}
|
||||
assert values[SystemConfigKey.PluginInstancesImported]
|
||||
|
||||
# 回滚到旧版本:旧版本只往旧键里新建分身,独立表保持不动
|
||||
values[SystemConfigKey.PluginInstances]["DemoPluginHome"] = {
|
||||
"instance_id": "DemoPluginHome",
|
||||
"source_plugin_id": "DemoPlugin",
|
||||
"plugin_name": "家庭实例",
|
||||
}
|
||||
|
||||
# 切回新版本
|
||||
second = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
|
||||
assert set(second.all()) == {"DemoPluginWork", "DemoPluginHome"}
|
||||
assert records["DemoPluginHome"].plugin_name == "家庭实例"
|
||||
|
||||
|
||||
def test_legacy_entries_changed_by_an_older_version_are_merged_in():
|
||||
"""回滚到旧版本改写过的分身,切回新版本后要按旧键的新内容合并。"""
|
||||
values = _legacy_values(
|
||||
DemoPluginWork={
|
||||
"instance_id": "DemoPluginWork",
|
||||
"source_plugin_id": "DemoPlugin",
|
||||
"plugin_name": "工作实例",
|
||||
}
|
||||
)
|
||||
storage, _written = _make_storage(values)
|
||||
directory, records = _make_directory()
|
||||
|
||||
first = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
assert first.all()["DemoPluginWork"].plugin_name == "工作实例"
|
||||
|
||||
values[SystemConfigKey.PluginInstances]["DemoPluginWork"]["plugin_name"] = "旧版本改过"
|
||||
|
||||
second = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
|
||||
assert second.all()["DemoPluginWork"].plugin_name == "旧版本改过"
|
||||
assert records["DemoPluginWork"].plugin_name == "旧版本改过"
|
||||
|
||||
|
||||
def test_rows_edited_in_the_new_table_are_not_overwritten_by_stale_legacy_copies():
|
||||
"""旧键没变时,独立表里被用户改过的行不得被旧键里的陈旧副本盖回去。"""
|
||||
values = _legacy_values(
|
||||
DemoPluginWork={
|
||||
"instance_id": "DemoPluginWork",
|
||||
"source_plugin_id": "DemoPlugin",
|
||||
"plugin_name": "工作实例",
|
||||
}
|
||||
)
|
||||
storage, _written = _make_storage(values)
|
||||
directory, records = _make_directory()
|
||||
|
||||
first = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
assert set(first.all()) == {"DemoPluginWork"}
|
||||
|
||||
# 用户在新版本里改名,旧键始终没人动过
|
||||
records["DemoPluginWork"] = PluginInstance(
|
||||
instance_id="DemoPluginWork",
|
||||
source_plugin_id="DemoPlugin",
|
||||
plugin_name="用户改过",
|
||||
)
|
||||
|
||||
second = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
|
||||
assert second.all()["DemoPluginWork"].plugin_name == "用户改过"
|
||||
|
||||
|
||||
def test_rows_migrated_by_alembic_are_claimed_without_being_overwritten():
|
||||
"""迁移已搬进独立表、指纹还没记过的那批行,只认领指纹不覆盖内容。"""
|
||||
values = _legacy_values(
|
||||
DemoPluginWork={
|
||||
"instance_id": "DemoPluginWork",
|
||||
"source_plugin_id": "DemoPlugin",
|
||||
"plugin_name": "工作实例",
|
||||
}
|
||||
)
|
||||
storage, _written = _make_storage(values)
|
||||
directory, records = _make_directory()
|
||||
# 模拟 alembic 迁移搬完之后用户又改过名
|
||||
records["DemoPluginWork"] = PluginInstance(
|
||||
instance_id="DemoPluginWork",
|
||||
source_plugin_id="DemoPlugin",
|
||||
plugin_name="迁移后改过",
|
||||
)
|
||||
|
||||
store = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
|
||||
assert store.all()["DemoPluginWork"].plugin_name == "迁移后改过"
|
||||
assert set(values[SystemConfigKey.PluginInstancesImported]) == {"DemoPluginWork"}
|
||||
|
||||
|
||||
def test_merging_legacy_entries_is_idempotent_across_restarts():
|
||||
"""旧键没有新增或变化时,重复启动既不重复建行也不重复落盘指纹。"""
|
||||
values = _legacy_values(
|
||||
DemoPluginWork={
|
||||
"instance_id": "DemoPluginWork",
|
||||
"source_plugin_id": "DemoPlugin",
|
||||
}
|
||||
)
|
||||
storage, written = _make_storage(values)
|
||||
directory, records = _make_directory()
|
||||
|
||||
for _restart in range(3):
|
||||
store = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
assert set(store.all()) == {"DemoPluginWork"}
|
||||
|
||||
assert set(records) == {"DemoPluginWork"}
|
||||
assert written == [SystemConfigKey.PluginInstancesImported]
|
||||
|
||||
|
||||
def test_loader_executes_each_instance_in_an_isolated_module_namespace(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
Reference in New Issue
Block a user