fix(plugin): 旧实例键回滚往返新增的分身要能合并进独立表

`PluginInstances` 旧键刻意保留作回滚依据、从不清理,旧版本只认它,因此它在
迁移之后仍可能长出新条目或被改写。原判据是「一条一次性的永久标记」加「整表
是否为空」:标记落下且表非空之后,回滚到旧版本新建的分身会被永久跳过,保留
旧键作回滚依据的承诺随之落空。两个条件都表达不出「旧载荷相对已导入内容有没有
新增或变化」。

改为按条目记住导入时的内容指纹(`PluginInstancesImported` 的载荷从布尔改为
`{实例ID: 摘要}`):指纹对得上就跳过,独立表里那行用户怎么改都不会被旧副本盖
回去;指纹对不上说明旧版本改写过,合并;指纹没记过且表里也没有该行说明旧版本
新增过,导入;指纹没记过但表里已有该行(alembic 刚搬过去的那一批)只认领指纹
不覆盖。指纹取自原始载荷而非模型 dump,模型字段将来增减才不会让全部指纹一起
失配、把用户改动整批盖回去。指纹表与旧键内容对齐后无变化的启动不再产生写入,
也不额外查表。
This commit is contained in:
Aqr-K
2026-09-13 00:14:47 -04:00
parent 617179b560
commit 3eee49659d
3 changed files with 206 additions and 17 deletions

View File

@@ -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

View File

@@ -386,7 +386,8 @@ class SystemConfigKey(Enum):
UserInstalledPlugins = "UserInstalledPlugins"
# 共享源码插件的虚拟运行实例(已迁移到独立表,本键保留作回滚依据)
PluginInstances = "PluginInstances"
# 上面那个旧键是否已完成向独立表的兜底导入,避免用户删光分身后被重新导回
# 上面那个旧键各条目导入独立表时的内容指纹 {实例ID: 摘要},据此识别回滚到旧版本
# 后新增或改写过的条目,同时避免把用户删掉的分身重新导回
PluginInstancesImported = "PluginInstancesImported"
# 插件文件夹分组配置
PluginFolders = "PluginFolders"

View File

@@ -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,