mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-09-29 00:32:06 +08:00
* bugfix:更换数据库初始化超时路径以修复连接超时问题 * 移除部分观测链路 * 细节修改 * 权限检查细节修改2 * bugfix:修复金币懒加载造成插件金币消耗不了的问题 * bugfix:整理鉴权逻辑 * 完善缓存系统 * 优化官端使用 * bugfix:增加官机和频道的前导统一剥离,新增获取群列表自愈 * bugfix:修复导入问题
2426 lines
85 KiB
Python
2426 lines
85 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass, field
|
|
import json
|
|
import os
|
|
import time
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
import uuid
|
|
|
|
from zhenxun.services.cache.config import CacheMode
|
|
from zhenxun.services.log import logger
|
|
from zhenxun.utils.enum import (
|
|
BlockType,
|
|
LimitCheckType,
|
|
LimitWatchType,
|
|
PluginLimitType,
|
|
PluginType,
|
|
)
|
|
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
|
|
|
if TYPE_CHECKING:
|
|
from zhenxun.models.plugin_info import PluginInfo
|
|
|
|
LOG_COMMAND = "RuntimeCache"
|
|
|
|
|
|
def _coerce_int(value, default: int) -> int:
|
|
try:
|
|
value_int = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return value_int if value_int >= 0 else default
|
|
|
|
|
|
# RuntimeCache 以模型 save/delete 主动失效为主,周期 refresh 只做兜底。
|
|
# 这些默认值避免低压力运行时频繁全量扫表。
|
|
# 权限检查热路径以 RuntimeCache/AuthSnapshot 为唯一数据入口;普通业务的
|
|
# DataAccess/CacheRoot 缓存不能替代这里的运行态快照。
|
|
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"
|
|
RUNTIME_CACHE_LOAD_RETRY_SECONDS = 1.0
|
|
RUNTIME_CACHE_STARTUP_REFRESH_SKIP_SECONDS = 5.0
|
|
|
|
|
|
INSTANCE_ID = uuid.uuid4().hex
|
|
_CACHE_READY_EVENT = asyncio.Event()
|
|
_APPLYING_REMOTE_CACHE_EVENT: ContextVar[bool] = ContextVar(
|
|
"APPLYING_REMOTE_RUNTIME_CACHE_EVENT",
|
|
default=False,
|
|
)
|
|
|
|
|
|
def _env_get(name: str, default: str | None = None) -> str | None:
|
|
value = os.getenv(name)
|
|
if value is None:
|
|
value = os.getenv(name.lower())
|
|
return value if value is not None else default
|
|
|
|
|
|
def _redis_enabled() -> bool:
|
|
mode = (_env_get("CACHE_MODE") or "").upper()
|
|
if mode != CacheMode.REDIS:
|
|
return False
|
|
return bool(_env_get("REDIS_HOST"))
|
|
|
|
|
|
def is_cache_ready() -> bool:
|
|
return _CACHE_READY_EVENT.is_set()
|
|
|
|
|
|
async def wait_cache_ready(timeout: float | None = None) -> bool:
|
|
try:
|
|
await asyncio.wait_for(_CACHE_READY_EVENT.wait(), timeout=timeout)
|
|
return True
|
|
except asyncio.TimeoutError:
|
|
return False
|
|
|
|
|
|
def _parse_block_modules(value: str) -> frozenset[str]:
|
|
if not value:
|
|
return frozenset()
|
|
items = []
|
|
for part in value.split("<"):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
part = part.strip(",").strip()
|
|
if part:
|
|
items.append(part)
|
|
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
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"module": self.module,
|
|
"module_path": self.module_path,
|
|
"name": self.name,
|
|
"status": self.status,
|
|
"block_type": self.block_type.value if self.block_type else None,
|
|
"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.value if self.plugin_type else None,
|
|
"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,
|
|
}
|
|
|
|
@classmethod
|
|
def from_payload(cls, payload: dict[str, Any]) -> "PluginInfoSnapshot":
|
|
block_type = payload.get("block_type")
|
|
if block_type is not None and not isinstance(block_type, BlockType):
|
|
block_type = BlockType(block_type)
|
|
plugin_type = payload.get("plugin_type")
|
|
if plugin_type is not None and not isinstance(plugin_type, PluginType):
|
|
plugin_type = PluginType(plugin_type)
|
|
return cls(
|
|
id=int(payload.get("id", 0) or 0),
|
|
module=str(payload.get("module", "") or ""),
|
|
module_path=str(payload.get("module_path", "") or ""),
|
|
name=str(payload.get("name", "") or ""),
|
|
status=bool(payload.get("status", True)),
|
|
block_type=block_type,
|
|
load_status=bool(payload.get("load_status", True)),
|
|
author=payload.get("author"),
|
|
version=payload.get("version"),
|
|
level=int(payload.get("level", 0) or 0),
|
|
default_status=bool(payload.get("default_status", True)),
|
|
limit_superuser=bool(payload.get("limit_superuser", False)),
|
|
menu_type=str(payload.get("menu_type", "") or ""),
|
|
plugin_type=plugin_type,
|
|
cost_gold=int(payload.get("cost_gold", 0) or 0),
|
|
admin_level=payload.get("admin_level"),
|
|
ignore_prompt=bool(payload.get("ignore_prompt", False)),
|
|
is_delete=bool(payload.get("is_delete", False)),
|
|
parent=payload.get("parent"),
|
|
is_show=bool(payload.get("is_show", True)),
|
|
ignore_statistics=bool(payload.get("ignore_statistics", False)),
|
|
impression=float(payload.get("impression", 0) or 0),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BanEntry:
|
|
user_id: str | None
|
|
group_id: str | None
|
|
ban_level: int
|
|
ban_time: int
|
|
duration: int
|
|
expire_at: float | None
|
|
|
|
def remaining(self, now: float | None = None) -> int:
|
|
if self.duration == -1:
|
|
return -1
|
|
now_ts = time.time() if now is None else now
|
|
left = int(self.ban_time + self.duration - now_ts)
|
|
return left if left > 0 else 0
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"user_id": self.user_id,
|
|
"group_id": self.group_id,
|
|
"ban_level": self.ban_level,
|
|
"ban_time": self.ban_time,
|
|
"duration": self.duration,
|
|
"expire_at": self.expire_at,
|
|
}
|
|
|
|
@classmethod
|
|
def from_payload(cls, payload: dict[str, Any]) -> "BanEntry":
|
|
user_id = payload.get("user_id")
|
|
group_id = payload.get("group_id")
|
|
ban_time = int(payload.get("ban_time", 0) or 0)
|
|
duration = int(payload.get("duration", 0) or 0)
|
|
expire_at = payload.get("expire_at")
|
|
if expire_at is None and duration != -1:
|
|
expire_at = float(ban_time + duration)
|
|
return cls(
|
|
user_id=str(user_id) if user_id else None,
|
|
group_id=str(group_id) if group_id else None,
|
|
ban_level=int(payload.get("ban_level", 0) or 0),
|
|
ban_time=ban_time,
|
|
duration=duration,
|
|
expire_at=expire_at,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BotSnapshot:
|
|
bot_id: str
|
|
status: bool
|
|
platform: str | None
|
|
block_plugins: str
|
|
block_tasks: str
|
|
available_plugins: str
|
|
available_tasks: str
|
|
|
|
@classmethod
|
|
def from_model(cls, model) -> "BotSnapshot":
|
|
return cls(
|
|
bot_id=str(model.bot_id),
|
|
status=bool(model.status),
|
|
platform=getattr(model, "platform", None),
|
|
block_plugins=getattr(model, "block_plugins", "") or "",
|
|
block_tasks=getattr(model, "block_tasks", "") or "",
|
|
available_plugins=getattr(model, "available_plugins", "") or "",
|
|
available_tasks=getattr(model, "available_tasks", "") or "",
|
|
)
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"bot_id": self.bot_id,
|
|
"status": self.status,
|
|
"platform": self.platform,
|
|
"block_plugins": self.block_plugins,
|
|
"block_tasks": self.block_tasks,
|
|
"available_plugins": self.available_plugins,
|
|
"available_tasks": self.available_tasks,
|
|
}
|
|
|
|
@classmethod
|
|
def from_payload(cls, payload: dict[str, Any]) -> "BotSnapshot":
|
|
return cls(
|
|
bot_id=str(payload.get("bot_id", "")),
|
|
status=bool(payload.get("status", True)),
|
|
platform=payload.get("platform"),
|
|
block_plugins=payload.get("block_plugins", "") or "",
|
|
block_tasks=payload.get("block_tasks", "") or "",
|
|
available_plugins=payload.get("available_plugins", "") or "",
|
|
available_tasks=payload.get("available_tasks", "") or "",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GroupSnapshot:
|
|
group_id: str
|
|
channel_id: str | None
|
|
group_name: str
|
|
max_member_count: int
|
|
member_count: int
|
|
status: bool
|
|
level: int
|
|
is_super: bool
|
|
group_flag: int
|
|
block_plugin: str
|
|
superuser_block_plugin: str
|
|
block_task: str
|
|
superuser_block_task: str
|
|
platform: str | None
|
|
block_plugin_set: frozenset[str] = field(default_factory=frozenset)
|
|
superuser_block_plugin_set: frozenset[str] = field(default_factory=frozenset)
|
|
block_task_set: frozenset[str] = field(default_factory=frozenset)
|
|
superuser_block_task_set: frozenset[str] = field(default_factory=frozenset)
|
|
|
|
@classmethod
|
|
def from_model(cls, model) -> "GroupSnapshot":
|
|
block_plugin = getattr(model, "block_plugin", "") or ""
|
|
superuser_block_plugin = getattr(model, "superuser_block_plugin", "") or ""
|
|
block_task = getattr(model, "block_task", "") or ""
|
|
superuser_block_task = getattr(model, "superuser_block_task", "") or ""
|
|
return cls(
|
|
group_id=str(model.group_id),
|
|
channel_id=getattr(model, "channel_id", None),
|
|
group_name=getattr(model, "group_name", "") or "",
|
|
max_member_count=int(getattr(model, "max_member_count", 0) or 0),
|
|
member_count=int(getattr(model, "member_count", 0) or 0),
|
|
status=bool(getattr(model, "status", True)),
|
|
level=int(getattr(model, "level", 0) or 0),
|
|
is_super=bool(getattr(model, "is_super", False)),
|
|
group_flag=int(getattr(model, "group_flag", 0) or 0),
|
|
block_plugin=block_plugin,
|
|
superuser_block_plugin=superuser_block_plugin,
|
|
block_task=block_task,
|
|
superuser_block_task=superuser_block_task,
|
|
platform=getattr(model, "platform", None),
|
|
block_plugin_set=_parse_block_modules(block_plugin),
|
|
superuser_block_plugin_set=_parse_block_modules(superuser_block_plugin),
|
|
block_task_set=_parse_block_modules(block_task),
|
|
superuser_block_task_set=_parse_block_modules(superuser_block_task),
|
|
)
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"group_id": self.group_id,
|
|
"channel_id": self.channel_id,
|
|
"group_name": self.group_name,
|
|
"max_member_count": self.max_member_count,
|
|
"member_count": self.member_count,
|
|
"status": self.status,
|
|
"level": self.level,
|
|
"is_super": self.is_super,
|
|
"group_flag": self.group_flag,
|
|
"block_plugin": self.block_plugin,
|
|
"superuser_block_plugin": self.superuser_block_plugin,
|
|
"block_task": self.block_task,
|
|
"superuser_block_task": self.superuser_block_task,
|
|
"platform": self.platform,
|
|
}
|
|
|
|
@classmethod
|
|
def from_payload(cls, payload: dict[str, Any]) -> "GroupSnapshot":
|
|
block_plugin = payload.get("block_plugin", "") or ""
|
|
superuser_block_plugin = payload.get("superuser_block_plugin", "") or ""
|
|
block_task = payload.get("block_task", "") or ""
|
|
superuser_block_task = payload.get("superuser_block_task", "") or ""
|
|
return cls(
|
|
group_id=str(payload.get("group_id", "")),
|
|
channel_id=payload.get("channel_id"),
|
|
group_name=payload.get("group_name", "") or "",
|
|
max_member_count=int(payload.get("max_member_count", 0) or 0),
|
|
member_count=int(payload.get("member_count", 0) or 0),
|
|
status=bool(payload.get("status", True)),
|
|
level=int(payload.get("level", 0) or 0),
|
|
is_super=bool(payload.get("is_super", False)),
|
|
group_flag=int(payload.get("group_flag", 0) or 0),
|
|
block_plugin=block_plugin,
|
|
superuser_block_plugin=superuser_block_plugin,
|
|
block_task=block_task,
|
|
superuser_block_task=superuser_block_task,
|
|
platform=payload.get("platform"),
|
|
block_plugin_set=_parse_block_modules(block_plugin),
|
|
superuser_block_plugin_set=_parse_block_modules(superuser_block_plugin),
|
|
block_task_set=_parse_block_modules(block_task),
|
|
superuser_block_task_set=_parse_block_modules(superuser_block_task),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LevelUserSnapshot:
|
|
user_id: str
|
|
group_id: str | None
|
|
user_level: int
|
|
group_flag: int
|
|
|
|
@classmethod
|
|
def from_model(cls, model) -> "LevelUserSnapshot":
|
|
return cls(
|
|
user_id=str(model.user_id),
|
|
group_id=getattr(model, "group_id", None),
|
|
user_level=int(getattr(model, "user_level", 0) or 0),
|
|
group_flag=int(getattr(model, "group_flag", 0) or 0),
|
|
)
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"user_id": self.user_id,
|
|
"group_id": self.group_id,
|
|
"user_level": self.user_level,
|
|
"group_flag": self.group_flag,
|
|
}
|
|
|
|
@classmethod
|
|
def from_payload(cls, payload: dict[str, Any]) -> "LevelUserSnapshot":
|
|
return cls(
|
|
user_id=str(payload.get("user_id", "")),
|
|
group_id=payload.get("group_id"),
|
|
user_level=int(payload.get("user_level", 0) or 0),
|
|
group_flag=int(payload.get("group_flag", 0) or 0),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PluginLimitSnapshot:
|
|
id: int
|
|
module: str
|
|
module_path: str
|
|
limit_type: PluginLimitType
|
|
watch_type: LimitWatchType
|
|
check_type: LimitCheckType
|
|
status: bool
|
|
result: str | None
|
|
cd: int | None
|
|
max_count: int | None
|
|
|
|
@classmethod
|
|
def from_model(cls, model) -> "PluginLimitSnapshot":
|
|
return cls(
|
|
id=int(model.id),
|
|
module=str(model.module),
|
|
module_path=str(model.module_path),
|
|
limit_type=model.limit_type,
|
|
watch_type=model.watch_type,
|
|
check_type=model.check_type,
|
|
status=bool(model.status),
|
|
result=getattr(model, "result", None),
|
|
cd=getattr(model, "cd", None),
|
|
max_count=getattr(model, "max_count", None),
|
|
)
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"module": self.module,
|
|
"module_path": self.module_path,
|
|
"limit_type": self.limit_type.value,
|
|
"watch_type": self.watch_type.value,
|
|
"check_type": self.check_type.value,
|
|
"status": self.status,
|
|
"result": self.result,
|
|
"cd": self.cd,
|
|
"max_count": self.max_count,
|
|
}
|
|
|
|
@classmethod
|
|
def from_payload(cls, payload: dict[str, Any]) -> "PluginLimitSnapshot":
|
|
return cls(
|
|
id=int(payload.get("id", 0) or 0),
|
|
module=str(payload.get("module", "")),
|
|
module_path=str(payload.get("module_path", "")),
|
|
limit_type=PluginLimitType(payload.get("limit_type", PluginLimitType.CD)),
|
|
watch_type=LimitWatchType(payload.get("watch_type", LimitWatchType.USER)),
|
|
check_type=LimitCheckType(payload.get("check_type", LimitCheckType.ALL)),
|
|
status=bool(payload.get("status", True)),
|
|
result=payload.get("result"),
|
|
cd=payload.get("cd"),
|
|
max_count=payload.get("max_count"),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TaskInfoSnapshot:
|
|
id: int
|
|
module: str
|
|
name: str
|
|
status: bool
|
|
load_status: bool
|
|
default_status: bool
|
|
run_time: str | None
|
|
|
|
@classmethod
|
|
def from_model(cls, model) -> "TaskInfoSnapshot":
|
|
return cls(
|
|
id=int(getattr(model, "id", 0) or 0),
|
|
module=str(model.module),
|
|
name=str(getattr(model, "name", "") or ""),
|
|
status=bool(getattr(model, "status", True)),
|
|
load_status=bool(getattr(model, "load_status", True)),
|
|
default_status=bool(getattr(model, "default_status", True)),
|
|
run_time=getattr(model, "run_time", None),
|
|
)
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"module": self.module,
|
|
"name": self.name,
|
|
"status": self.status,
|
|
"load_status": self.load_status,
|
|
"default_status": self.default_status,
|
|
"run_time": self.run_time,
|
|
}
|
|
|
|
@classmethod
|
|
def from_payload(cls, payload: dict[str, Any]) -> "TaskInfoSnapshot":
|
|
return cls(
|
|
id=int(payload.get("id", 0) or 0),
|
|
module=str(payload.get("module", "")),
|
|
name=str(payload.get("name", "") or ""),
|
|
status=bool(payload.get("status", True)),
|
|
load_status=bool(payload.get("load_status", True)),
|
|
default_status=bool(payload.get("default_status", True)),
|
|
run_time=payload.get("run_time"),
|
|
)
|
|
|
|
|
|
class RuntimeCacheSync:
|
|
_redis: ClassVar[Any | None] = None
|
|
_pubsub: ClassVar[Any | None] = None
|
|
_task: ClassVar[asyncio.Task | None] = None
|
|
_publish_tasks: ClassVar[set[asyncio.Task]] = set()
|
|
_ready: ClassVar[bool] = False
|
|
_channel: ClassVar[str] = ""
|
|
|
|
@classmethod
|
|
def _sync_enabled(cls) -> bool:
|
|
enabled = RUNTIME_CACHE_SYNC_ENABLED
|
|
return enabled and _redis_enabled()
|
|
|
|
@classmethod
|
|
async def start(cls) -> None:
|
|
if cls._ready:
|
|
return
|
|
if not cls._sync_enabled():
|
|
return
|
|
try:
|
|
import redis.asyncio as redis_async
|
|
except ImportError:
|
|
logger.warning(
|
|
"redis not installed, runtime cache sync disabled", LOG_COMMAND
|
|
)
|
|
return
|
|
|
|
host = _env_get("REDIS_HOST")
|
|
if not host:
|
|
return
|
|
port = _coerce_int(_env_get("REDIS_PORT"), 6379)
|
|
password = _env_get("REDIS_PASSWORD")
|
|
cls._channel = RUNTIME_CACHE_SYNC_CHANNEL
|
|
try:
|
|
cls._redis = redis_async.Redis(
|
|
host=host,
|
|
port=port,
|
|
password=password,
|
|
decode_responses=True,
|
|
)
|
|
cls._pubsub = cls._redis.pubsub()
|
|
if cls._pubsub is None:
|
|
return
|
|
await cls._pubsub.subscribe(cls._channel)
|
|
cls._task = asyncio.create_task(cls._listen_loop())
|
|
cls._ready = True
|
|
logger.info("runtime cache sync enabled", LOG_COMMAND)
|
|
except Exception as exc:
|
|
logger.error("runtime cache sync init failed", LOG_COMMAND, e=exc)
|
|
await cls.stop()
|
|
|
|
@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
|
|
try:
|
|
if cls._pubsub is not None:
|
|
await cls._pubsub.close()
|
|
except Exception:
|
|
pass
|
|
cls._pubsub = None
|
|
try:
|
|
if cls._redis is not None:
|
|
await cls._redis.close()
|
|
except Exception:
|
|
pass
|
|
cls._redis = None
|
|
|
|
@classmethod
|
|
def publish_event(cls, cache_type: str, action: str, data: dict[str, Any]) -> None:
|
|
if not cls._ready:
|
|
return
|
|
payload = {
|
|
"source": INSTANCE_ID,
|
|
"type": cache_type,
|
|
"action": action,
|
|
"data": data,
|
|
}
|
|
task = asyncio.create_task(cls._publish(payload))
|
|
cls._publish_tasks.add(task)
|
|
task.add_done_callback(cls._publish_tasks.discard)
|
|
|
|
@classmethod
|
|
async def _publish(cls, payload: dict[str, Any]) -> None:
|
|
if not cls._ready or cls._redis is None:
|
|
return
|
|
try:
|
|
await cls._redis.publish(cls._channel, json.dumps(payload))
|
|
except Exception as exc:
|
|
logger.error("runtime cache sync publish failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
async def _listen_loop(cls) -> None:
|
|
if cls._pubsub is None:
|
|
return
|
|
try:
|
|
while True:
|
|
message = await cls._pubsub.get_message(
|
|
ignore_subscribe_messages=True, timeout=1.0
|
|
)
|
|
if not message:
|
|
await asyncio.sleep(0.05)
|
|
continue
|
|
await cls._handle_message(message.get("data"))
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception as exc:
|
|
logger.error("runtime cache sync listener failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
async def _handle_message(cls, raw: Any) -> None:
|
|
if raw is None:
|
|
return
|
|
if isinstance(raw, bytes | bytearray):
|
|
try:
|
|
raw = raw.decode()
|
|
except Exception:
|
|
return
|
|
if not raw:
|
|
return
|
|
try:
|
|
payload = json.loads(raw)
|
|
except Exception:
|
|
return
|
|
if payload.get("source") == INSTANCE_ID:
|
|
return
|
|
cache_type = payload.get("type")
|
|
action = payload.get("action")
|
|
data = payload.get("data") or {}
|
|
token = _APPLYING_REMOTE_CACHE_EVENT.set(True)
|
|
try:
|
|
if cache_type == "bot":
|
|
await BotMemoryCache.apply_sync_event(action, data)
|
|
elif cache_type == "group":
|
|
await GroupMemoryCache.apply_sync_event(action, data)
|
|
elif cache_type == "ban":
|
|
await BanMemoryCache.apply_sync_event(action, data)
|
|
elif cache_type == "level":
|
|
await LevelUserMemoryCache.apply_sync_event(action, data)
|
|
elif cache_type == "task":
|
|
await TaskInfoMemoryCache.apply_sync_event(action, data)
|
|
elif cache_type == "plugin_limit":
|
|
await PluginLimitMemoryCache.apply_sync_event(action, data)
|
|
elif cache_type == "plugin":
|
|
await PluginInfoMemoryCache.apply_sync_event(action, data)
|
|
finally:
|
|
_APPLYING_REMOTE_CACHE_EVENT.reset(token)
|
|
|
|
|
|
class RuntimeCacheMutation:
|
|
"""Small helpers for runtime cache mutation bookkeeping.
|
|
|
|
Cache classes still own their storage layout. This helper centralizes the
|
|
shared mutation side effects: health markers, negative-cache cleanup and
|
|
cross-process publish.
|
|
"""
|
|
|
|
_load_locks: ClassVar[dict[str, asyncio.Lock]] = {}
|
|
_retry_after: ClassVar[dict[str, float]] = {}
|
|
|
|
@classmethod
|
|
async def ensure_loaded(cls, cache_cls: type, label: str) -> None:
|
|
if getattr(cache_cls, "_loaded", False):
|
|
return
|
|
now = time.monotonic()
|
|
if cls._retry_after.get(label, 0.0) > now:
|
|
return
|
|
lock = cls._load_locks.setdefault(label, asyncio.Lock())
|
|
async with lock:
|
|
if getattr(cache_cls, "_loaded", False):
|
|
return
|
|
now = time.monotonic()
|
|
if cls._retry_after.get(label, 0.0) > now:
|
|
return
|
|
try:
|
|
await cache_cls.refresh()
|
|
except Exception as exc:
|
|
cls.mark_error(cache_cls, exc)
|
|
cls._retry_after[label] = (
|
|
time.monotonic() + RUNTIME_CACHE_LOAD_RETRY_SECONDS
|
|
)
|
|
raise
|
|
|
|
@staticmethod
|
|
def mark_refreshed(cache_cls: type) -> None:
|
|
setattr(cache_cls, "_loaded", True)
|
|
setattr(cache_cls, "_last_refresh", time.time())
|
|
setattr(cache_cls, "_last_error", None)
|
|
|
|
@staticmethod
|
|
def mark_error(cache_cls: type, exc: Exception) -> None:
|
|
setattr(cache_cls, "_last_error", f"{type(exc).__name__}: {exc}")
|
|
|
|
@staticmethod
|
|
def clear_negative_key(cache_cls: type, key: object) -> None:
|
|
negative = getattr(cache_cls, "_negative", None)
|
|
if isinstance(negative, dict):
|
|
negative.pop(key, None)
|
|
|
|
@staticmethod
|
|
def clear_negative_all(cache_cls: type) -> None:
|
|
negative = getattr(cache_cls, "_negative", None)
|
|
if isinstance(negative, dict):
|
|
negative.clear()
|
|
|
|
@staticmethod
|
|
def publish(cache_type: str, action: str, data: dict[str, Any]) -> None:
|
|
if _APPLYING_REMOTE_CACHE_EVENT.get():
|
|
return
|
|
RuntimeCacheSync.publish_event(cache_type, action, data)
|
|
|
|
|
|
class PluginInfoMemoryCache:
|
|
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
_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
|
|
_last_error: ClassVar[str | None] = None
|
|
|
|
@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
|
|
|
|
@staticmethod
|
|
def _module_snapshot_rank(snapshot: PluginInfoSnapshot) -> tuple[int, int]:
|
|
return (1 if snapshot.load_status else 0, snapshot.id)
|
|
|
|
@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, PluginInfoSnapshot] = {}
|
|
by_module_path: dict[str, PluginInfoSnapshot] = {}
|
|
for plugin in plugins:
|
|
snapshot = PluginInfoSnapshot.from_model(plugin)
|
|
if snapshot.module:
|
|
current = by_module.get(snapshot.module)
|
|
if current is None or cls._module_snapshot_rank(
|
|
snapshot
|
|
) >= cls._module_snapshot_rank(current):
|
|
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
|
|
RuntimeCacheMutation.mark_refreshed(cls)
|
|
logger.debug(
|
|
f"plugin cache refreshed: {len(by_module)} entries", LOG_COMMAND
|
|
)
|
|
|
|
@classmethod
|
|
async def ensure_loaded(cls) -> None:
|
|
if cls._loaded:
|
|
return
|
|
await RuntimeCacheMutation.ensure_loaded(cls, "plugin")
|
|
|
|
@classmethod
|
|
def is_loaded(cls) -> bool:
|
|
return cls._loaded
|
|
|
|
@classmethod
|
|
async def get_by_module(cls, module: str) -> "PluginInfo | None":
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
return cls._to_model(cls._by_module.get(module))
|
|
|
|
@classmethod
|
|
def get_by_module_if_ready(cls, module: str) -> "PluginInfo | None":
|
|
if not cls._loaded:
|
|
return None
|
|
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 {
|
|
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._to_model(cls._by_module_path.get(module_path))
|
|
|
|
@classmethod
|
|
def set_plugin(cls, plugin) -> None:
|
|
if not plugin:
|
|
return
|
|
snapshot = PluginInfoSnapshot.from_model(plugin)
|
|
cls._store_snapshot(snapshot)
|
|
|
|
@classmethod
|
|
def remove_by_module(cls, module: str) -> 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:
|
|
snapshot = PluginInfoSnapshot.from_model(plugin)
|
|
cls._store_snapshot(snapshot)
|
|
RuntimeCacheMutation.publish("plugin", "upsert", snapshot.to_payload())
|
|
|
|
@classmethod
|
|
async def upsert_from_payload(cls, payload: dict[str, Any]) -> None:
|
|
try:
|
|
snapshot = PluginInfoSnapshot.from_payload(payload)
|
|
except Exception:
|
|
return
|
|
async with cls._lock:
|
|
cls._store_snapshot(snapshot)
|
|
|
|
@classmethod
|
|
async def remove(
|
|
cls, module: str | None = None, module_path: str | None = None
|
|
) -> None:
|
|
if not module and not module_path:
|
|
return
|
|
async with cls._lock:
|
|
if module:
|
|
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:
|
|
snapshot = cls._by_module_path.pop(module_path, None)
|
|
if snapshot and snapshot.module:
|
|
cls._by_module.pop(snapshot.module, None)
|
|
RuntimeCacheMutation.publish(
|
|
"plugin",
|
|
"delete",
|
|
{"module": module, "module_path": module_path},
|
|
)
|
|
|
|
@classmethod
|
|
async def apply_sync_event(cls, action: str, data: dict[str, Any]) -> None:
|
|
if action == "upsert":
|
|
await cls.upsert_from_payload(data)
|
|
elif action == "delete":
|
|
await cls.remove(data.get("module"), data.get("module_path"))
|
|
elif action == "refresh":
|
|
await cls.refresh()
|
|
|
|
@classmethod
|
|
async def _refresh_loop(cls, interval: int) -> None:
|
|
while True:
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await cls.refresh()
|
|
except Exception as exc:
|
|
logger.error("plugin cache refresh failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
def start_refresh_task(cls) -> None:
|
|
interval = PLUGININFO_MEM_REFRESH_INTERVAL
|
|
if interval <= 0:
|
|
return
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
return
|
|
cls._refresh_task = asyncio.create_task(cls._refresh_loop(interval))
|
|
|
|
@classmethod
|
|
def stop_tasks(cls) -> None:
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
cls._refresh_task.cancel()
|
|
cls._refresh_task = None
|
|
|
|
|
|
class BotMemoryCache:
|
|
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
_by_id: ClassVar[dict[str, BotSnapshot]] = {}
|
|
_negative: ClassVar[dict[str, float]] = {}
|
|
_loaded: ClassVar[bool] = False
|
|
_refresh_task: ClassVar[asyncio.Task | None] = None
|
|
_last_refresh: ClassVar[float] = 0.0
|
|
_last_error: ClassVar[str | None] = None
|
|
|
|
@classmethod
|
|
def _normalize(cls, bot_id: str | None) -> str | None:
|
|
if bot_id is None:
|
|
return None
|
|
bot_id = bot_id.strip()
|
|
return bot_id if bot_id else None
|
|
|
|
@classmethod
|
|
def _negative_ttl(cls) -> int:
|
|
return BOT_MEM_NEGATIVE_TTL
|
|
|
|
@classmethod
|
|
def _is_negative(cls, bot_id: str) -> bool:
|
|
expire_at = cls._negative.get(bot_id)
|
|
if not expire_at:
|
|
return False
|
|
if expire_at <= time.time():
|
|
RuntimeCacheMutation.clear_negative_key(cls, bot_id)
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def _mark_negative(cls, bot_id: str) -> None:
|
|
ttl = cls._negative_ttl()
|
|
if ttl <= 0:
|
|
return
|
|
cls._negative[bot_id] = time.time() + ttl
|
|
|
|
@classmethod
|
|
async def refresh(cls) -> None:
|
|
from zhenxun.models.bot_console import BotConsole
|
|
|
|
async with cls._lock:
|
|
records = await BotConsole.all()
|
|
cls._by_id = {str(r.bot_id): BotSnapshot.from_model(r) for r in records}
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
RuntimeCacheMutation.mark_refreshed(cls)
|
|
logger.debug(f"bot cache refreshed: {len(cls._by_id)} entries", LOG_COMMAND)
|
|
|
|
@classmethod
|
|
async def ensure_loaded(cls) -> None:
|
|
if cls._loaded:
|
|
return
|
|
await RuntimeCacheMutation.ensure_loaded(cls, "bot")
|
|
|
|
@classmethod
|
|
def is_loaded(cls) -> bool:
|
|
return cls._loaded
|
|
|
|
@classmethod
|
|
async def get(cls, bot_id: str | None) -> BotSnapshot | None:
|
|
bot_id = cls._normalize(bot_id)
|
|
if not bot_id:
|
|
return None
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
entry = cls._by_id.get(bot_id)
|
|
if entry:
|
|
return entry
|
|
if cls._is_negative(bot_id):
|
|
return None
|
|
cls._mark_negative(bot_id)
|
|
return None
|
|
|
|
@classmethod
|
|
def get_if_ready(cls, bot_id: str | None) -> BotSnapshot | None:
|
|
bot_id = cls._normalize(bot_id)
|
|
if not bot_id or not cls._loaded:
|
|
return None
|
|
entry = cls._by_id.get(bot_id)
|
|
if entry:
|
|
return entry
|
|
if cls._is_negative(bot_id):
|
|
return None
|
|
cls._mark_negative(bot_id)
|
|
return None
|
|
|
|
@classmethod
|
|
async def get_all(cls) -> dict[str, BotSnapshot]:
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
return dict(cls._by_id)
|
|
|
|
@classmethod
|
|
async def update_status(cls, bot_id: str | None, status: bool) -> None:
|
|
bot_id = cls._normalize(bot_id)
|
|
if not bot_id:
|
|
return
|
|
async with cls._lock:
|
|
entry = cls._by_id.get(bot_id)
|
|
if not entry:
|
|
return
|
|
updated = BotSnapshot(
|
|
bot_id=entry.bot_id,
|
|
status=bool(status),
|
|
platform=entry.platform,
|
|
block_plugins=entry.block_plugins,
|
|
block_tasks=entry.block_tasks,
|
|
available_plugins=entry.available_plugins,
|
|
available_tasks=entry.available_tasks,
|
|
)
|
|
cls._by_id[bot_id] = updated
|
|
RuntimeCacheMutation.clear_negative_key(cls, bot_id)
|
|
RuntimeCacheMutation.publish("bot", "upsert", updated.to_payload())
|
|
|
|
@classmethod
|
|
async def upsert_from_model(cls, record) -> None:
|
|
entry = BotSnapshot.from_model(record)
|
|
async with cls._lock:
|
|
cls._by_id[entry.bot_id] = entry
|
|
RuntimeCacheMutation.clear_negative_key(cls, entry.bot_id)
|
|
RuntimeCacheMutation.publish("bot", "upsert", entry.to_payload())
|
|
|
|
@classmethod
|
|
async def upsert_from_payload(cls, payload: dict[str, Any]) -> None:
|
|
entry = BotSnapshot.from_payload(payload)
|
|
if not entry.bot_id:
|
|
return
|
|
async with cls._lock:
|
|
cls._by_id[entry.bot_id] = entry
|
|
RuntimeCacheMutation.clear_negative_key(cls, entry.bot_id)
|
|
|
|
@classmethod
|
|
async def remove(cls, bot_id: str | None) -> None:
|
|
bot_id = cls._normalize(bot_id)
|
|
if not bot_id:
|
|
return
|
|
async with cls._lock:
|
|
cls._by_id.pop(bot_id, None)
|
|
RuntimeCacheMutation.clear_negative_key(cls, bot_id)
|
|
RuntimeCacheMutation.publish("bot", "delete", {"bot_id": bot_id})
|
|
|
|
@classmethod
|
|
async def apply_sync_event(cls, action: str, data: dict[str, Any]) -> None:
|
|
if action == "upsert":
|
|
await cls.upsert_from_payload(data)
|
|
elif action == "delete":
|
|
await cls.remove(data.get("bot_id"))
|
|
elif action == "refresh":
|
|
await cls.refresh()
|
|
|
|
@classmethod
|
|
async def _refresh_loop(cls, interval: int) -> None:
|
|
while True:
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await cls.refresh()
|
|
except Exception as exc:
|
|
logger.error("bot cache refresh failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
def start_tasks(cls) -> None:
|
|
interval = BOT_MEM_REFRESH_INTERVAL
|
|
if interval <= 0:
|
|
return
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
return
|
|
cls._refresh_task = asyncio.create_task(cls._refresh_loop(interval))
|
|
|
|
@classmethod
|
|
def stop_tasks(cls) -> None:
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
cls._refresh_task.cancel()
|
|
cls._refresh_task = None
|
|
|
|
|
|
class GroupMemoryCache:
|
|
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
_by_key: ClassVar[dict[tuple[str, str], GroupSnapshot]] = {}
|
|
_negative: ClassVar[dict[tuple[str, str], float]] = {}
|
|
_loaded: ClassVar[bool] = False
|
|
_refresh_task: ClassVar[asyncio.Task | None] = None
|
|
_last_refresh: ClassVar[float] = 0.0
|
|
_last_error: ClassVar[str | None] = None
|
|
|
|
@classmethod
|
|
def _normalize(cls, value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
value = value.strip()
|
|
return value if value else None
|
|
|
|
@classmethod
|
|
def _key(
|
|
cls, group_id: str | None, channel_id: str | None
|
|
) -> tuple[str, str] | None:
|
|
group_id = cls._normalize(group_id)
|
|
if not group_id:
|
|
return None
|
|
channel_id = cls._normalize(channel_id) or ""
|
|
return (group_id, channel_id)
|
|
|
|
@classmethod
|
|
def _negative_ttl(cls) -> int:
|
|
return GROUP_MEM_NEGATIVE_TTL
|
|
|
|
@classmethod
|
|
def _is_negative(cls, key: tuple[str, str]) -> bool:
|
|
expire_at = cls._negative.get(key)
|
|
if not expire_at:
|
|
return False
|
|
if expire_at <= time.time():
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def _mark_negative(cls, key: tuple[str, str]) -> None:
|
|
ttl = cls._negative_ttl()
|
|
if ttl <= 0:
|
|
return
|
|
cls._negative[key] = time.time() + ttl
|
|
|
|
@classmethod
|
|
async def refresh(cls) -> None:
|
|
from zhenxun.models.group_console import GroupConsole
|
|
|
|
async with cls._lock:
|
|
records = await GroupConsole.all()
|
|
by_key: dict[tuple[str, str], GroupSnapshot] = {}
|
|
for record in records:
|
|
entry = GroupSnapshot.from_model(record)
|
|
key = cls._key(entry.group_id, entry.channel_id)
|
|
if key:
|
|
by_key[key] = entry
|
|
cls._by_key = by_key
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
RuntimeCacheMutation.mark_refreshed(cls)
|
|
logger.debug(f"group cache refreshed: {len(by_key)} entries", LOG_COMMAND)
|
|
|
|
@classmethod
|
|
async def ensure_loaded(cls) -> None:
|
|
if cls._loaded:
|
|
return
|
|
await RuntimeCacheMutation.ensure_loaded(cls, "group")
|
|
|
|
@classmethod
|
|
def is_loaded(cls) -> bool:
|
|
return cls._loaded
|
|
|
|
@classmethod
|
|
async def get(
|
|
cls, group_id: str | None, channel_id: str | None = None
|
|
) -> GroupSnapshot | None:
|
|
key = cls._key(group_id, channel_id)
|
|
if not key:
|
|
return None
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
entry = cls._by_key.get(key)
|
|
if entry:
|
|
return entry
|
|
if cls._is_negative(key):
|
|
return None
|
|
cls._mark_negative(key)
|
|
return None
|
|
|
|
@classmethod
|
|
def get_if_ready(
|
|
cls, group_id: str | None, channel_id: str | None = None
|
|
) -> GroupSnapshot | None:
|
|
key = cls._key(group_id, channel_id)
|
|
if not key:
|
|
return None
|
|
if not cls._loaded:
|
|
return None
|
|
entry = cls._by_key.get(key)
|
|
if entry:
|
|
return entry
|
|
if cls._is_negative(key):
|
|
return None
|
|
cls._mark_negative(key)
|
|
return None
|
|
|
|
@classmethod
|
|
async def upsert_from_model(cls, record) -> None:
|
|
entry = GroupSnapshot.from_model(record)
|
|
key = cls._key(entry.group_id, entry.channel_id)
|
|
if not key:
|
|
return
|
|
async with cls._lock:
|
|
cls._by_key[key] = entry
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
RuntimeCacheMutation.publish("group", "upsert", entry.to_payload())
|
|
|
|
@classmethod
|
|
async def upsert_from_payload(cls, payload: dict[str, Any]) -> None:
|
|
entry = GroupSnapshot.from_payload(payload)
|
|
key = cls._key(entry.group_id, entry.channel_id)
|
|
if not key:
|
|
return
|
|
async with cls._lock:
|
|
cls._by_key[key] = entry
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
|
|
@classmethod
|
|
async def remove(cls, group_id: str | None, channel_id: str | None = None) -> None:
|
|
key = cls._key(group_id, channel_id)
|
|
if not key:
|
|
return
|
|
async with cls._lock:
|
|
cls._by_key.pop(key, None)
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
RuntimeCacheMutation.publish(
|
|
"group", "delete", {"group_id": key[0], "channel_id": key[1] or None}
|
|
)
|
|
|
|
@classmethod
|
|
async def apply_sync_event(cls, action: str, data: dict[str, Any]) -> None:
|
|
if action == "upsert":
|
|
await cls.upsert_from_payload(data)
|
|
elif action == "delete":
|
|
await cls.remove(data.get("group_id"), data.get("channel_id"))
|
|
elif action == "refresh":
|
|
await cls.refresh()
|
|
|
|
@classmethod
|
|
async def _refresh_loop(cls, interval: int) -> None:
|
|
while True:
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await cls.refresh()
|
|
except Exception as exc:
|
|
logger.error("group cache refresh failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
def start_tasks(cls) -> None:
|
|
interval = GROUP_MEM_REFRESH_INTERVAL
|
|
if interval <= 0:
|
|
return
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
return
|
|
cls._refresh_task = asyncio.create_task(cls._refresh_loop(interval))
|
|
|
|
@classmethod
|
|
def stop_tasks(cls) -> None:
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
cls._refresh_task.cancel()
|
|
cls._refresh_task = None
|
|
|
|
|
|
class LevelUserMemoryCache:
|
|
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
_by_key: ClassVar[dict[tuple[str, str], LevelUserSnapshot]] = {}
|
|
_by_user_max: ClassVar[dict[str, int]] = {}
|
|
_negative: ClassVar[dict[tuple[str, str], float]] = {}
|
|
_loaded: ClassVar[bool] = False
|
|
_refresh_task: ClassVar[asyncio.Task | None] = None
|
|
_last_refresh: ClassVar[float] = 0.0
|
|
|
|
@classmethod
|
|
def _normalize(cls, value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
value = value.strip()
|
|
return value if value else ""
|
|
|
|
@classmethod
|
|
def _key(cls, user_id: str | None, group_id: str | None) -> tuple[str, str] | None:
|
|
user_id = cls._normalize(user_id)
|
|
if not user_id:
|
|
return None
|
|
group_id = cls._normalize(group_id) or ""
|
|
return (user_id, group_id)
|
|
|
|
@classmethod
|
|
def _negative_ttl(cls) -> int:
|
|
return LEVEL_MEM_NEGATIVE_TTL
|
|
|
|
@classmethod
|
|
def _is_negative(cls, key: tuple[str, str]) -> bool:
|
|
expire_at = cls._negative.get(key)
|
|
if not expire_at:
|
|
return False
|
|
if expire_at <= time.time():
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def _mark_negative(cls, key: tuple[str, str]) -> None:
|
|
ttl = cls._negative_ttl()
|
|
if ttl <= 0:
|
|
return
|
|
cls._negative[key] = time.time() + ttl
|
|
|
|
@classmethod
|
|
async def refresh(cls) -> None:
|
|
from zhenxun.models.level_user import LevelUser
|
|
|
|
async with cls._lock:
|
|
records = await LevelUser.all()
|
|
by_key: dict[tuple[str, str], LevelUserSnapshot] = {}
|
|
by_user_max: dict[str, int] = {}
|
|
for record in records:
|
|
entry = LevelUserSnapshot.from_model(record)
|
|
key = cls._key(entry.user_id, entry.group_id)
|
|
if key:
|
|
by_key[key] = entry
|
|
current = by_user_max.get(entry.user_id, 0)
|
|
if entry.user_level > current:
|
|
by_user_max[entry.user_id] = entry.user_level
|
|
cls._by_key = by_key
|
|
cls._by_user_max = by_user_max
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
RuntimeCacheMutation.mark_refreshed(cls)
|
|
logger.debug(f"level cache refreshed: {len(by_key)} entries", LOG_COMMAND)
|
|
|
|
@classmethod
|
|
async def ensure_loaded(cls) -> None:
|
|
if cls._loaded:
|
|
return
|
|
await RuntimeCacheMutation.ensure_loaded(cls, "level")
|
|
|
|
@classmethod
|
|
def is_loaded(cls) -> bool:
|
|
return cls._loaded
|
|
|
|
@classmethod
|
|
async def ensure_fresh(cls) -> None:
|
|
interval = LEVEL_MEM_REFRESH_INTERVAL
|
|
if not cls._loaded:
|
|
await cls.refresh()
|
|
return
|
|
if interval <= 0:
|
|
return
|
|
if time.time() - cls._last_refresh > interval:
|
|
await cls.refresh()
|
|
|
|
@classmethod
|
|
async def get(
|
|
cls, user_id: str | None, group_id: str | None
|
|
) -> LevelUserSnapshot | None:
|
|
key = cls._key(user_id, group_id)
|
|
if not key:
|
|
return None
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
entry = cls._by_key.get(key)
|
|
if entry:
|
|
return entry
|
|
if cls._is_negative(key):
|
|
return None
|
|
cls._mark_negative(key)
|
|
return None
|
|
|
|
@classmethod
|
|
async def get_levels(
|
|
cls, user_id: str | None, group_id: str | None
|
|
) -> tuple[LevelUserSnapshot | None, LevelUserSnapshot | None]:
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
global_user = None
|
|
group_user = None
|
|
global_key = cls._key(user_id, "")
|
|
if global_key:
|
|
global_user = cls._by_key.get(global_key)
|
|
if group_id:
|
|
group_key = cls._key(user_id, group_id)
|
|
if group_key:
|
|
group_user = cls._by_key.get(group_key)
|
|
return global_user, group_user
|
|
|
|
@classmethod
|
|
def get_levels_if_ready(
|
|
cls, user_id: str | None, group_id: str | None
|
|
) -> tuple[LevelUserSnapshot | None, LevelUserSnapshot | None] | None:
|
|
if not cls._loaded:
|
|
return None
|
|
global_user = None
|
|
group_user = None
|
|
global_key = cls._key(user_id, "")
|
|
if global_key:
|
|
global_user = cls._by_key.get(global_key)
|
|
if group_id:
|
|
group_key = cls._key(user_id, group_id)
|
|
if group_key:
|
|
group_user = cls._by_key.get(group_key)
|
|
return global_user, group_user
|
|
|
|
@classmethod
|
|
async def get_max_level(cls, user_id: str | None) -> int:
|
|
user_id = cls._normalize(user_id)
|
|
if not user_id:
|
|
return 0
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
return cls._by_user_max.get(user_id, 0)
|
|
|
|
@classmethod
|
|
async def upsert_from_model(cls, record) -> None:
|
|
entry = LevelUserSnapshot.from_model(record)
|
|
key = cls._key(entry.user_id, entry.group_id)
|
|
if not key:
|
|
return
|
|
async with cls._lock:
|
|
prev = cls._by_key.get(key)
|
|
cls._by_key[key] = entry
|
|
current = cls._by_user_max.get(entry.user_id, 0)
|
|
if entry.user_level >= current:
|
|
cls._by_user_max[entry.user_id] = entry.user_level
|
|
elif prev and prev.user_level == current and entry.user_level < current:
|
|
cls._recalc_user_max(entry.user_id)
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
RuntimeCacheMutation.publish("level", "upsert", entry.to_payload())
|
|
|
|
@classmethod
|
|
async def upsert_from_payload(cls, payload: dict[str, Any]) -> None:
|
|
entry = LevelUserSnapshot.from_payload(payload)
|
|
key = cls._key(entry.user_id, entry.group_id)
|
|
if not key:
|
|
return
|
|
async with cls._lock:
|
|
prev = cls._by_key.get(key)
|
|
cls._by_key[key] = entry
|
|
current = cls._by_user_max.get(entry.user_id, 0)
|
|
if entry.user_level >= current:
|
|
cls._by_user_max[entry.user_id] = entry.user_level
|
|
elif prev and prev.user_level == current and entry.user_level < current:
|
|
cls._recalc_user_max(entry.user_id)
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
|
|
@classmethod
|
|
async def remove(cls, user_id: str | None, group_id: str | None) -> None:
|
|
key = cls._key(user_id, group_id)
|
|
if not key:
|
|
return
|
|
async with cls._lock:
|
|
removed = cls._by_key.pop(key, None)
|
|
if removed and cls._by_user_max.get(removed.user_id) == removed.user_level:
|
|
cls._recalc_user_max(removed.user_id)
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
RuntimeCacheMutation.publish(
|
|
"level", "delete", {"user_id": key[0], "group_id": key[1] or None}
|
|
)
|
|
|
|
@classmethod
|
|
def _recalc_user_max(cls, user_id: str) -> None:
|
|
max_level = 0
|
|
for entry in cls._by_key.values():
|
|
if entry.user_id == user_id and entry.user_level > max_level:
|
|
max_level = entry.user_level
|
|
if max_level:
|
|
cls._by_user_max[user_id] = max_level
|
|
else:
|
|
cls._by_user_max.pop(user_id, None)
|
|
|
|
@classmethod
|
|
async def apply_sync_event(cls, action: str, data: dict[str, Any]) -> None:
|
|
if action == "upsert":
|
|
await cls.upsert_from_payload(data)
|
|
elif action == "delete":
|
|
await cls.remove(data.get("user_id"), data.get("group_id"))
|
|
elif action == "refresh":
|
|
await cls.refresh()
|
|
|
|
@classmethod
|
|
async def _refresh_loop(cls, interval: int) -> None:
|
|
while True:
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await cls.refresh()
|
|
except Exception as exc:
|
|
logger.error("level cache refresh failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
def start_tasks(cls) -> None:
|
|
interval = LEVEL_MEM_REFRESH_INTERVAL
|
|
if interval <= 0:
|
|
return
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
return
|
|
cls._refresh_task = asyncio.create_task(cls._refresh_loop(interval))
|
|
|
|
@classmethod
|
|
def stop_tasks(cls) -> None:
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
cls._refresh_task.cancel()
|
|
cls._refresh_task = None
|
|
|
|
|
|
class TaskInfoMemoryCache:
|
|
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
_by_module: ClassVar[dict[str, TaskInfoSnapshot]] = {}
|
|
_by_name: ClassVar[dict[str, TaskInfoSnapshot]] = {}
|
|
_negative: ClassVar[dict[str, float]] = {}
|
|
_loaded: ClassVar[bool] = False
|
|
_refresh_task: ClassVar[asyncio.Task | None] = None
|
|
_last_refresh: ClassVar[float] = 0.0
|
|
_last_error: ClassVar[str | None] = None
|
|
|
|
@classmethod
|
|
def _normalize(cls, module: str | None) -> str | None:
|
|
if module is None:
|
|
return None
|
|
module = module.strip()
|
|
return module if module else None
|
|
|
|
@classmethod
|
|
def _negative_ttl(cls) -> int:
|
|
return TASK_MEM_NEGATIVE_TTL
|
|
|
|
@classmethod
|
|
def _is_negative(cls, module: str) -> bool:
|
|
expire_at = cls._negative.get(module)
|
|
if not expire_at:
|
|
return False
|
|
if expire_at <= time.time():
|
|
RuntimeCacheMutation.clear_negative_key(cls, module)
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def _mark_negative(cls, module: str) -> None:
|
|
ttl = cls._negative_ttl()
|
|
if ttl <= 0:
|
|
return
|
|
cls._negative[module] = time.time() + ttl
|
|
|
|
@classmethod
|
|
async def refresh(cls) -> None:
|
|
from zhenxun.models.task_info import TaskInfo
|
|
|
|
async with cls._lock:
|
|
records = await TaskInfo.all()
|
|
by_module: dict[str, TaskInfoSnapshot] = {}
|
|
by_name: dict[str, TaskInfoSnapshot] = {}
|
|
for record in records:
|
|
entry = TaskInfoSnapshot.from_model(record)
|
|
by_module[entry.module] = entry
|
|
if entry.name:
|
|
by_name[entry.name] = entry
|
|
cls._by_module = by_module
|
|
cls._by_name = by_name
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
RuntimeCacheMutation.mark_refreshed(cls)
|
|
logger.debug(
|
|
f"task info cache refreshed: {len(cls._by_module)} entries",
|
|
LOG_COMMAND,
|
|
)
|
|
|
|
@classmethod
|
|
async def ensure_loaded(cls) -> None:
|
|
if cls._loaded:
|
|
return
|
|
await RuntimeCacheMutation.ensure_loaded(cls, "task")
|
|
|
|
@classmethod
|
|
async def get(cls, module: str | None) -> TaskInfoSnapshot | None:
|
|
module = cls._normalize(module)
|
|
if not module:
|
|
return None
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
entry = cls._by_module.get(module)
|
|
if entry:
|
|
return entry
|
|
if cls._is_negative(module):
|
|
return None
|
|
cls._mark_negative(module)
|
|
return None
|
|
|
|
@classmethod
|
|
async def get_by_name(cls, name: str | None) -> TaskInfoSnapshot | None:
|
|
name = (name or "").strip()
|
|
if not name:
|
|
return None
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
return cls._by_name.get(name)
|
|
|
|
@classmethod
|
|
async def get_all(cls) -> list[TaskInfoSnapshot]:
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
return sorted(cls._by_module.values(), key=lambda item: (item.id, item.module))
|
|
|
|
@classmethod
|
|
async def is_disabled(cls, module: str | None) -> bool:
|
|
"""Backward-compatible runtime disabled check for passive tasks."""
|
|
return await cls.is_runtime_disabled(module)
|
|
|
|
@classmethod
|
|
async def is_runtime_disabled(cls, module: str | None) -> bool:
|
|
"""Return whether a passive task is unavailable at runtime.
|
|
|
|
Runtime passive availability is defined by TaskInfo.status and
|
|
TaskInfo.load_status. Bot/group scoped block lists are checked by
|
|
CommonUtils.task_is_block().
|
|
"""
|
|
entry = await cls.get(module)
|
|
if not entry:
|
|
return False
|
|
return not entry.status or not entry.load_status
|
|
|
|
@classmethod
|
|
async def upsert_from_model(cls, record) -> None:
|
|
entry = TaskInfoSnapshot.from_model(record)
|
|
async with cls._lock:
|
|
cls._by_module[entry.module] = entry
|
|
if entry.name:
|
|
cls._by_name[entry.name] = entry
|
|
RuntimeCacheMutation.clear_negative_key(cls, entry.module)
|
|
RuntimeCacheMutation.publish("task", "upsert", entry.to_payload())
|
|
|
|
@classmethod
|
|
async def upsert_from_payload(cls, payload: dict[str, Any]) -> None:
|
|
entry = TaskInfoSnapshot.from_payload(payload)
|
|
if not entry.module:
|
|
return
|
|
async with cls._lock:
|
|
cls._by_module[entry.module] = entry
|
|
if entry.name:
|
|
cls._by_name[entry.name] = entry
|
|
RuntimeCacheMutation.clear_negative_key(cls, entry.module)
|
|
|
|
@classmethod
|
|
async def remove(cls, module: str | None) -> None:
|
|
module = cls._normalize(module)
|
|
if not module:
|
|
return
|
|
async with cls._lock:
|
|
removed = cls._by_module.pop(module, None)
|
|
if removed and removed.name:
|
|
current = cls._by_name.get(removed.name)
|
|
if current and current.module == removed.module:
|
|
cls._by_name.pop(removed.name, None)
|
|
RuntimeCacheMutation.clear_negative_key(cls, module)
|
|
RuntimeCacheMutation.publish("task", "delete", {"module": module})
|
|
|
|
@classmethod
|
|
async def apply_sync_event(cls, action: str, data: dict[str, Any]) -> None:
|
|
if action == "upsert":
|
|
await cls.upsert_from_payload(data)
|
|
elif action == "delete":
|
|
await cls.remove(data.get("module"))
|
|
elif action == "refresh":
|
|
await cls.refresh()
|
|
|
|
@classmethod
|
|
async def _refresh_loop(cls, interval: int) -> None:
|
|
while True:
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await cls.refresh()
|
|
except Exception as exc:
|
|
logger.error("task info cache refresh failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
def start_tasks(cls) -> None:
|
|
interval = TASK_MEM_REFRESH_INTERVAL
|
|
if interval <= 0:
|
|
return
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
return
|
|
cls._refresh_task = asyncio.create_task(cls._refresh_loop(interval))
|
|
|
|
@classmethod
|
|
def stop_tasks(cls) -> None:
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
cls._refresh_task.cancel()
|
|
cls._refresh_task = None
|
|
|
|
|
|
class PluginLimitMemoryCache:
|
|
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
_by_id: ClassVar[dict[int, PluginLimitSnapshot]] = {}
|
|
_by_module: ClassVar[dict[str, list[PluginLimitSnapshot]]] = {}
|
|
_negative: ClassVar[dict[str, float]] = {}
|
|
_loaded: ClassVar[bool] = False
|
|
_refresh_task: ClassVar[asyncio.Task | None] = None
|
|
_last_refresh: ClassVar[float] = 0.0
|
|
_last_error: ClassVar[str | None] = None
|
|
|
|
@classmethod
|
|
def _normalize(cls, value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
value = value.strip()
|
|
return value if value else None
|
|
|
|
@classmethod
|
|
def _negative_ttl(cls) -> int:
|
|
return LIMIT_MEM_NEGATIVE_TTL
|
|
|
|
@classmethod
|
|
def _is_negative(cls, module: str) -> bool:
|
|
expire_at = cls._negative.get(module)
|
|
if not expire_at:
|
|
return False
|
|
if expire_at <= time.time():
|
|
RuntimeCacheMutation.clear_negative_key(cls, module)
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def _mark_negative(cls, module: str) -> None:
|
|
ttl = cls._negative_ttl()
|
|
if ttl <= 0:
|
|
return
|
|
cls._negative[module] = time.time() + ttl
|
|
|
|
@classmethod
|
|
async def refresh(cls) -> None:
|
|
from zhenxun.models.plugin_limit import PluginLimit
|
|
|
|
async with cls._lock:
|
|
records = await PluginLimit.filter(status=True).all()
|
|
by_id: dict[int, PluginLimitSnapshot] = {}
|
|
by_module: dict[str, list[PluginLimitSnapshot]] = {}
|
|
for record in records:
|
|
entry = PluginLimitSnapshot.from_model(record)
|
|
by_id[entry.id] = entry
|
|
by_module.setdefault(entry.module, []).append(entry)
|
|
cls._by_id = by_id
|
|
cls._by_module = by_module
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
RuntimeCacheMutation.mark_refreshed(cls)
|
|
logger.debug(
|
|
f"plugin limit cache refreshed: {len(by_id)} entries",
|
|
LOG_COMMAND,
|
|
)
|
|
|
|
@classmethod
|
|
async def ensure_loaded(cls) -> None:
|
|
if cls._loaded:
|
|
return
|
|
await RuntimeCacheMutation.ensure_loaded(cls, "plugin_limit")
|
|
|
|
@classmethod
|
|
def is_loaded(cls) -> bool:
|
|
return cls._loaded
|
|
|
|
@classmethod
|
|
async def get_limits(cls, module: str) -> list[PluginLimitSnapshot]:
|
|
normalized = cls._normalize(module)
|
|
if not normalized:
|
|
return []
|
|
module = normalized
|
|
if not cls._loaded:
|
|
await cls.ensure_loaded()
|
|
limits = cls._by_module.get(module)
|
|
if limits is not None:
|
|
return limits
|
|
if cls._is_negative(module):
|
|
return []
|
|
cls._mark_negative(module)
|
|
return []
|
|
|
|
@classmethod
|
|
def get_limits_if_ready(cls, module: str) -> list[PluginLimitSnapshot] | None:
|
|
normalized = cls._normalize(module)
|
|
if not normalized:
|
|
return []
|
|
module = normalized
|
|
if not cls._loaded:
|
|
return None
|
|
limits = cls._by_module.get(module)
|
|
if limits is not None:
|
|
return limits
|
|
if cls._is_negative(module):
|
|
return []
|
|
cls._mark_negative(module)
|
|
return []
|
|
|
|
@classmethod
|
|
def get_all_limits(cls) -> list[PluginLimitSnapshot]:
|
|
return list(cls._by_id.values())
|
|
|
|
@classmethod
|
|
async def upsert_from_model(cls, record) -> None:
|
|
entry = PluginLimitSnapshot.from_model(record)
|
|
await cls._upsert_entry(entry)
|
|
RuntimeCacheMutation.publish("plugin_limit", "upsert", entry.to_payload())
|
|
|
|
@classmethod
|
|
async def upsert_from_payload(cls, payload: dict[str, Any]) -> None:
|
|
try:
|
|
entry = PluginLimitSnapshot.from_payload(payload)
|
|
except Exception:
|
|
return
|
|
await cls._upsert_entry(entry)
|
|
|
|
@classmethod
|
|
async def _upsert_entry(cls, entry: PluginLimitSnapshot) -> None:
|
|
async with cls._lock:
|
|
prev = cls._by_id.get(entry.id)
|
|
if prev and prev.module != entry.module:
|
|
cls._by_module[prev.module] = [
|
|
item
|
|
for item in cls._by_module.get(prev.module, [])
|
|
if item.id != prev.id
|
|
]
|
|
if not entry.status:
|
|
cls._by_id.pop(entry.id, None)
|
|
cls._by_module[entry.module] = [
|
|
item
|
|
for item in cls._by_module.get(entry.module, [])
|
|
if item.id != entry.id
|
|
]
|
|
RuntimeCacheMutation.clear_negative_key(cls, entry.module)
|
|
return
|
|
cls._by_id[entry.id] = entry
|
|
module_limits = [
|
|
item
|
|
for item in cls._by_module.get(entry.module, [])
|
|
if item.id != entry.id
|
|
]
|
|
module_limits.append(entry)
|
|
cls._by_module[entry.module] = module_limits
|
|
RuntimeCacheMutation.clear_negative_key(cls, entry.module)
|
|
|
|
@classmethod
|
|
async def remove_by_id(cls, limit_id: int | None) -> None:
|
|
if not limit_id:
|
|
return
|
|
async with cls._lock:
|
|
entry = cls._by_id.pop(limit_id, None)
|
|
if entry:
|
|
cls._by_module[entry.module] = [
|
|
item
|
|
for item in cls._by_module.get(entry.module, [])
|
|
if item.id != entry.id
|
|
]
|
|
RuntimeCacheMutation.clear_negative_key(cls, entry.module)
|
|
RuntimeCacheMutation.publish("plugin_limit", "delete", {"id": int(limit_id)})
|
|
|
|
@classmethod
|
|
async def apply_sync_event(cls, action: str, data: dict[str, Any]) -> None:
|
|
if action == "upsert":
|
|
await cls.upsert_from_payload(data)
|
|
elif action == "delete":
|
|
await cls.remove_by_id(data.get("id"))
|
|
elif action == "refresh":
|
|
await cls.refresh()
|
|
|
|
@classmethod
|
|
async def _refresh_loop(cls, interval: int) -> None:
|
|
while True:
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await cls.refresh()
|
|
except Exception as exc:
|
|
logger.error("plugin limit cache refresh failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
def start_tasks(cls) -> None:
|
|
interval = LIMIT_MEM_REFRESH_INTERVAL
|
|
if interval <= 0:
|
|
return
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
return
|
|
cls._refresh_task = asyncio.create_task(cls._refresh_loop(interval))
|
|
|
|
@classmethod
|
|
def stop_tasks(cls) -> None:
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
cls._refresh_task.cancel()
|
|
cls._refresh_task = None
|
|
|
|
|
|
class BanMemoryCache:
|
|
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
_by_user: ClassVar[dict[str, BanEntry]] = {}
|
|
_by_group: ClassVar[dict[str, BanEntry]] = {}
|
|
_by_user_group: ClassVar[dict[tuple[str, str], BanEntry]] = {}
|
|
_negative: ClassVar[dict[tuple[str | None, str | None], float]] = {}
|
|
_loaded: ClassVar[bool] = False
|
|
_refresh_task: ClassVar[asyncio.Task | None] = None
|
|
_cleanup_task: ClassVar[asyncio.Task | None] = None
|
|
_remove_tasks: ClassVar[set[asyncio.Task]] = set()
|
|
_last_refresh: ClassVar[float] = 0.0
|
|
_last_error: ClassVar[str | None] = None
|
|
|
|
@classmethod
|
|
def _normalize_id(cls, value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
value = value.strip()
|
|
return value if value else None
|
|
|
|
@classmethod
|
|
def _neg_ttl(cls) -> int:
|
|
return BAN_MEM_NEGATIVE_TTL
|
|
|
|
@classmethod
|
|
def _neg_key(
|
|
cls, user_id: str | None, group_id: str | None
|
|
) -> tuple[str | None, str | None]:
|
|
return (cls._normalize_id(user_id), cls._normalize_id(group_id))
|
|
|
|
@classmethod
|
|
def _is_negative(cls, key: tuple[str | None, str | None]) -> bool:
|
|
expire_at = cls._negative.get(key)
|
|
if not expire_at:
|
|
return False
|
|
if expire_at <= time.time():
|
|
RuntimeCacheMutation.clear_negative_key(cls, key)
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def _mark_negative(cls, key: tuple[str | None, str | None]) -> None:
|
|
ttl = cls._neg_ttl()
|
|
if ttl <= 0:
|
|
return
|
|
cls._negative[key] = time.time() + ttl
|
|
|
|
@classmethod
|
|
def _build_entry(cls, record) -> BanEntry | None:
|
|
user_id = cls._normalize_id(record.user_id)
|
|
group_id = cls._normalize_id(record.group_id)
|
|
duration = int(record.duration)
|
|
if duration == -1:
|
|
expire_at = None
|
|
else:
|
|
expire_at = float(record.ban_time + duration)
|
|
return BanEntry(
|
|
user_id=user_id,
|
|
group_id=group_id,
|
|
ban_level=int(record.ban_level),
|
|
ban_time=int(record.ban_time),
|
|
duration=duration,
|
|
expire_at=expire_at,
|
|
)
|
|
|
|
@classmethod
|
|
async def refresh(cls) -> None:
|
|
from zhenxun.models.ban_console import BanConsole
|
|
|
|
async with cls._lock:
|
|
now_ts = time.time()
|
|
records = await BanConsole.all()
|
|
by_user: dict[str, BanEntry] = {}
|
|
by_group: dict[str, BanEntry] = {}
|
|
by_user_group: dict[tuple[str, str], BanEntry] = {}
|
|
for record in records:
|
|
entry = cls._build_entry(record)
|
|
if not entry:
|
|
continue
|
|
if entry.expire_at is not None and entry.expire_at <= now_ts:
|
|
continue
|
|
if entry.user_id and entry.group_id:
|
|
by_user_group[(entry.user_id, entry.group_id)] = entry
|
|
elif entry.user_id:
|
|
by_user[entry.user_id] = entry
|
|
elif entry.group_id:
|
|
by_group[entry.group_id] = entry
|
|
cls._by_user = by_user
|
|
cls._by_group = by_group
|
|
cls._by_user_group = by_user_group
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
RuntimeCacheMutation.mark_refreshed(cls)
|
|
logger.debug(
|
|
"ban cache refreshed: "
|
|
f"user={len(by_user)} group={len(by_group)} "
|
|
f"user_group={len(by_user_group)}",
|
|
LOG_COMMAND,
|
|
)
|
|
|
|
@classmethod
|
|
async def ensure_loaded(cls) -> None:
|
|
if cls._loaded:
|
|
return
|
|
await RuntimeCacheMutation.ensure_loaded(cls, "ban")
|
|
|
|
@classmethod
|
|
def is_loaded(cls) -> bool:
|
|
return cls._loaded
|
|
|
|
@classmethod
|
|
async def upsert_from_model(cls, record) -> None:
|
|
entry = cls._build_entry(record)
|
|
if not entry:
|
|
return
|
|
async with cls._lock:
|
|
if entry.user_id and entry.group_id:
|
|
cls._by_user_group[(entry.user_id, entry.group_id)] = entry
|
|
elif entry.user_id:
|
|
cls._by_user[entry.user_id] = entry
|
|
elif entry.group_id:
|
|
cls._by_group[entry.group_id] = entry
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
RuntimeCacheMutation.publish("ban", "upsert", entry.to_payload())
|
|
|
|
@classmethod
|
|
async def remove(cls, user_id: str | None, group_id: str | None) -> None:
|
|
await cls._remove_local(user_id, group_id)
|
|
RuntimeCacheMutation.publish(
|
|
"ban", "delete", {"user_id": user_id, "group_id": group_id}
|
|
)
|
|
|
|
@classmethod
|
|
async def _remove_local(cls, user_id: str | None, group_id: str | None) -> None:
|
|
user_id = cls._normalize_id(user_id)
|
|
group_id = cls._normalize_id(group_id)
|
|
async with cls._lock:
|
|
if user_id and group_id:
|
|
cls._by_user_group.pop((user_id, group_id), None)
|
|
elif user_id:
|
|
cls._by_user.pop(user_id, None)
|
|
elif group_id:
|
|
cls._by_group.pop(group_id, None)
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
|
|
@classmethod
|
|
def _get_entry(cls, user_id: str | None, group_id: str | None) -> BanEntry | None:
|
|
user_id = cls._normalize_id(user_id)
|
|
group_id = cls._normalize_id(group_id)
|
|
if user_id and group_id:
|
|
entry = cls._by_user_group.get((user_id, group_id))
|
|
if entry:
|
|
return entry
|
|
entry = cls._by_user.get(user_id)
|
|
if entry:
|
|
return entry
|
|
return None
|
|
if user_id:
|
|
return cls._by_user.get(user_id)
|
|
if group_id:
|
|
return cls._by_group.get(group_id)
|
|
return None
|
|
|
|
@classmethod
|
|
def is_banned(cls, user_id: str | None, group_id: str | None) -> bool:
|
|
if not cls._loaded:
|
|
return False
|
|
neg_key = cls._neg_key(user_id, group_id)
|
|
if cls._is_negative(neg_key):
|
|
return False
|
|
entry = cls._get_entry(user_id, group_id)
|
|
if not entry:
|
|
cls._mark_negative(neg_key)
|
|
return False
|
|
remaining = entry.remaining()
|
|
if remaining == 0 and entry.duration != -1:
|
|
task = asyncio.create_task(cls.remove(entry.user_id, entry.group_id))
|
|
cls._remove_tasks.add(task)
|
|
task.add_done_callback(cls._remove_tasks.discard)
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def remaining_time(cls, user_id: str | None, group_id: str | None) -> int:
|
|
if not cls._loaded:
|
|
return 0
|
|
neg_key = cls._neg_key(user_id, group_id)
|
|
if cls._is_negative(neg_key):
|
|
return 0
|
|
entry = cls._get_entry(user_id, group_id)
|
|
if not entry:
|
|
cls._mark_negative(neg_key)
|
|
return 0
|
|
remaining = entry.remaining()
|
|
if remaining == 0 and entry.duration != -1:
|
|
task = asyncio.create_task(cls.remove(entry.user_id, entry.group_id))
|
|
cls._remove_tasks.add(task)
|
|
task.add_done_callback(cls._remove_tasks.discard)
|
|
return 0
|
|
return remaining
|
|
|
|
@classmethod
|
|
def check_ban_level(
|
|
cls, user_id: str | None, group_id: str | None, level: int
|
|
) -> bool:
|
|
if not cls._loaded:
|
|
return False
|
|
neg_key = cls._neg_key(user_id, group_id)
|
|
if cls._is_negative(neg_key):
|
|
return False
|
|
entry = cls._get_entry(user_id, group_id)
|
|
if not entry:
|
|
cls._mark_negative(neg_key)
|
|
return False
|
|
remaining = entry.remaining()
|
|
if remaining == 0 and entry.duration != -1:
|
|
task = asyncio.create_task(cls.remove(entry.user_id, entry.group_id))
|
|
cls._remove_tasks.add(task)
|
|
task.add_done_callback(cls._remove_tasks.discard)
|
|
return False
|
|
return entry.ban_level <= level
|
|
|
|
@classmethod
|
|
async def cleanup_expired(cls, delete_db: bool = True) -> None:
|
|
now_ts = time.time()
|
|
expired: list[BanEntry] = []
|
|
async with cls._lock:
|
|
for entry in list(cls._by_user.values()):
|
|
if entry.expire_at is not None and entry.expire_at <= now_ts:
|
|
expired.append(entry)
|
|
for entry in list(cls._by_group.values()):
|
|
if entry.expire_at is not None and entry.expire_at <= now_ts:
|
|
expired.append(entry)
|
|
for entry in list(cls._by_user_group.values()):
|
|
if entry.expire_at is not None and entry.expire_at <= now_ts:
|
|
expired.append(entry)
|
|
for entry in expired:
|
|
if entry.user_id and entry.group_id:
|
|
cls._by_user_group.pop((entry.user_id, entry.group_id), None)
|
|
elif entry.user_id:
|
|
cls._by_user.pop(entry.user_id, None)
|
|
elif entry.group_id:
|
|
cls._by_group.pop(entry.group_id, None)
|
|
if expired:
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
if not delete_db or not expired:
|
|
return
|
|
from tortoise.expressions import Q
|
|
|
|
from zhenxun.models.ban_console import BanConsole
|
|
|
|
for entry in expired:
|
|
query = BanConsole.filter()
|
|
if entry.user_id:
|
|
query = query.filter(user_id=entry.user_id)
|
|
else:
|
|
query = query.filter(Q(user_id__isnull=True) | Q(user_id=""))
|
|
if entry.group_id:
|
|
query = query.filter(group_id=entry.group_id)
|
|
else:
|
|
query = query.filter(Q(group_id__isnull=True) | Q(group_id=""))
|
|
await query.delete()
|
|
|
|
@classmethod
|
|
async def _refresh_loop(cls, interval: int) -> None:
|
|
while True:
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await cls.refresh()
|
|
except Exception as exc:
|
|
logger.error("ban cache refresh failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
async def _cleanup_loop(cls, interval: int, delete_db: bool) -> None:
|
|
while True:
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await cls.cleanup_expired(delete_db=delete_db)
|
|
except Exception as exc:
|
|
logger.error("ban cache cleanup failed", LOG_COMMAND, e=exc)
|
|
|
|
@classmethod
|
|
def start_tasks(cls) -> None:
|
|
refresh_interval = BAN_MEM_REFRESH_INTERVAL
|
|
clean_interval = BAN_MEM_CLEAN_INTERVAL
|
|
cleanup_db = BAN_MEM_CLEANUP_DB
|
|
|
|
if refresh_interval > 0 and (not cls._refresh_task or cls._refresh_task.done()):
|
|
cls._refresh_task = asyncio.create_task(cls._refresh_loop(refresh_interval))
|
|
if clean_interval > 0 and (not cls._cleanup_task or cls._cleanup_task.done()):
|
|
cls._cleanup_task = asyncio.create_task(
|
|
cls._cleanup_loop(clean_interval, cleanup_db)
|
|
)
|
|
|
|
@classmethod
|
|
def stop_tasks(cls) -> None:
|
|
if cls._refresh_task and not cls._refresh_task.done():
|
|
cls._refresh_task.cancel()
|
|
if cls._cleanup_task and not cls._cleanup_task.done():
|
|
cls._cleanup_task.cancel()
|
|
cls._refresh_task = None
|
|
cls._cleanup_task = None
|
|
|
|
@classmethod
|
|
async def upsert_from_payload(cls, payload: dict[str, Any]) -> None:
|
|
entry = BanEntry.from_payload(payload)
|
|
async with cls._lock:
|
|
if entry.user_id and entry.group_id:
|
|
cls._by_user_group[(entry.user_id, entry.group_id)] = entry
|
|
elif entry.user_id:
|
|
cls._by_user[entry.user_id] = entry
|
|
elif entry.group_id:
|
|
cls._by_group[entry.group_id] = entry
|
|
RuntimeCacheMutation.clear_negative_all(cls)
|
|
|
|
@classmethod
|
|
async def apply_sync_event(cls, action: str, data: dict[str, Any]) -> None:
|
|
if action == "upsert":
|
|
await cls.upsert_from_payload(data)
|
|
elif action == "delete":
|
|
await cls._remove_local(data.get("user_id"), data.get("group_id"))
|
|
elif action == "refresh":
|
|
await cls.refresh()
|
|
|
|
|
|
async def _safe_refresh(cache_cls: type, label: str) -> None:
|
|
"""安全地刷新单个缓存,异常不影响其他缓存。"""
|
|
if getattr(cache_cls, "_loaded", False):
|
|
last_refresh = float(getattr(cache_cls, "_last_refresh", 0.0) or 0.0)
|
|
if time.time() - last_refresh <= RUNTIME_CACHE_STARTUP_REFRESH_SKIP_SECONDS:
|
|
logger.debug(f"{label} cache startup refresh skipped", LOG_COMMAND)
|
|
return
|
|
try:
|
|
await cache_cls.refresh()
|
|
except Exception as exc:
|
|
RuntimeCacheMutation.mark_error(cache_cls, exc)
|
|
logger.error(f"{label} cache init failed", LOG_COMMAND, e=exc)
|
|
|
|
|
|
def _cache_health(
|
|
cache_cls: type,
|
|
*,
|
|
entry_count: int,
|
|
negative_count: int = 0,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"loaded": bool(getattr(cache_cls, "_loaded", False)),
|
|
"entry_count": entry_count,
|
|
"last_refresh": float(getattr(cache_cls, "_last_refresh", 0.0) or 0.0),
|
|
"negative_count": negative_count,
|
|
"last_error": getattr(cache_cls, "_last_error", None),
|
|
}
|
|
|
|
|
|
def health_snapshot() -> dict[str, dict[str, Any]]:
|
|
"""Return in-memory runtime cache health without touching the database."""
|
|
return {
|
|
"plugin": _cache_health(
|
|
PluginInfoMemoryCache,
|
|
entry_count=len(PluginInfoMemoryCache._by_module),
|
|
),
|
|
"bot": _cache_health(
|
|
BotMemoryCache,
|
|
entry_count=len(BotMemoryCache._by_id),
|
|
negative_count=len(BotMemoryCache._negative),
|
|
),
|
|
"group": _cache_health(
|
|
GroupMemoryCache,
|
|
entry_count=len(GroupMemoryCache._by_key),
|
|
negative_count=len(GroupMemoryCache._negative),
|
|
),
|
|
"level": _cache_health(
|
|
LevelUserMemoryCache,
|
|
entry_count=len(LevelUserMemoryCache._by_key),
|
|
negative_count=len(LevelUserMemoryCache._negative),
|
|
),
|
|
"task": _cache_health(
|
|
TaskInfoMemoryCache,
|
|
entry_count=len(TaskInfoMemoryCache._by_module),
|
|
negative_count=len(TaskInfoMemoryCache._negative),
|
|
),
|
|
"plugin_limit": _cache_health(
|
|
PluginLimitMemoryCache,
|
|
entry_count=len(PluginLimitMemoryCache._by_id),
|
|
negative_count=len(PluginLimitMemoryCache._negative),
|
|
),
|
|
"ban": _cache_health(
|
|
BanMemoryCache,
|
|
entry_count=(
|
|
len(BanMemoryCache._by_user)
|
|
+ len(BanMemoryCache._by_group)
|
|
+ len(BanMemoryCache._by_user_group)
|
|
),
|
|
negative_count=len(BanMemoryCache._negative),
|
|
),
|
|
}
|
|
|
|
|
|
def passive_status_snapshot(max_modules: int = 50) -> dict[str, Any]:
|
|
"""Return passive-task state from in-memory caches only.
|
|
|
|
This is a local diagnostic helper: it does not query or write the database,
|
|
and it is not used by runtime decisions.
|
|
"""
|
|
tasks = list(TaskInfoMemoryCache._by_module.values())
|
|
disabled = sorted(task.module for task in tasks if not task.status)
|
|
unloaded = sorted(task.module for task in tasks if not task.load_status)
|
|
runtime_enabled = [
|
|
task.module for task in tasks if task.status and task.load_status
|
|
]
|
|
bot_block_total = sum(
|
|
len(_parse_block_modules(bot.block_tasks))
|
|
for bot in BotMemoryCache._by_id.values()
|
|
)
|
|
group_block_total = sum(
|
|
len(group.block_task_set) + len(group.superuser_block_task_set)
|
|
for group in GroupMemoryCache._by_key.values()
|
|
)
|
|
return {
|
|
"cache": health_snapshot(),
|
|
"passive_tasks": {
|
|
"total": len(tasks),
|
|
"status_enabled": sum(1 for task in tasks if task.status),
|
|
"load_status_enabled": sum(1 for task in tasks if task.load_status),
|
|
"runtime_enabled": len(runtime_enabled),
|
|
"disabled_modules": disabled[:max_modules],
|
|
"disabled_modules_total": len(disabled),
|
|
"unloaded_modules": unloaded[:max_modules],
|
|
"unloaded_modules_total": len(unloaded),
|
|
},
|
|
"scoped_blocks": {
|
|
"bot_block_tasks_total": bot_block_total,
|
|
"group_block_tasks_total": group_block_total,
|
|
},
|
|
"semantics": {
|
|
"available_tasks": "management_display_mirror_not_runtime_whitelist",
|
|
"runtime_truth": [
|
|
"TaskInfo.status",
|
|
"TaskInfo.load_status",
|
|
"BotConsole.block_tasks",
|
|
"GroupConsole.block_task",
|
|
"GroupConsole.superuser_block_task",
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
@PriorityLifecycle.on_startup(priority=6)
|
|
async def _init_runtime_cache():
|
|
await RuntimeCacheSync.start()
|
|
# 并发刷新所有缓存,互不依赖
|
|
await asyncio.gather(
|
|
_safe_refresh(PluginInfoMemoryCache, "plugin"),
|
|
_safe_refresh(BotMemoryCache, "bot"),
|
|
_safe_refresh(GroupMemoryCache, "group"),
|
|
_safe_refresh(LevelUserMemoryCache, "level"),
|
|
_safe_refresh(TaskInfoMemoryCache, "task info"),
|
|
_safe_refresh(PluginLimitMemoryCache, "plugin limit"),
|
|
_safe_refresh(BanMemoryCache, "ban"),
|
|
)
|
|
PluginInfoMemoryCache.start_refresh_task()
|
|
BotMemoryCache.start_tasks()
|
|
GroupMemoryCache.start_tasks()
|
|
LevelUserMemoryCache.start_tasks()
|
|
TaskInfoMemoryCache.start_tasks()
|
|
PluginLimitMemoryCache.start_tasks()
|
|
BanMemoryCache.start_tasks()
|
|
_CACHE_READY_EVENT.set()
|
|
|
|
|
|
@PriorityLifecycle.on_shutdown(priority=6)
|
|
async def _stop_runtime_cache():
|
|
PluginInfoMemoryCache.stop_tasks()
|
|
BotMemoryCache.stop_tasks()
|
|
GroupMemoryCache.stop_tasks()
|
|
LevelUserMemoryCache.stop_tasks()
|
|
TaskInfoMemoryCache.stop_tasks()
|
|
PluginLimitMemoryCache.stop_tasks()
|
|
BanMemoryCache.stop_tasks()
|
|
await RuntimeCacheSync.stop()
|