性能优化 (#2126)

* 性能优化

* 代码改进

* 优化浏览器代际切换逻辑

* 统一缓存与生命周期

* 添加aiomysql依赖

* 优化插件路径处理逻辑,简化条件判断;在虚拟环境包管理器中添加编码和错误处理参数以增强稳定性

* 🚨 auto fix by pre-commit hooks

* 优化Windows下的关闭逻辑

* 代码优化

* bugfix:修复配置重载问题

* bugfix:修复插件加载启动竞态问题

* 收敛事件入口和权限上下文

* 优化 Windows launcher 关闭重启兜底

---------

Co-authored-by: HibiKier <775757368@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Copaan
2026-04-26 15:50:15 +08:00
committed by GitHub
co-authored by HibiKier pre-commit-ci[bot]
parent 24c316cd2c
commit 5d92ccd3b0
56 changed files with 3092 additions and 806 deletions
+5
View File
@@ -63,6 +63,11 @@ class AvatarService:
identifier = str(identifier)
return self.cache_path / platform / f"{identifier}.png"
def clear_memory_cache(self) -> int:
size = len(self._memory_cache)
self._memory_cache.clear()
return size
async def get_avatar_path(
self, platform: str, identifier: str, force_refresh: bool = False
) -> Path | None:
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import asyncio
from collections import deque
import contextlib
import time
from zhenxun.models.user_gold_log import UserGoldLog
from zhenxun.services.log import logger
from zhenxun.utils.enum import GoldHandle
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
LOG_COMMAND = "BufferedWriters"
_USER_GOLD_LOG_BUFFER_MAX_RETAIN = 10_000
_USER_GOLD_LOG_FLUSH_TRIGGER_SIZE = 128
_USER_GOLD_LOG_FLUSH_BATCH_SIZE = 500
_USER_GOLD_LOG_FLUSH_INTERVAL_SECONDS = 60.0
_USER_GOLD_LOG_DROP_LOG_INTERVAL_SECONDS = 10.0
_user_gold_log_buffer: deque[UserGoldLog] = deque()
_user_gold_log_buffer_lock = asyncio.Lock()
_user_gold_log_flush_lock = asyncio.Lock()
_user_gold_log_flush_task: asyncio.Task[None] | None = None
_user_gold_log_dropped = 0
_user_gold_log_last_drop_log_at = 0.0
def _ensure_user_gold_log_flush_task() -> None:
global _user_gold_log_flush_task
if _user_gold_log_flush_task is not None and not _user_gold_log_flush_task.done():
return
_user_gold_log_flush_task = asyncio.create_task(_user_gold_log_flush_loop())
def _record_user_gold_log_drop() -> None:
global _user_gold_log_dropped, _user_gold_log_last_drop_log_at
_user_gold_log_dropped += 1
now = time.monotonic()
if now - _user_gold_log_last_drop_log_at < _USER_GOLD_LOG_DROP_LOG_INTERVAL_SECONDS:
return
_user_gold_log_last_drop_log_at = now
logger.warning(
"user_gold_log buffer full, dropped "
f"{_user_gold_log_dropped} records, backlog={len(_user_gold_log_buffer)}",
LOG_COMMAND,
)
async def _user_gold_log_flush_loop() -> None:
while True:
await asyncio.sleep(_USER_GOLD_LOG_FLUSH_INTERVAL_SECONDS)
try:
await flush_user_gold_log_buffer("定时")
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("定时批量写入金币流水失败", LOG_COMMAND, e=exc)
async def append_user_gold_log(
user_id: str,
gold: int,
handle: GoldHandle,
source: str | None = None,
) -> None:
_ensure_user_gold_log_flush_task()
record = UserGoldLog(user_id=user_id, gold=gold, handle=handle, source=source)
async with _user_gold_log_buffer_lock:
if len(_user_gold_log_buffer) >= _USER_GOLD_LOG_BUFFER_MAX_RETAIN:
_user_gold_log_buffer.popleft()
_record_user_gold_log_drop()
_user_gold_log_buffer.append(record)
should_flush = (
len(_user_gold_log_buffer) >= _USER_GOLD_LOG_FLUSH_TRIGGER_SIZE
and not _user_gold_log_flush_lock.locked()
)
if should_flush:
await flush_user_gold_log_buffer("缓冲区触发")
async def flush_user_gold_log_buffer(reason: str) -> int:
async with _user_gold_log_flush_lock:
written = 0
while True:
batch: list[UserGoldLog] = []
async with _user_gold_log_buffer_lock:
if not _user_gold_log_buffer:
break
while (
_user_gold_log_buffer
and len(batch) < _USER_GOLD_LOG_FLUSH_BATCH_SIZE
):
batch.append(_user_gold_log_buffer.popleft())
if not batch:
break
try:
await UserGoldLog.bulk_create(batch, _USER_GOLD_LOG_FLUSH_BATCH_SIZE)
except Exception as exc:
async with _user_gold_log_buffer_lock:
retain_count = max(
_USER_GOLD_LOG_BUFFER_MAX_RETAIN - len(_user_gold_log_buffer),
0,
)
for record in reversed(batch[-retain_count:]):
_user_gold_log_buffer.appendleft(record)
logger.error(f"{reason}批量写入金币流水失败", LOG_COMMAND, e=exc)
return written
written += len(batch)
if written:
logger.debug(f"{reason}批量写入金币流水 {written} 条", LOG_COMMAND)
return written
async def stop_user_gold_log_buffer() -> int:
global _user_gold_log_flush_task
task = _user_gold_log_flush_task
_user_gold_log_flush_task = None
if task is not None:
task.cancel()
with contextlib.suppress(BaseException):
await task
return await flush_user_gold_log_buffer("关闭")
@PriorityLifecycle.on_shutdown(priority=90)
async def _flush_user_gold_log_buffer_on_shutdown() -> None:
await stop_user_gold_log_buffer()
+10 -6
View File
@@ -74,6 +74,7 @@ from .config import (
)
__all__ = [
"BoundedTTLCache",
"Cache",
"CacheDict",
"CacheManager",
@@ -82,6 +83,7 @@ __all__ = [
]
from . import runtime_cache as _runtime_cache # noqa: F401
from .bounded_ttl import BoundedTTLCache
T = TypeVar("T")
U = TypeVar("U")
@@ -400,9 +402,10 @@ class CacheManager:
"""清除缓存
参数:
cache_type: 缓存类型,为None时清除所有缓存。
注意:受 aiocache 限制,无法按类型精确删除,
指定 cache_type 时仅清除整个 backend(行为与不指定相同)。
cache_type: 缓存类型。为 None 时清除整个 backend。
指定 cache_type 时不再退化为清除整个 backend,避免误删其他类型缓存。
需要刷新模型运行态缓存时,应调用对应
RuntimeCache.refresh/upsert/remove。
返回:
bool: 是否成功
@@ -413,11 +416,12 @@ class CacheManager:
try:
if cache_type:
logger.debug(
f"清除缓存类型 {cache_type}"
"(aiocache 不支持按前缀删除,清除整个 backend)",
logger.warning(
f"拒绝清除缓存类型 {cache_type}: "
"当前后端不支持可靠的按类型清理,已避免清除整个 backend",
LOG_COMMAND,
)
return False
await self.cache_backend.clear() # type: ignore
return True
except Exception as e:
+218
View File
@@ -0,0 +1,218 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass
import sys
import time
from typing import Generic, TypeVar
import weakref
K = TypeVar("K")
V = TypeVar("V")
def _default_sizeof(value: object) -> int:
if isinstance(value, bytes | bytearray | memoryview):
return len(value)
return 0
@dataclass(frozen=True)
class BoundedTTLCacheStats:
name: str
items: int
max_items: int
total_bytes: int
max_total_bytes: int | None
hits: int
misses: int
sets: int
evictions: int
def to_dict(self) -> dict[str, int | str | None]:
return {
"name": self.name,
"items": self.items,
"max_items": self.max_items,
"total_bytes": self.total_bytes,
"max_total_bytes": self.max_total_bytes,
"hits": self.hits,
"misses": self.misses,
"sets": self.sets,
"evictions": self.evictions,
}
class BoundedTTLCache(Generic[K, V]):
"""Small async TTL/LRU cache with optional total-byte limit."""
_instances: weakref.WeakSet["BoundedTTLCache"] = weakref.WeakSet()
def __init__(
self,
name: str,
ttl_seconds: float,
max_items: int,
max_total_bytes: int | None = None,
sizeof: Callable[[V], int] | None = None,
) -> None:
self.name = name.upper()
self._ttl_seconds = max(ttl_seconds, 0.0)
self._max_items = max(max_items, 1)
self._max_total_bytes = (
max_total_bytes
if isinstance(max_total_bytes, int) and max_total_bytes > 0
else None
)
self._sizeof = sizeof or _default_sizeof
self._cache: OrderedDict[K, tuple[float, V, int]] = OrderedDict()
self._total_bytes = 0
self._hits = 0
self._misses = 0
self._sets = 0
self._evictions = 0
self._lock = asyncio.Lock()
self.__class__._instances.add(self)
def _expire_at(self, now: float) -> float:
if self._ttl_seconds <= 0:
return sys.float_info.max
return now + self._ttl_seconds
def _value_size(self, value: V) -> int:
try:
return max(0, int(self._sizeof(value)))
except Exception:
return 0
def _remove_key_nolock(self, key: K) -> bool:
item = self._cache.pop(key, None)
if item is None:
return False
self._total_bytes -= item[2]
if self._total_bytes < 0:
self._total_bytes = 0
return True
def _pop_oldest_nolock(self) -> bool:
if not self._cache:
return False
_, (_, _, size) = self._cache.popitem(last=False)
self._total_bytes -= size
if self._total_bytes < 0:
self._total_bytes = 0
self._evictions += 1
return True
def _cleanup_nolock(self, now: float) -> None:
expired_keys = [
key for key, (expire_at, _, _) in self._cache.items() if expire_at <= now
]
for key in expired_keys:
if self._remove_key_nolock(key):
self._evictions += 1
while len(self._cache) > self._max_items:
self._pop_oldest_nolock()
if self._max_total_bytes is not None:
while self._total_bytes > self._max_total_bytes and self._cache:
self._pop_oldest_nolock()
async def get(self, key: K) -> V | None:
now = time.monotonic()
async with self._lock:
self._cleanup_nolock(now)
item = self._cache.get(key)
if item is None:
self._misses += 1
return None
expire_at, value, _ = item
if expire_at <= now:
self._remove_key_nolock(key)
self._misses += 1
return None
self._cache.move_to_end(key)
self._hits += 1
return value
async def set(self, key: K, value: V) -> bool:
value_size = self._value_size(value)
if self._max_total_bytes is not None and value_size > self._max_total_bytes:
return False
now = time.monotonic()
async with self._lock:
self._remove_key_nolock(key)
self._cache[key] = (self._expire_at(now), value, value_size)
self._total_bytes += value_size
self._sets += 1
self._cache.move_to_end(key)
self._cleanup_nolock(now)
return key in self._cache
async def delete(self, key: K) -> bool:
async with self._lock:
return self._remove_key_nolock(key)
async def clear(self) -> int:
async with self._lock:
size = len(self._cache)
self._cache.clear()
self._total_bytes = 0
return size
async def stats(self) -> BoundedTTLCacheStats:
now = time.monotonic()
async with self._lock:
self._cleanup_nolock(now)
return BoundedTTLCacheStats(
name=self.name,
items=len(self._cache),
max_items=self._max_items,
total_bytes=self._total_bytes,
max_total_bytes=self._max_total_bytes,
hits=self._hits,
misses=self._misses,
sets=self._sets,
evictions=self._evictions,
)
@classmethod
async def clear_all(cls) -> dict[str, int]:
result: dict[str, int] = {}
for cache in list(cls._instances):
size = await cache.clear()
if size:
result[cache.name] = result.get(cache.name, 0) + size
return result
@classmethod
async def stats_all(cls) -> dict[str, dict[str, int | str | None]]:
result: dict[str, dict[str, int | str | None]] = {}
for cache in list(cls._instances):
stats = await cache.stats()
if not stats.items:
continue
if cache.name not in result:
result[cache.name] = stats.to_dict()
continue
current = result[cache.name]
for key in (
"items",
"max_items",
"total_bytes",
"hits",
"misses",
"sets",
"evictions",
):
current[key] = int(current.get(key) or 0) + int(
getattr(stats, key) or 0
)
current_max_bytes = current.get("max_total_bytes")
if current_max_bytes is not None or stats.max_total_bytes is not None:
current["max_total_bytes"] = int(current_max_bytes or 0) + int(
stats.max_total_bytes or 0
)
return result
+84 -2
View File
@@ -1,9 +1,12 @@
from dataclasses import dataclass
import time
from typing import Any, Generic, TypeVar
import weakref
T = TypeVar("T")
DEFAULT_CACHE_MAX_ITEMS = 10000
@dataclass
class CacheData(Generic[T]):
@@ -16,16 +19,21 @@ class CacheData(Generic[T]):
class CacheDict(Generic[T]):
"""缓存字典类,提供类似普通字典的接口,数据只存储在内存中"""
def __init__(self, name: str, expire: int = 0):
_instances: weakref.WeakSet = weakref.WeakSet()
def __init__(self, name: str, expire: int = 0, max_items: int | None = None):
"""初始化缓存字典
参数:
name: 字典名称
expire: 过期时间(秒),默认为0表示永不过期
max_items: 最大缓存项数,None 使用统一默认值,0 表示不限制
"""
self.name = name.upper()
self.expire = expire
self.max_items = DEFAULT_CACHE_MAX_ITEMS if max_items is None else max_items
self._data: dict[str, CacheData[T]] = {}
self.__class__._instances.add(self)
def expire_time(self, key: str) -> float:
"""获取字典项的过期时间"""
@@ -62,6 +70,7 @@ class CacheDict(Generic[T]):
"""
expire_time = time.time() + self.expire if self.expire > 0 else 0
self._data[key] = CacheData(value=value, expire_time=expire_time)
self._enforce_limit()
def __delitem__(self, key: str) -> None:
"""删除字典项
@@ -122,6 +131,7 @@ class CacheDict(Generic[T]):
expire_time = time.time() + self.expire
self._data[key] = CacheData(value=value, expire_time=expire_time)
self._enforce_limit()
def pop(self, key: str, default: Any = None) -> T:
"""删除并返回字典项
@@ -146,6 +156,32 @@ class CacheDict(Generic[T]):
"""清空字典"""
self._data.clear()
def stats(self) -> dict[str, int]:
"""返回当前缓存条目统计。"""
self._clean_expired()
return {"items": len(self._data), "max_items": self.max_items}
@classmethod
def stats_all(cls) -> dict[str, dict[str, int]]:
"""返回所有 CacheDict 实例的条目统计。"""
result: dict[str, dict[str, int]] = {}
for cache in list(cls._instances):
stats = cache.stats()
if stats["items"]:
result[cache.name] = stats
return result
@classmethod
def clear_all(cls) -> dict[str, int]:
"""清空所有 CacheDict,返回各缓存清理的条目数。"""
result: dict[str, int] = {}
for cache in list(cls._instances):
size = len(cache._data)
if size:
cache.clear()
result[cache.name] = result.get(cache.name, 0) + size
return result
def keys(self) -> list[str]:
"""获取所有键
@@ -187,6 +223,12 @@ class CacheDict(Generic[T]):
for key in expired_keys:
del self._data[key]
def _enforce_limit(self) -> None:
if self.max_items <= 0:
return
while len(self._data) > self.max_items:
self._data.pop(next(iter(self._data)))
def __len__(self) -> int:
"""获取字典长度
@@ -211,17 +253,22 @@ class CacheDict(Generic[T]):
class CacheList(Generic[T]):
"""缓存列表类,提供类似普通列表的接口,数据只存储在内存中"""
def __init__(self, name: str, expire: int = 0):
_instances: weakref.WeakSet = weakref.WeakSet()
def __init__(self, name: str, expire: int = 0, max_items: int | None = None):
"""初始化缓存列表
参数:
name: 列表名称
expire: 过期时间(秒),默认为0表示永不过期
max_items: 最大缓存项数,None 使用统一默认值,0 表示不限制
"""
self.name = name.upper()
self.expire = expire
self.max_items = DEFAULT_CACHE_MAX_ITEMS if max_items is None else max_items
self._data: list[CacheData[T]] = []
self._expire_time = 0
self.__class__._instances.add(self)
# 如果设置了过期时间,计算整个列表的过期时间
if self.expire > 0:
@@ -303,6 +350,7 @@ class CacheList(Generic[T]):
self.clear()
self._data.append(CacheData(value=value))
self._enforce_limit()
# 更新过期时间
self._update_expire_time()
@@ -318,6 +366,7 @@ class CacheList(Generic[T]):
self.clear()
self._data.extend([CacheData(value=v) for v in values])
self._enforce_limit()
# 更新过期时间
self._update_expire_time()
@@ -334,6 +383,7 @@ class CacheList(Generic[T]):
self.clear()
self._data.insert(index, CacheData(value=value))
self._enforce_limit()
# 更新过期时间
self._update_expire_time()
@@ -389,6 +439,32 @@ class CacheList(Generic[T]):
# 重置过期时间
self._update_expire_time()
def stats(self) -> dict[str, int]:
"""返回当前缓存条目统计。"""
if self._is_expired():
self.clear()
return {"items": len(self._data), "max_items": self.max_items}
@classmethod
def stats_all(cls) -> dict[str, dict[str, int]]:
"""返回所有 CacheList 实例的条目统计。"""
result: dict[str, dict[str, int]] = {}
for cache in list(cls._instances):
stats = cache.stats()
if stats["items"]:
result[cache.name] = stats
return result
@classmethod
def clear_all(cls) -> dict[str, int]:
result: dict[str, int] = {}
for cache in list(cls._instances):
size = len(cache._data)
if size:
cache.clear()
result[cache.name] = result.get(cache.name, 0) + size
return result
def index(self, value: T, start: int = 0, end: int | None = None) -> int:
"""查找值的索引
@@ -438,6 +514,12 @@ class CacheList(Generic[T]):
"""更新过期时间"""
self._expire_time = time.time() + self.expire if self.expire > 0 else 0
def _enforce_limit(self) -> None:
if self.max_items <= 0:
return
if len(self._data) > self.max_items:
del self._data[: len(self._data) - self.max_items]
def __str__(self) -> str:
"""字符串表示
+169 -42
View File
@@ -10,7 +10,13 @@ import uuid
from zhenxun.services.cache.config import CacheMode
from zhenxun.services.log import logger
from zhenxun.utils.enum import LimitCheckType, LimitWatchType, PluginLimitType
from zhenxun.utils.enum import (
BlockType,
LimitCheckType,
LimitWatchType,
PluginLimitType,
PluginType,
)
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
if TYPE_CHECKING:
@@ -18,24 +24,6 @@ if TYPE_CHECKING:
LOG_COMMAND = "RuntimeCache"
PLUGININFO_MEM_REFRESH_INTERVAL = 1800 # 30分钟 - 插件信息很少变化
BAN_MEM_REFRESH_INTERVAL = 60
BAN_MEM_CLEAN_INTERVAL = 60
BAN_MEM_CLEANUP_DB = True
BAN_MEM_NEGATIVE_TTL = 5
BOT_MEM_REFRESH_INTERVAL = 300 # 5分钟
BOT_MEM_NEGATIVE_TTL = 60
GROUP_MEM_REFRESH_INTERVAL = 300 # 5分钟 - 群组信息很少变化
GROUP_MEM_NEGATIVE_TTL = 60
LEVEL_MEM_REFRESH_INTERVAL = 300 # 5分钟 - 用户等级很少变化
LEVEL_MEM_NEGATIVE_TTL = 60
TASK_MEM_REFRESH_INTERVAL = 900
TASK_MEM_NEGATIVE_TTL = 60
LIMIT_MEM_REFRESH_INTERVAL = 300 # 5分钟
LIMIT_MEM_NEGATIVE_TTL = 30
RUNTIME_CACHE_SYNC_ENABLED = True
RUNTIME_CACHE_SYNC_CHANNEL = "ZHENXUN_RUNTIME_CACHE_SYNC"
def _coerce_int(value, default: int) -> int:
try:
@@ -45,6 +33,27 @@ def _coerce_int(value, default: int) -> int:
return value_int if value_int >= 0 else default
# RuntimeCache 以模型 save/delete 主动失效为主,周期 refresh 只做兜底。
# 这些默认值避免低压力运行时频繁全量扫表。
PLUGININFO_MEM_REFRESH_INTERVAL = 3600 # 60分钟
BAN_MEM_REFRESH_INTERVAL = 300
BAN_MEM_CLEAN_INTERVAL = 60
BAN_MEM_CLEANUP_DB = True
BAN_MEM_NEGATIVE_TTL = 5
BOT_MEM_REFRESH_INTERVAL = 900 # 15分钟
BOT_MEM_NEGATIVE_TTL = 60
GROUP_MEM_REFRESH_INTERVAL = 900 # 15分钟
GROUP_MEM_NEGATIVE_TTL = 60
LEVEL_MEM_REFRESH_INTERVAL = 900 # 15分钟
LEVEL_MEM_NEGATIVE_TTL = 60
TASK_MEM_REFRESH_INTERVAL = 1800
TASK_MEM_NEGATIVE_TTL = 60
LIMIT_MEM_REFRESH_INTERVAL = 900 # 15分钟
LIMIT_MEM_NEGATIVE_TTL = 30
RUNTIME_CACHE_SYNC_ENABLED = True
RUNTIME_CACHE_SYNC_CHANNEL = "ZHENXUN_RUNTIME_CACHE_SYNC"
INSTANCE_ID = uuid.uuid4().hex
_CACHE_READY_EVENT = asyncio.Event()
@@ -89,6 +98,89 @@ def _parse_block_modules(value: str) -> frozenset[str]:
return frozenset(items)
@dataclass(frozen=True)
class PluginInfoSnapshot:
id: int
module: str
module_path: str
name: str
status: bool
block_type: BlockType | None
load_status: bool
author: str | None
version: str | None
level: int
default_status: bool
limit_superuser: bool
menu_type: str
plugin_type: PluginType | None
cost_gold: int
admin_level: int | None
ignore_prompt: bool
is_delete: bool
parent: str | None
is_show: bool
ignore_statistics: bool
impression: float
@classmethod
def from_model(cls, model) -> "PluginInfoSnapshot":
return cls(
id=int(getattr(model, "id", 0) or 0),
module=str(getattr(model, "module", "") or ""),
module_path=str(getattr(model, "module_path", "") or ""),
name=str(getattr(model, "name", "") or ""),
status=bool(getattr(model, "status", True)),
block_type=getattr(model, "block_type", None),
load_status=bool(getattr(model, "load_status", True)),
author=getattr(model, "author", None),
version=getattr(model, "version", None),
level=int(getattr(model, "level", 0) or 0),
default_status=bool(getattr(model, "default_status", True)),
limit_superuser=bool(getattr(model, "limit_superuser", False)),
menu_type=str(getattr(model, "menu_type", "") or ""),
plugin_type=getattr(model, "plugin_type", None),
cost_gold=int(getattr(model, "cost_gold", 0) or 0),
admin_level=getattr(model, "admin_level", None),
ignore_prompt=bool(getattr(model, "ignore_prompt", False)),
is_delete=bool(getattr(model, "is_delete", False)),
parent=getattr(model, "parent", None),
is_show=bool(getattr(model, "is_show", True)),
ignore_statistics=bool(getattr(model, "ignore_statistics", False)),
impression=float(getattr(model, "impression", 0) or 0),
)
def to_model(self):
from zhenxun.models.plugin_info import PluginInfo
plugin = PluginInfo(
id=self.id,
module=self.module,
module_path=self.module_path,
name=self.name,
status=self.status,
block_type=self.block_type,
load_status=self.load_status,
author=self.author,
version=self.version,
level=self.level,
default_status=self.default_status,
limit_superuser=self.limit_superuser,
menu_type=self.menu_type,
plugin_type=self.plugin_type,
cost_gold=self.cost_gold,
admin_level=self.admin_level,
ignore_prompt=self.ignore_prompt,
is_delete=self.is_delete,
parent=self.parent,
is_show=self.is_show,
ignore_statistics=self.ignore_statistics,
impression=self.impression,
)
plugin._saved_in_db = True
return plugin
@dataclass(frozen=True)
class BanEntry:
user_id: str | None
@@ -465,6 +557,20 @@ class RuntimeCacheSync:
@classmethod
async def stop(cls) -> None:
cls._ready = False
if cls._publish_tasks:
tasks = list(cls._publish_tasks)
try:
await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
for task in tasks:
if not task.done():
task.cancel()
finally:
cls._publish_tasks.difference_update(tasks)
if cls._task and not cls._task.done():
cls._task.cancel()
cls._task = None
@@ -480,7 +586,6 @@ class RuntimeCacheSync:
except Exception:
pass
cls._redis = None
cls._ready = False
@classmethod
def publish_event(cls, cache_type: str, action: str, data: dict[str, Any]) -> None:
@@ -559,25 +664,43 @@ class RuntimeCacheSync:
class PluginInfoMemoryCache:
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
_by_module: ClassVar[dict[str, "PluginInfo"]] = {}
_by_module_path: ClassVar[dict[str, "PluginInfo"]] = {}
_by_module: ClassVar[dict[str, PluginInfoSnapshot]] = {}
_by_module_path: ClassVar[dict[str, PluginInfoSnapshot]] = {}
_loaded: ClassVar[bool] = False
_refresh_task: ClassVar[asyncio.Task | None] = None
_last_refresh: ClassVar[float] = 0.0
@classmethod
def _to_model(cls, snapshot: PluginInfoSnapshot | None) -> "PluginInfo | None":
return snapshot.to_model() if snapshot else None
@classmethod
def _store_snapshot(cls, snapshot: PluginInfoSnapshot) -> None:
if snapshot.module:
old = cls._by_module.get(snapshot.module)
if old and old.module_path != snapshot.module_path:
cls._by_module_path.pop(old.module_path, None)
cls._by_module[snapshot.module] = snapshot
if snapshot.module_path:
old = cls._by_module_path.get(snapshot.module_path)
if old and old.module != snapshot.module:
cls._by_module.pop(old.module, None)
cls._by_module_path[snapshot.module_path] = snapshot
@classmethod
async def refresh(cls) -> None:
from zhenxun.models.plugin_info import PluginInfo
async with cls._lock:
plugins = await PluginInfo.all()
by_module: dict[str, "PluginInfo"] = {}
by_module_path: dict[str, "PluginInfo"] = {}
by_module: dict[str, PluginInfoSnapshot] = {}
by_module_path: dict[str, PluginInfoSnapshot] = {}
for plugin in plugins:
if plugin.module:
by_module[plugin.module] = plugin
if plugin.module_path:
by_module_path[plugin.module_path] = plugin
snapshot = PluginInfoSnapshot.from_model(plugin)
if snapshot.module:
by_module[snapshot.module] = snapshot
if snapshot.module_path:
by_module_path[snapshot.module_path] = snapshot
cls._by_module = by_module
cls._by_module_path = by_module_path
cls._loaded = True
@@ -596,42 +719,42 @@ class PluginInfoMemoryCache:
async def get_by_module(cls, module: str) -> "PluginInfo | None":
if not cls._loaded:
await cls.ensure_loaded()
return cls._by_module.get(module)
return cls._to_model(cls._by_module.get(module))
@classmethod
async def get_all(cls) -> dict[str, "PluginInfo"]:
if not cls._loaded:
await cls.ensure_loaded()
return dict(cls._by_module)
return {
module: snapshot.to_model() for module, snapshot in cls._by_module.items()
}
@classmethod
def get_by_module_path(cls, module_path: str) -> "PluginInfo | None":
return cls._by_module_path.get(module_path)
return cls._to_model(cls._by_module_path.get(module_path))
@classmethod
def set_plugin(cls, plugin) -> None:
if not plugin:
return
if plugin.module:
cls._by_module[plugin.module] = plugin
if getattr(plugin, "module_path", None):
cls._by_module_path[plugin.module_path] = plugin
snapshot = PluginInfoSnapshot.from_model(plugin)
cls._store_snapshot(snapshot)
cls._loaded = True
cls._last_refresh = time.time()
@classmethod
def remove_by_module(cls, module: str) -> None:
cls._by_module.pop(module, None)
snapshot = cls._by_module.pop(module, None)
if snapshot and snapshot.module_path:
cls._by_module_path.pop(snapshot.module_path, None)
@classmethod
async def upsert_from_model(cls, plugin) -> None:
if not plugin:
return
async with cls._lock:
if getattr(plugin, "module", None):
cls._by_module[plugin.module] = plugin
if getattr(plugin, "module_path", None):
cls._by_module_path[plugin.module_path] = plugin
snapshot = PluginInfoSnapshot.from_model(plugin)
cls._store_snapshot(snapshot)
cls._loaded = True
cls._last_refresh = time.time()
@@ -643,9 +766,13 @@ class PluginInfoMemoryCache:
return
async with cls._lock:
if module:
cls._by_module.pop(module, None)
snapshot = cls._by_module.pop(module, None)
if snapshot and snapshot.module_path:
cls._by_module_path.pop(snapshot.module_path, None)
if module_path:
cls._by_module_path.pop(module_path, None)
snapshot = cls._by_module_path.pop(module_path, None)
if snapshot and snapshot.module:
cls._by_module.pop(snapshot.module, None)
@classmethod
async def _refresh_loop(cls, interval: int) -> None:
+19 -58
View File
@@ -10,7 +10,11 @@ T = TypeVar("T", bound=Model)
class DataAccess(Generic[T]):
"""数据访问层,根据配置决定是否使用缓存
"""数据访问兼容层,根据配置保留单点缓存读取和清理能力
新的高频运行态路径应优先使用 RuntimeCache 或 BoundedTTLCache。
这里不再把 filter/all/create/update_or_create 结果写入通用缓存,
create/update_or_create 只负责清理旧缓存,避免旧值残留。
使用示例:
```python
@@ -395,34 +399,25 @@ class DataAccess(Generic[T]):
return COMPOSITE_KEY_SEPARATOR.join(key_parts)
async def _cache_items(self, data_list: list[T]) -> None:
"""将数据列表存入缓存
参数:
data_list: 数据列表
"""
if (
not data_list
or not self.cache_type
or cache_config.cache_mode == CacheMode.NONE
):
async def _invalidate_item_cache(self, item: T, action: str) -> None:
if not self.cache_type or cache_config.cache_mode == CacheMode.NONE:
return
try:
# 遍历数据列表,将每条数据存入缓存
cached_count = 0
for item in data_list:
cache_key = self._build_cache_key_for_item(item)
if cache_key is not None:
await self.cache.set(cache_key, item)
cached_count += 1
self._cache_stats[self.cache_type]["sets"] += 1
cache_key = self._build_cache_key_for_item(item)
if cache_key is None:
return
await self.cache.delete(cache_key)
self._cache_stats[self.cache_type]["deletes"] += 1
logger.debug(
f"{self.model_cls.__name__} 批量缓存: {cached_count}/{len(data_list)}项"
f"{self.model_cls.__name__} {action}: 已失效兼容缓存: {cache_key}"
)
except Exception as e:
logger.error(f"{self.model_cls.__name__} 批量缓存失败", e=e)
logger.error(
f"{self.model_cls.__name__} {action}: 更新兼容缓存失败",
e=e,
)
async def filter(self, *args, **kwargs) -> list[T]:
"""筛选数据
@@ -441,9 +436,6 @@ class DataAccess(Generic[T]):
f"{self.model_cls.__name__} filter: 查询结果数量: {len(data_list)}"
)
# 将数据存入缓存
await self._cache_items(data_list)
return data_list
async def all(self) -> list[T]:
@@ -457,9 +449,6 @@ class DataAccess(Generic[T]):
data_list = await self.model_cls.all()
logger.debug(f"{self.model_cls.__name__} all: 查询结果数量: {len(data_list)}")
# 将数据存入缓存
await self._cache_items(data_list)
return data_list
async def count(self, *args, **kwargs) -> int:
@@ -501,24 +490,7 @@ class DataAccess(Generic[T]):
logger.debug(f"{self.model_cls.__name__} create: 创建数据, 参数: {kwargs}")
data = await self.model_cls.create(**kwargs)
# 如果有缓存类型,将数据存入缓存
if self.cache_type and cache_config.cache_mode != CacheMode.NONE:
try:
# 生成缓存键
cache_key = self._build_cache_key_for_item(data)
if cache_key is not None:
# 存入缓存
await self.cache.set(cache_key, data)
self._cache_stats[self.cache_type]["sets"] += 1
logger.debug(
f"{self.model_cls.__name__} create: "
f"新创建的数据已存入缓存: {cache_key}"
)
except Exception as e:
logger.error(
f"{self.model_cls.__name__} create: 存入缓存失败,参数: {kwargs}",
e=e,
)
await self._invalidate_item_cache(data, "create")
return data
@@ -539,18 +511,7 @@ class DataAccess(Generic[T]):
defaults=defaults, **kwargs
)
# 如果有缓存类型,将数据存入缓存
if self.cache_type and cache_config.cache_mode != CacheMode.NONE:
try:
# 生成缓存键
cache_key = self._build_cache_key_for_item(data)
if cache_key is not None:
# 存入缓存
await self.cache.set(cache_key, data)
self._cache_stats[self.cache_type]["sets"] += 1
logger.debug(f"更新或创建的数据已存入缓存: {cache_key}")
except Exception as e:
logger.error(f"存入缓存失败,参数: {kwargs}", e=e)
await self._invalidate_item_cache(data, "update_or_create")
return data, created
+1 -1
View File
@@ -150,7 +150,7 @@ class Model(TortoiseModel):
obj = await cls.filter(**kwargs).using_db(connection).get()
result = (obj, False)
if cache_type := cls.get_cache_type():
if result[1] and (cache_type := cls.get_cache_type()):
await CacheRoot.invalidate_cache(cache_type, cls.get_cache_key(result[0]))
return result
+6 -3
View File
@@ -5,10 +5,9 @@ import ujson as json
from zhenxun.configs.config import Config
from zhenxun.models.group_plugin_setting import GroupPluginSetting
from zhenxun.services.cache import Cache
from zhenxun.services.cache import BoundedTTLCache
from zhenxun.services.data_access import DataAccess
from zhenxun.services.log import logger
from zhenxun.utils.enum import CacheType
from zhenxun.utils.pydantic_compat import model_dump, model_validate, parse_as
T = TypeVar("T", bound=BaseModel)
@@ -22,7 +21,11 @@ class GroupSettingsService:
def __init__(self):
self.dao = DataAccess(GroupPluginSetting)
self._cache = Cache[dict[str, Any]](CacheType.GROUP_PLUGIN_SETTINGS_VIEW)
self._cache = BoundedTTLCache[str, dict[str, Any]](
"GROUP_PLUGIN_SETTINGS_VIEW",
ttl_seconds=600,
max_items=10000,
)
@staticmethod
def _build_cache_key(group_id: str, plugin_name: str) -> str:
+307
View File
@@ -0,0 +1,307 @@
from __future__ import annotations
import asyncio
import contextlib
import gc
import inspect
import sys
import time
from typing import Any
from aiocache import SimpleMemoryCache
from zhenxun.services.cache import CacheRoot
from zhenxun.services.cache.bounded_ttl import BoundedTTLCache
from zhenxun.services.cache.cache_containers import CacheDict, CacheList
from zhenxun.services.log import logger
from zhenxun.services.message_load import idle_seconds, is_overloaded
LOG_COMMAND = "MemoryGovernor"
IDLE_CHECK_INTERVAL_SECONDS = 60
IDLE_RECLAIM_SECONDS = 600
RECLAIM_COOLDOWN_SECONDS = 3 * 60 * 60
RECLAIM_TIMEOUT_SECONDS = 10
_task: asyncio.Task | None = None
_reclaim_lock = asyncio.Lock()
_last_reclaim_at = 0.0
def _cooldown_left(now: float | None = None) -> float:
now = time.monotonic() if now is None else now
return max(0.0, _last_reclaim_at + RECLAIM_COOLDOWN_SECONDS - now)
async def start_memory_governor() -> None:
global _task
if _task is not None and not _task.done():
return
if IDLE_CHECK_INTERVAL_SECONDS <= 0 or IDLE_RECLAIM_SECONDS <= 0:
logger.info("idle memory governor disabled", LOG_COMMAND)
return
_task = asyncio.create_task(_idle_reclaim_loop())
async def stop_memory_governor() -> None:
global _task
task = _task
_task = None
if task is not None:
task.cancel()
with contextlib.suppress(BaseException):
await task
async def _idle_reclaim_loop() -> None:
while True:
await asyncio.sleep(IDLE_CHECK_INTERVAL_SECONDS)
if not await _should_reclaim():
continue
if _reclaim_lock.locked():
continue
async with _reclaim_lock:
if not await _should_reclaim():
continue
try:
await asyncio.wait_for(
_run_reclaim(),
timeout=max(RECLAIM_TIMEOUT_SECONDS, 1),
)
except asyncio.TimeoutError:
logger.warning("idle memory reclaim timed out", LOG_COMMAND)
except Exception as exc:
logger.warning("idle memory reclaim failed", LOG_COMMAND, e=exc)
async def _should_reclaim() -> bool:
if _cooldown_left() > 0:
return False
if idle_seconds() < IDLE_RECLAIM_SECONDS:
return False
if is_overloaded():
return False
if await _has_active_auth_work():
return False
return not await _has_active_render_work()
async def _has_active_auth_work() -> bool:
module = sys.modules.get("zhenxun.builtin_plugins.hooks.auth_checker")
if module is None:
return False
hooks_active = int(getattr(module, "HOOKS_ACTIVE_COUNT", 0) or 0)
db_active = int(getattr(module, "DB_ACTIVE_COUNT", 0) or 0)
return hooks_active > 0 or db_active > 0
async def _has_active_render_work() -> bool:
module = sys.modules.get("zhenxun.services.renderer.engine")
if module is None:
return False
manager = getattr(module, "engine_manager", None)
engine = getattr(manager, "_instance", None)
if engine is None:
return False
try:
snapshot = await asyncio.wait_for(engine.get_runtime_snapshot(), timeout=1.0)
except Exception:
return True
if snapshot.get("active_renders", 0):
return True
if snapshot.get("htmlrender_active_tasks", 0):
return True
active_generation = snapshot.get("active_generation")
if isinstance(active_generation, dict) and active_generation.get(
"active_leases", 0
):
return True
retiring = snapshot.get("retiring_generations", [])
if isinstance(retiring, list):
return any(
isinstance(item, dict) and item.get("active_leases", 0) for item in retiring
)
return False
async def _run_reclaim() -> None:
global _last_reclaim_at
start = time.monotonic()
before_rss = _get_total_rss()
cleared: dict[str, Any] = {}
cache_stats_before = {
"cache_dict": CacheDict.stats_all(),
"cache_list": CacheList.stats_all(),
"bounded_ttl": await BoundedTTLCache.stats_all(),
}
cleared["statistics"] = await _flush_statistics_buffer()
cleared["user_gold_logs"] = await _flush_user_gold_log_buffer()
cleared["bounded_ttl_clear"] = await BoundedTTLCache.clear_all()
cleared["cache_dict_clear"] = CacheDict.clear_all()
cleared["cache_list_clear"] = CacheList.clear_all()
cleared["runtime_negative"] = _clear_runtime_negative_caches()
cleared["auth_local"] = _clear_auth_local_caches()
cleared["avatar_l1"] = _clear_avatar_memory_cache()
cleared["renderer_runtime"] = await _clear_renderer_runtime_caches()
cleared["message_manager"] = _clear_message_manager_cache()
cleared["aiocache_memory"] = await _clear_simple_memory_backend()
collected = gc.collect(2)
malloc_trimmed = _malloc_trim()
after_rss = _get_total_rss()
_last_reclaim_at = time.monotonic()
logger.info(
"idle memory reclaim completed: "
f"cost={time.monotonic() - start:.3f}s "
f"rss_before={_format_bytes(before_rss)} "
f"rss_after={_format_bytes(after_rss)} "
f"gc={collected} malloc_trim={malloc_trimmed} "
f"cleared={cleared} cache_stats_before={cache_stats_before}",
LOG_COMMAND,
)
async def _flush_statistics_buffer() -> int:
module = sys.modules.get("zhenxun.builtin_plugins.statistics.statistics_hook")
if module is None:
return 0
flush = getattr(module, "_flush_statistics_buffer", None)
if flush is None:
return 0
result = await flush("内存回收")
return int(result or 0)
async def _flush_user_gold_log_buffer() -> int:
module = sys.modules.get("zhenxun.services.buffered_writers")
if module is None:
return 0
flush = getattr(module, "flush_user_gold_log_buffer", None)
if flush is None:
return 0
result = await flush("内存回收")
return int(result or 0)
async def _clear_simple_memory_backend() -> bool:
backend = getattr(CacheRoot, "_cache_backend", None)
if not isinstance(backend, SimpleMemoryCache):
return False
await backend.clear()
return True
def _clear_runtime_negative_caches() -> dict[str, int]:
module = sys.modules.get("zhenxun.services.cache.runtime_cache")
if module is None:
return {}
result: dict[str, int] = {}
for name in (
"BotMemoryCache",
"GroupMemoryCache",
"LevelUserMemoryCache",
"TaskInfoMemoryCache",
"PluginLimitMemoryCache",
"BanMemoryCache",
):
cache_cls = getattr(module, name, None)
negative = getattr(cache_cls, "_negative", None)
if isinstance(negative, dict) and negative:
result[name] = len(negative)
negative.clear()
return result
def _clear_auth_local_caches() -> dict[str, int]:
module = sys.modules.get("zhenxun.builtin_plugins.hooks.auth_checker")
if module is None:
return {}
result: dict[str, int] = {}
for name in (
"_MATCHER_COMMAND_TYPE_CACHE",
"_MATCHER_COMMAND_LITERAL_CACHE",
"_MATCHER_ALCONNA_SHORTCUT_CACHE",
):
cache = getattr(module, name, None)
if isinstance(cache, dict) and cache:
result[name] = len(cache)
cache.clear()
return result
def _clear_avatar_memory_cache() -> int:
module = sys.modules.get("zhenxun.services.avatar_service")
if module is None:
return 0
service = getattr(module, "avatar_service", None)
clear = getattr(service, "clear_memory_cache", None)
if not callable(clear):
return 0
result = clear()
return result if isinstance(result, int) and result > 0 else 0
async def _clear_renderer_runtime_caches() -> dict[str, int]:
module = sys.modules.get("zhenxun.services.renderer.service")
if module is None:
return {}
service = getattr(module, "renderer_service", None)
clear = getattr(service, "clear_runtime_caches", None)
if not callable(clear):
return {}
result = clear()
if inspect.isawaitable(result):
result = await result
if not isinstance(result, dict):
return {}
return {
str(key): int(value)
for key, value in result.items()
if isinstance(value, int) and value > 0
}
def _clear_message_manager_cache() -> int:
module = sys.modules.get("zhenxun.utils.manager.message_manager")
if module is None:
return 0
manager_cls = getattr(module, "MessageManager", None)
clear = getattr(manager_cls, "clear_all", None)
if not callable(clear):
return 0
result = clear()
return result if isinstance(result, int) and result > 0 else 0
def _get_total_rss() -> int | None:
try:
import psutil
process = psutil.Process()
total = process.memory_info().rss
for child in process.children(recursive=True):
with contextlib.suppress(Exception):
total += child.memory_info().rss
return int(total)
except Exception:
return None
def _malloc_trim() -> bool:
if sys.platform.startswith(("win", "darwin")):
return False
try:
import ctypes
libc = ctypes.CDLL("libc.so.6")
return bool(libc.malloc_trim(0))
except Exception:
return False
def _format_bytes(value: int | None) -> str:
if value is None:
return "unknown"
return f"{value / 1024 / 1024:.2f}MiB"
+11
View File
@@ -3,6 +3,17 @@ from __future__ import annotations
import time
_OVERLOAD_UNTIL = 0.0
_LAST_ACTIVITY = time.monotonic()
def mark_activity() -> None:
"""Record lightweight runtime activity for idle-only maintenance jobs."""
global _LAST_ACTIVITY
_LAST_ACTIVITY = time.monotonic()
def idle_seconds() -> float:
return max(0.0, time.monotonic() - _LAST_ACTIVITY)
def signal_overload(duration: float = 5.0) -> None:
+62 -23
View File
@@ -20,6 +20,12 @@ from zhenxun.services.log import logger
from .types import BaseScreenshotEngine
_PLAYWRIGHT_DISCONNECT_ERROR = "Connection closed while reading from the driver"
_PLAYWRIGHT_TARGET_CLOSED_ERROR_MARKERS = (
"TargetClosedError",
"Target page, context or browser has been closed",
"browser has been closed",
"BrowserContext.new_page",
)
_UNRETRIEVED_FUTURE_MESSAGE = "Future exception was never retrieved"
_LOOP_EXCEPTION_FILTER_STATE_ATTR = "_zhenxun_playwright_exception_filter_state"
_DISCONNECT_SUPPRESSION_WINDOW_SECONDS = 10.0
@@ -135,6 +141,14 @@ def _is_ignorable_playwright_disconnect(ctx: dict[str, Any]) -> bool:
)
def _is_playwright_target_closed_error(exc: Exception) -> bool:
exc_name = type(exc).__name__
if exc_name == "TargetClosedError":
return True
message = str(exc)
return any(marker in message for marker in _PLAYWRIGHT_TARGET_CLOSED_ERROR_MARKERS)
def _get_loop_exception_filter_state(
loop: asyncio.AbstractEventLoop,
) -> dict[str, Any] | None:
@@ -1103,29 +1117,54 @@ class PlaywrightEngine(BaseScreenshotEngine):
template_path: str,
render_options: dict[str, Any],
) -> bytes:
generation, context = await self._acquire_context()
page = None
broken = False
try:
page = await context.new_page()
page_options = self._build_page_options(render_options, pooled=True)
viewport = page_options.get("viewport")
if isinstance(viewport, dict):
width = viewport.get("width")
height = viewport.get("height")
if isinstance(width, int) and isinstance(height, int):
await page.set_viewport_size({"width": width, "height": height})
return await self._render_with_page(
page, html, template_path, render_options
)
except Exception:
broken = True
raise
finally:
if page is not None:
with contextlib.suppress(Exception):
await page.close()
await self._release_context(generation, context, broken=broken)
last_error: Exception | None = None
for attempt in range(2):
generation, context = await self._acquire_context()
page = None
broken = False
try:
page = await context.new_page()
page_options = self._build_page_options(render_options, pooled=True)
viewport = page_options.get("viewport")
if isinstance(viewport, dict):
width = viewport.get("width")
height = viewport.get("height")
if isinstance(width, int) and isinstance(height, int):
await page.set_viewport_size({"width": width, "height": height})
return await self._render_with_page(
page, html, template_path, render_options
)
except Exception as e:
broken = True
last_error = e
if attempt == 0:
if _is_playwright_target_closed_error(e):
logger.warning(
"截图引擎浏览器上下文代已失效,切换新代后重试一次。",
"PlaywrightEngine",
e=e,
)
try:
await self._swap_generation("target_closed")
except Exception:
raise e
else:
logger.warning(
"截图引擎上下文已失效,丢弃后重试一次。",
"PlaywrightEngine",
e=e,
)
continue
raise
finally:
if page is not None:
with contextlib.suppress(Exception):
await page.close()
await self._release_context(generation, context, broken=broken)
if last_error is not None:
raise last_error
raise RuntimeError("截图引擎上下文池渲染失败。")
async def _render_html(
self,
+9 -55
View File
@@ -1,11 +1,9 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
import hashlib
import time
from typing import Any
from zhenxun.services.cache.bounded_ttl import BoundedTTLCache
from zhenxun.utils.pydantic_compat import dump_json_safely
@@ -23,9 +21,12 @@ class RenderResultMemoryCache:
if isinstance(max_total_bytes, int) and max_total_bytes > 0
else None
)
self._cache: OrderedDict[str, tuple[float, bytes]] = OrderedDict()
self._total_bytes = 0
self._lock = asyncio.Lock()
self._cache = BoundedTTLCache[str, bytes](
"RENDER_RESULT",
ttl_seconds=self._ttl_seconds,
max_items=self._max_items,
max_total_bytes=self._max_total_bytes,
)
@staticmethod
def build_key(payload: Any) -> str:
@@ -37,55 +38,8 @@ class RenderResultMemoryCache:
)
return hashlib.sha256(payload_text.encode("utf-8")).hexdigest()
def _pop_oldest(self) -> None:
if not self._cache:
return
_, (_, value) = self._cache.popitem(last=False)
self._total_bytes -= len(value)
if self._total_bytes < 0:
self._total_bytes = 0
def _cleanup(self, now: float) -> None:
while self._cache:
expire_at, _ = next(iter(self._cache.values()))
if expire_at > now:
break
self._pop_oldest()
while len(self._cache) > self._max_items:
self._pop_oldest()
if self._max_total_bytes is not None:
while self._total_bytes > self._max_total_bytes and self._cache:
self._pop_oldest()
async def get(self, key: str) -> bytes | None:
now = time.monotonic()
async with self._lock:
self._cleanup(now)
item = self._cache.get(key)
if item is None:
return None
expire_at, value = item
if expire_at <= now:
removed = self._cache.pop(key, None)
if removed:
self._total_bytes -= len(removed[1])
if self._total_bytes < 0:
self._total_bytes = 0
return None
self._cache.move_to_end(key)
return value
return await self._cache.get(key)
async def set(self, key: str, value: bytes) -> None:
value_size = len(value)
if self._max_total_bytes is not None and value_size > self._max_total_bytes:
return
now = time.monotonic()
async with self._lock:
if old := self._cache.pop(key, None):
self._total_bytes -= len(old[1])
if self._total_bytes < 0:
self._total_bytes = 0
self._cache[key] = (now + self._ttl_seconds, value)
self._total_bytes += value_size
self._cache.move_to_end(key)
self._cleanup(now)
await self._cache.set(key, value)
+12
View File
@@ -475,6 +475,18 @@ class RendererService:
raise RuntimeError("ThemeManager尚未初始化。")
return self._theme_manager.list_available_themes()
def clear_runtime_caches(self) -> dict[str, int]:
cleared: dict[str, int] = {}
if self._theme_manager:
cleared.update(self._theme_manager.clear_runtime_caches())
if self._template_engine and self._template_engine.env.cache:
jinja_cache = self._template_engine.env.cache
cache_size = len(jinja_cache)
jinja_cache.clear()
if cache_size:
cleared["jinja_env"] = cache_size
return cleared
async def switch_theme(self, theme_name: str) -> str:
"""
切换UI主题,加载新主题并持久化配置。
+18 -1
View File
@@ -51,8 +51,10 @@ class ManifestRegistry:
self._manifest_cache: dict[str, TemplateManifest] = {}
self._lock = asyncio.Lock()
def clear_cache(self):
def clear_cache(self) -> int:
size = len(self._manifest_cache)
self._manifest_cache.clear()
return size
async def get_manifest(
self, component_path: str, skin: str | None = None
@@ -362,6 +364,21 @@ class ThemeManager:
tuple[type, str, str | None], ComponentDependency
] = OrderedDict()
def clear_runtime_caches(self) -> dict[str, int]:
cleared = {
"asset_resolution": len(self._asset_resolution_cache),
"global_template": len(self._global_template_cache),
"component_dependency": len(self._component_dependency_cache),
}
self._asset_resolution_cache.clear()
self._global_template_cache.clear()
self._component_dependency_cache.clear()
if self.manifest_registry:
manifest_count = self.manifest_registry.clear_cache()
if manifest_count:
cleared["manifest"] = manifest_count
return {key: value for key, value in cleared.items() if value}
@staticmethod
def _get_lru_entry(cache: OrderedDict, key: Any) -> Any:
value = cache.get(key)
+63 -3
View File
@@ -2,10 +2,17 @@ import asyncio
from concurrent.futures import ThreadPoolExecutor
import contextlib
import os
import signal
import anyio.to_thread
from nonebot.drivers import Driver
from zhenxun.services.log import logger
from zhenxun.services.memory_governor import (
start_memory_governor,
stop_memory_governor,
)
from zhenxun.services.send_queue import start_send_queue, stop_send_queue
from zhenxun.services.uninfo_patch import apply_uninfo_onebot11_patch
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
DEFAULT_EXECUTOR_MIN_WORKERS = 16
@@ -14,6 +21,7 @@ DEFAULT_ANYIO_MIN_TOKENS = 32
DEFAULT_ANYIO_MAX_TOKENS = 128
_thread_executor: ThreadPoolExecutor | None = None
_launcher_watchdog_task: asyncio.Task[None] | None = None
_runtime_hooks_registered = False
_alconna_patch_applied = False
@@ -57,14 +65,60 @@ def _apply_alconna_conflict_patch() -> None:
_alconna_patch_applied = True
def register_runtime_bootstrap(driver: Driver) -> None:
async def _launcher_watchdog_loop(launcher_pid: int) -> None:
try:
import psutil
except Exception:
return
current_pid = os.getpid()
while True:
await asyncio.sleep(2)
if psutil.pid_exists(launcher_pid):
continue
logger.warning(
f"检测到 launcher 进程 {launcher_pid} 已退出,worker 将主动结束...",
"RuntimeBootstrap",
)
with contextlib.suppress(Exception):
os.kill(current_pid, signal.SIGTERM)
return
def _start_launcher_watchdog() -> None:
global _launcher_watchdog_task
if _launcher_watchdog_task is not None and not _launcher_watchdog_task.done():
return
launcher_pid_text = os.getenv("ZHENXUN_LAUNCHER_PID", "").strip()
if not launcher_pid_text:
return
with contextlib.suppress(ValueError):
launcher_pid = int(launcher_pid_text)
if launcher_pid > 0:
_launcher_watchdog_task = asyncio.create_task(
_launcher_watchdog_loop(launcher_pid)
)
async def _stop_launcher_watchdog() -> None:
global _launcher_watchdog_task
task = _launcher_watchdog_task
_launcher_watchdog_task = None
if task is None or task.done():
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
def register_runtime_bootstrap(_driver) -> None:
_apply_alconna_conflict_patch()
apply_uninfo_onebot11_patch()
global _runtime_hooks_registered
if _runtime_hooks_registered:
return
_runtime_hooks_registered = True
@driver.on_startup
@PriorityLifecycle.on_startup(priority=-100)
async def _setup_runtime_concurrency() -> None:
global _thread_executor
workers = _get_executor_workers()
@@ -77,10 +131,16 @@ def register_runtime_bootstrap(driver: Driver) -> None:
with contextlib.suppress(Exception):
limiter = anyio.to_thread.current_default_thread_limiter()
limiter.total_tokens = _get_anyio_tokens(workers)
_start_launcher_watchdog()
await start_send_queue()
await start_memory_governor()
@PriorityLifecycle.on_shutdown(priority=50)
async def _shutdown_runtime_concurrency() -> None:
global _thread_executor
await _stop_launcher_watchdog()
await stop_send_queue()
await stop_memory_governor()
executor = _thread_executor
_thread_executor = None
if executor is not None:
+91 -13
View File
@@ -2,21 +2,29 @@ import asyncio
import time
from typing import Any
import nonebot
from nonebot.adapters import Bot
from zhenxun.services.log import logger
_SEND_APIS = {"send_msg", "send_like"}
_QUEUE: asyncio.Queue[tuple[Bot, str, dict[str, Any], asyncio.Future]] = asyncio.Queue()
_SEND_APIS = {"send_msg", "send_group_msg", "send_private_msg", "send_like"}
_WORKERS = 3
_MIN_INTERVAL = 0.05
_QUEUE_MAXSIZE = 2000
_SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 3.0
_QUEUE_PRESSURE_LOG_INTERVAL = 10.0
_QUEUE: asyncio.Queue[tuple[Bot, str, dict[str, Any], asyncio.Future[Any]]] = (
asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
)
_SEND_LOCK = asyncio.Lock()
_LAST_SEND_TS = 0.0
_API_SEMAPHORE = asyncio.Semaphore(3)
_ORIG_CALL_API = Bot.call_api
_PATCHED = False
_WORKER_TASKS: list[asyncio.Task] = []
_QUEUE_TIMEOUT_COUNT = 0
_SEND_LIKE_DROP_COUNT = 0
_LAST_QUEUE_PRESSURE_LOG = 0.0
_STOPPING = False
async def _rate_limit():
@@ -29,15 +37,36 @@ async def _rate_limit():
_LAST_SEND_TS = time.monotonic()
def _log_queue_pressure(reason: str) -> None:
global _LAST_QUEUE_PRESSURE_LOG
now = time.monotonic()
if now - _LAST_QUEUE_PRESSURE_LOG < _QUEUE_PRESSURE_LOG_INTERVAL:
return
_LAST_QUEUE_PRESSURE_LOG = now
logger.warning(
f"{reason}; qsize={_QUEUE.qsize()}/{_QUEUE_MAXSIZE} "
f"timeouts={_QUEUE_TIMEOUT_COUNT} dropped_like={_SEND_LIKE_DROP_COUNT}",
"SendQueue",
)
async def _direct_call_api(bot: Bot, api: str, data: dict[str, Any]) -> Any:
await _rate_limit()
async with _API_SEMAPHORE:
return await _ORIG_CALL_API(bot, api, **data)
async def _worker(worker_id: int):
while True:
bot, api, data, future = await _QUEUE.get()
try:
await _rate_limit()
async with _API_SEMAPHORE:
result = await _ORIG_CALL_API(bot, api, **data)
result = await _direct_call_api(bot, api, data)
if not future.done():
future.set_result(result)
except asyncio.CancelledError:
if not future.done():
future.set_exception(RuntimeError("send queue worker cancelled"))
raise
except Exception as exc:
if not future.done():
future.set_exception(exc)
@@ -54,12 +83,41 @@ async def _worker(worker_id: int):
async def _queued_call_api(self: Bot, api: str, **data: Any):
if api not in _SEND_APIS:
return await _ORIG_CALL_API(self, api, **data)
if _STOPPING:
return await _direct_call_api(self, api, data)
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
await _QUEUE.put((self, api, data, future))
future: asyncio.Future[Any] = loop.create_future()
queue_item = (self, api, data, future)
try:
_QUEUE.put_nowait(queue_item)
except asyncio.QueueFull:
if api == "send_like":
global _SEND_LIKE_DROP_COUNT
_SEND_LIKE_DROP_COUNT += 1
_log_queue_pressure("send_like dropped because send queue is full")
return None
global _QUEUE_TIMEOUT_COUNT
_QUEUE_TIMEOUT_COUNT += 1
_log_queue_pressure(f"{api} fallback to direct send because queue is full")
return await _direct_call_api(self, api, data)
return await future
def _drain_pending_futures(reason: str) -> int:
drained = 0
while True:
try:
_, _, _, future = _QUEUE.get_nowait()
except asyncio.QueueEmpty:
break
if not future.done():
future.set_exception(RuntimeError(reason))
_QUEUE.task_done()
drained += 1
return drained
def patch_send_queue() -> None:
global _PATCHED
if _PATCHED:
@@ -68,21 +126,41 @@ def patch_send_queue() -> None:
_PATCHED = True
driver = nonebot.get_driver()
def unpatch_send_queue() -> None:
global _PATCHED
if not _PATCHED:
return
Bot.call_api = _ORIG_CALL_API # type: ignore[assignment]
_PATCHED = False
@driver.on_startup
async def _start_send_queue():
async def start_send_queue() -> None:
global _STOPPING
patch_send_queue()
_STOPPING = False
if _WORKER_TASKS:
return
for idx in range(_WORKERS):
_WORKER_TASKS.append(asyncio.create_task(_worker(idx)))
@driver.on_shutdown
async def _stop_send_queue():
async def stop_send_queue() -> None:
global _STOPPING
_STOPPING = True
try:
await asyncio.wait_for(_QUEUE.join(), timeout=_SHUTDOWN_DRAIN_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
drained = _drain_pending_futures("send queue shutdown before drain completed")
logger.warning(
f"send queue shutdown timed out, dropped pending futures={drained}, "
f"qsize={_QUEUE.qsize()}",
"SendQueue",
)
tasks = _WORKER_TASKS.copy()
_WORKER_TASKS.clear()
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
unpatch_send_queue()
_STOPPING = False
+149
View File
@@ -0,0 +1,149 @@
import asyncio
from collections.abc import Awaitable, Callable
import contextlib
from typing import Any, cast
from nonebot.adapters import Bot, Event
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot.log import logger
_PATCHED = False
_ORIGINAL_FETCH: Callable[..., Awaitable[Any]] | None = None
_ORIGINAL_ONEBOT11_GROUP_MESSAGE: Callable[..., Awaitable[dict[str, Any]]] | None = None
def _sender_value(sender: Any, key: str, default: Any = None) -> Any:
value = getattr(sender, key, default)
return default if value is None else value
def _event_value(event: Event, key: str, default: Any = None) -> Any:
value = getattr(event, key, default)
return default if value is None else value
def _event_group_name(event: Event) -> str | None:
group_name = _event_value(event, "group_name")
if isinstance(group_name, str) and group_name:
return group_name
group = _event_value(event, "group")
if group is not None:
name = _sender_value(group, "name") or _sender_value(group, "group_name")
if isinstance(name, str) and name:
return name
return None
def _has_compatible_onebot11_sender(event: Event) -> bool:
if getattr(event, "_zx_uninfo_full_fetch", False):
return False
sender = _event_value(event, "sender")
if sender is None:
return False
return (
_event_value(event, "user_id") is not None
and _event_value(event, "group_id") is not None
and _sender_value(sender, "nickname") is not None
and _sender_value(sender, "role") is not None
)
async def _fast_onebot11_group_message(bot: Bot, event: Event) -> dict[str, Any]:
"""Build Uninfo session data from OneBot v11 group message event fields.
nonebot-plugin-uninfo's default OneBot v11 fetcher always calls
get_group_info and get_group_member_info for group messages. For normal
matcher rule checks, event-provided sender fields are enough and avoid
multiplying protocol API calls by the number of candidate matchers.
"""
original = _ORIGINAL_ONEBOT11_GROUP_MESSAGE
if not _has_compatible_onebot11_sender(event):
if original is not None:
return await original(bot, event)
logger.debug("Uninfo OneBot11 fast fetch fallback unavailable")
sender = _event_value(event, "sender")
user_id = str(_event_value(event, "user_id", ""))
group_id = str(_event_value(event, "group_id", ""))
nickname = _sender_value(sender, "nickname", "")
card = _sender_value(sender, "card", "") or nickname
return {
"group_id": group_id,
"group_name": _event_group_name(event),
"user_id": user_id,
"name": nickname,
"nickname": card,
"card": card,
"role": _sender_value(sender, "role", "member"),
"join_time": _event_value(event, "join_time"),
"gender": _sender_value(sender, "sex", "unknown") or "unknown",
}
async def _singleflight_fetch(self: Any, bot: Bot, event: Event) -> Any:
original = _ORIGINAL_FETCH
if original is None:
return None
try:
sess_id = self.get_session_id(event)
except ValueError:
return await original(self, bot, event)
session_cache = getattr(self, "session_cache", None)
if isinstance(session_cache, dict) and sess_id in session_cache:
return session_cache[sess_id]
inflight = getattr(self, "_zx_fetch_inflight", None)
if not isinstance(inflight, dict):
inflight = {}
setattr(self, "_zx_fetch_inflight", inflight)
key = (str(getattr(bot, "self_id", "")), event.__class__, sess_id)
task = inflight.get(key)
if task is None or task.done():
task = asyncio.ensure_future(original(self, bot, event))
inflight[key] = task
try:
return await task
finally:
if inflight.get(key) is task and task.done():
inflight.pop(key, None)
def apply_uninfo_onebot11_patch() -> None:
global _ORIGINAL_FETCH, _ORIGINAL_ONEBOT11_GROUP_MESSAGE, _PATCHED
if _PATCHED:
return
with contextlib.suppress(Exception):
from nonebot_plugin_uninfo.adapters.onebot11.main import fetcher
original_endpoint = fetcher.endpoint.get(GroupMessageEvent)
if not getattr(original_endpoint, "__zhenxun_fast_onebot11__", False):
_ORIGINAL_ONEBOT11_GROUP_MESSAGE = cast(
Callable[..., Awaitable[dict[str, Any]]] | None,
original_endpoint,
)
setattr(_fast_onebot11_group_message, "__zhenxun_fast_onebot11__", True)
fetcher.endpoint[GroupMessageEvent] = _fast_onebot11_group_message
try:
from nonebot_plugin_uninfo.fetch import InfoFetcher
except Exception as e:
logger.warning("Uninfo patch skipped", e=e)
return
original_fetch = getattr(InfoFetcher, "fetch", None)
if getattr(original_fetch, "__zhenxun_singleflight__", False):
_PATCHED = True
return
if original_fetch is None:
return
_ORIGINAL_FETCH = cast(Callable[..., Awaitable[Any]], original_fetch)
setattr(_singleflight_fetch, "__zhenxun_singleflight__", True)
setattr(InfoFetcher, "fetch", _singleflight_fetch)
_PATCHED = True
logger.debug("Uninfo OneBot11 fast fetch and singleflight patch applied")