fix(plugin): release reloaded plugin code held by framework caches (#6807)

This commit is contained in:
InfinityPacer
2026-09-28 13:32:19 +08:00
committed by GitHub
parent d4238982d2
commit 2a68dd6fea
3 changed files with 226 additions and 0 deletions

View File

@@ -6,9 +6,24 @@ from threading import Lock
from typing import Any, Callable, Optional
from fastapi import Depends, FastAPI
from fastapi.dependencies import models as fastapi_dependency_models
from fastapi.routing import APIRoute
def _clear_fastapi_callable_caches() -> None:
"""清空 FastAPI 按可调用对象身份缓存的分类结果。
FastAPI 在模块级 ``lru_cache`` 中以强引用保存每个端点与依赖函数(容量 4096),
插件路由移除后旧端点函数仍被缓存持有,进而经 ``__globals__`` 留住整个旧插件模块,
每次重载都多留一份。缓存只记录“是否生成器/协程”这类分类结果,清空后下次请求按需
重算。缓存属于 FastAPI 私有实现,按属性探测,版本不再提供时自然跳过。
"""
for value in vars(fastapi_dependency_models).values():
cache_clear = getattr(value, "cache_clear", None)
if callable(cache_clear):
cache_clear()
class FastAPIDynamicRouteRegistry:
"""在 FastAPI 上注册插件自由响应路由,并维护 OpenAPI 缓存。"""
@@ -95,6 +110,7 @@ class FastAPIDynamicRouteRegistry:
raise ValueError("Action must be 'add' or 'remove'")
modified = False
removed = False
existing_paths = {
path: route
for route in self._app.routes
@@ -104,6 +120,7 @@ class FastAPIDynamicRouteRegistry:
for current_id in plugin_ids:
if self.remove(current_id):
modified = True
removed = True
if action != "add":
continue
for source_api in self._plugin_apis(current_id):
@@ -136,6 +153,9 @@ class FastAPIDynamicRouteRegistry:
self.clean(existing_paths)
self._app.openapi_schema = None
self._app.setup()
if removed:
# 旧路由已不在路由表中,此后不会再有请求把旧端点写回缓存
_clear_fastapi_callable_caches()
def remove(self, plugin_id: str) -> bool:
"""移除指定插件前缀下的全部动态路由。"""

View File

@@ -7,6 +7,7 @@ import importlib.util
import sys
import threading
import traceback
import typing
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any, Optional
@@ -37,6 +38,83 @@ def _undeclared_runtime(_: str) -> Mapping[str, bool]:
PluginRuntimeStatusWriter = Callable[[str, PluginRuntimeStatus], None]
# ---- 插件模块卸载后释放第三方类型缓存 ----
# 插件重载或卸载只把模块移出 sys.modules;若旧插件类仍被进程级类型缓存引用,
# 整份旧模块(函数、类、模块全局)会一直留在内存中,每重载一次多留一份。
# 类型参数嵌套深度上限,防止病态注解导致递归过深
_MAX_TYPE_DEPTH = 8
def release_plugin_type_caches(module_prefix: str) -> None:
"""释放缓存中引用指定插件模块前缀下类型的条目。
- Pydantic 泛型模型缓存:值是弱引用,但键以强引用保存参数化用到的类,而参数化
结果又引用这些类,两者互相保活永不释放;顶层参数化还会在泛型基类模块留下全局
引用。只删除键里含有该插件类型的条目及其全局引用,其余模型的参数化结果与类身份
保持不变。
- ``typing`` 下标缓存:``Optional[X]`` 等注解按参数强引用缓存(每个函数 128 条),
只影响注解构造性能,整体清空。
:param module_prefix: 插件模块前缀,例如 ``app.plugins.demo``;为 ``app.plugins`` 时覆盖全部插件
"""
_purge_pydantic_generic_cache(module_prefix)
# typing 私有清理入口,与标准库测试清理类型缓存的方式一致;版本不提供时跳过
for cleanup in getattr(typing, "_cleanups", ()):
cleanup()
def _purge_pydantic_generic_cache(module_prefix: str) -> None:
"""删除 Pydantic 泛型缓存中键引用了插件类型的条目,以及它们留下的模块全局引用。"""
try:
from pydantic._internal._generics import _GENERIC_TYPES_CACHE
except ImportError:
# 私有实现随 Pydantic 版本变化,缺失时不影响插件卸载
return
for key in list(_GENERIC_TYPES_CACHE.keys()):
if not _references_module(key, module_prefix, 0):
continue
model = _GENERIC_TYPES_CACHE.pop(key, None)
if model is not None:
_drop_pickle_reference(model)
def _drop_pickle_reference(model: type) -> None:
"""移除 Pydantic 为支持 pickle 写入泛型基类所在模块的全局引用。
在模块顶层参数化泛型模型(如 ``RootModel[list[Payload]]``)时,Pydantic 会把结果以
``RootModel[list[Payload]]``(重名时追加下划线)登记到泛型基类所在模块的全局命名空间,
该引用与缓存无关、永不删除。只移除以模型名开头且确实指向该模型的登记项。
"""
module = sys.modules.get(model.__module__)
if module is None:
return
namespace = vars(module)
for name, value in list(namespace.items()):
if value is model and name.startswith(model.__name__):
namespace.pop(name, None)
def _references_module(value: Any, module_prefix: str, depth: int) -> bool:
"""判断缓存键(类型、元组或参数化注解)是否引用了指定模块前缀下定义的类型。"""
if depth > _MAX_TYPE_DEPTH:
return False
if isinstance(value, type):
module = value.__dict__.get("__module__")
return isinstance(module, str) and (
module == module_prefix or module.startswith(f"{module_prefix}.")
)
if isinstance(value, tuple):
return any(_references_module(item, module_prefix, depth + 1) for item in value)
origin = typing.get_origin(value)
if origin is None:
return False
return _references_module(origin, module_prefix, depth + 1) or any(
_references_module(arg, module_prefix, depth + 1)
for arg in typing.get_args(value)
)
class PluginLoader:
"""只负责从运行目录发现插件类,并维护对应模块缓存。"""
@@ -393,6 +471,9 @@ class PluginLoader:
for module_name in removed:
sys.modules.pop(module_name, None)
self._logger.debug(f"已清除插件模块缓存:{module_name}")
if removed:
# 只移出 sys.modules 不足以释放旧模块:第三方类型缓存仍强引用旧插件类
release_plugin_type_caches(prefix)
importlib.invalidate_caches()
self._logger.debug("已清除查找器的缓存")
if plugin_id:

View File

@@ -0,0 +1,125 @@
"""插件重载与路由移除后,旧插件代码不得被第三方进程级缓存留在内存中。"""
from __future__ import annotations
import gc
import sys
import types
import typing
import weakref
from pathlib import Path
from types import SimpleNamespace
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
from app.runtime.extensions.plugin.loader import PluginLoader
_SILENT_LOG = SimpleNamespace(
debug=lambda *_args: None,
info=lambda *_args: None,
warning=lambda *_args: None,
error=lambda *_args: None,
)
def _install_plugin_module(name: str, source: str) -> types.ModuleType:
"""以插件运行时命名空间登记一个内存模块,模拟已导入的插件源码。"""
module = types.ModuleType(name)
sys.modules[name] = module
exec(compile(source, f"<{name}>", "exec"), module.__dict__)
return module
def test_removed_plugin_route_releases_endpoint_from_fastapi_caches() -> None:
"""插件路由移除后,FastAPI 可调用分类缓存不再持有旧端点及其模块。"""
module = _install_plugin_module(
"app.plugins.routecachedemo",
"async def health():\n return {'ok': True}\n",
)
apis: list[dict] = [
{
"path": "/RouteCacheDemo/health",
"endpoint": module.health,
"methods": ["GET"],
"allow_anonymous": True,
}
]
app = FastAPI()
registry = FastAPIDynamicRouteRegistry(
app=app,
plugin_ids=lambda: ["RouteCacheDemo"],
plugin_apis=lambda _plugin_id: apis,
verify_token=lambda: None,
verify_apikey=lambda: None,
prefix="/api/v1/plugin",
protected_routes=set(),
log=_SILENT_LOG,
)
registry.update("RouteCacheDemo", "add")
# 真实请求让 FastAPI 把端点写入分类缓存
with TestClient(app) as client:
assert client.get("/api/v1/plugin/RouteCacheDemo/health").json() == {"ok": True}
endpoint_ref = weakref.ref(module.health)
apis.clear()
sys.modules.pop(module.__name__)
del module
registry.update("RouteCacheDemo", "remove")
gc.collect()
assert endpoint_ref() is None
def test_clear_modules_releases_plugin_classes_from_type_caches(tmp_path: Path) -> None:
"""清除插件模块缓存后,Pydantic 泛型缓存与 typing 缓存不再持有旧插件类。"""
module = _install_plugin_module(
"app.plugins.typecachedemo",
"from typing import Optional\n"
"from pydantic import BaseModel, RootModel\n"
"class Payload(BaseModel):\n"
" title: str\n"
"PayloadList = RootModel[list[Payload]]\n"
"MaybePayload = Optional[Payload]\n",
)
# 对照:其他模块的参数化结果不应被清掉,类身份保持不变
host_model = _install_plugin_module(
"app.schemas.typecachehost",
"from pydantic import BaseModel, RootModel\n"
"class HostItem(BaseModel):\n"
" name: str\n",
)
from pydantic import RootModel
host_list = RootModel[list[host_model.HostItem]]
payload_ref = weakref.ref(module.Payload)
loader = PluginLoader(
plugins_root=tmp_path,
import_preparer=lambda *_args, **_kwargs: None,
import_scanner=lambda *_args, **_kwargs: None,
log=_SILENT_LOG,
)
removed = loader.clear_modules("TypeCacheDemo")
del module
gc.collect()
assert removed == ["app.plugins.typecachedemo"]
assert payload_ref() is None
assert RootModel[list[host_model.HostItem]] is host_list
sys.modules.pop(host_model.__name__)
def test_clear_modules_without_matching_modules_keeps_type_caches(tmp_path: Path) -> None:
"""没有可清除的插件模块时不触碰全局类型缓存。"""
cached = typing.Optional[int]
loader = PluginLoader(
plugins_root=tmp_path,
import_preparer=lambda *_args, **_kwargs: None,
import_scanner=lambda *_args, **_kwargs: None,
log=_SILENT_LOG,
)
assert loader.clear_modules("NotLoadedPlugin") == []
assert typing.Optional[int] is cached