mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-03 19:00:00 +08:00
* bugfix:修复notice事件扩散问题 * 优化并发调度 * bugfix:修复签到样式 * bugfix:功能调用统计修复 * bugfix:修复私聊时功能调用统计显示已退群问题 * 提高插件适配兼容性 * 优化发送队列 * 修改权限检查设计 * 继续修改权限检查设计 * 完善权限检查设计 * 优化sqlite配置 * 优化数据库初始化 * 代码整理,无用代码清理 * bugfix:修复启动时数据库校验问题 * bugfix:修复预算裁剪过于激进问题
548 lines
19 KiB
Python
548 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections import deque
|
|
import contextlib
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
import json
|
|
import random
|
|
import time
|
|
from typing import Any, TypeVar
|
|
|
|
from tortoise import Tortoise
|
|
|
|
from zhenxun.builtin_plugins.hooks.auth_runtime_config import (
|
|
AUTH_OBSERVABILITY_RUNTIME_CONFIG,
|
|
)
|
|
from zhenxun.models.auth_decision_log import AuthDecisionLog
|
|
from zhenxun.models.runtime_backpressure_log import RuntimeBackpressureLog
|
|
from zhenxun.services.log import logger
|
|
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
|
|
|
LOG_COMMAND = "AuthObservability"
|
|
|
|
_BUFFER_MAX_RETAIN = AUTH_OBSERVABILITY_RUNTIME_CONFIG.buffer_max_retain
|
|
_FLUSH_TRIGGER_SIZE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.flush_trigger_size
|
|
_FLUSH_BATCH_SIZE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.flush_batch_size
|
|
_FLUSH_INTERVAL_SECONDS = AUTH_OBSERVABILITY_RUNTIME_CONFIG.flush_interval_seconds
|
|
_DROP_LOG_INTERVAL_SECONDS = AUTH_OBSERVABILITY_RUNTIME_CONFIG.drop_log_interval_seconds
|
|
_ALLOW_SAMPLE_RATE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.allow_sample_rate
|
|
_OVERLOADED_ALLOW_SAMPLE_RATE = (
|
|
AUTH_OBSERVABILITY_RUNTIME_CONFIG.overloaded_allow_sample_rate
|
|
)
|
|
_NON_ALLOW_SAMPLE_RATE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.non_allow_sample_rate
|
|
_BACKPRESSURE_SAMPLE_RATE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.backpressure_sample_rate
|
|
_BACKPRESSURE_SEVERE_ACTIVE_THRESHOLD = (
|
|
AUTH_OBSERVABILITY_RUNTIME_CONFIG.backpressure_severe_active_threshold
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AuthDecisionLogRecord:
|
|
bot_id: str | None
|
|
platform: str | None
|
|
group_id: str | None
|
|
user_id: str | None
|
|
module: str | None
|
|
effect: str
|
|
reason: str | None = None
|
|
shadow_effect: str | None = None
|
|
shadow_reason: str | None = None
|
|
side_effect_state: dict[str, Any] | None = None
|
|
latency_ms: float = 0.0
|
|
overloaded: bool = False
|
|
|
|
def to_model(self) -> AuthDecisionLog:
|
|
return AuthDecisionLog(
|
|
bot_id=self.bot_id,
|
|
platform=self.platform,
|
|
group_id=self.group_id,
|
|
user_id=self.user_id,
|
|
module=self.module,
|
|
effect=self.effect,
|
|
reason=self.reason,
|
|
shadow_effect=self.shadow_effect,
|
|
shadow_reason=self.shadow_reason,
|
|
side_effect_state=json.dumps(
|
|
self.side_effect_state,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
)[:4000]
|
|
if self.side_effect_state
|
|
else None,
|
|
latency_ms=self.latency_ms,
|
|
overloaded=self.overloaded,
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RuntimeBackpressureLogRecord:
|
|
scope_key: str | None
|
|
reason: str | None
|
|
lane: str | None
|
|
action: str
|
|
queue_size: int = 0
|
|
active_count: int = 0
|
|
duration_ms: float = 0.0
|
|
|
|
def to_model(self) -> RuntimeBackpressureLog:
|
|
return RuntimeBackpressureLog(
|
|
scope_key=self.scope_key,
|
|
reason=self.reason,
|
|
lane=self.lane,
|
|
action=self.action,
|
|
queue_size=self.queue_size,
|
|
active_count=self.active_count,
|
|
duration_ms=self.duration_ms,
|
|
)
|
|
|
|
|
|
_auth_decision_buffer: deque[AuthDecisionLogRecord] = deque()
|
|
_backpressure_buffer: deque[RuntimeBackpressureLogRecord] = deque()
|
|
_buffer_lock = asyncio.Lock()
|
|
_flush_lock = asyncio.Lock()
|
|
_flush_task: asyncio.Task[None] | None = None
|
|
_dropped = 0
|
|
_last_drop_log_at = 0.0
|
|
_last_schema_repair_at = 0.0
|
|
_SCHEMA_REPAIR_INTERVAL_SECONDS = 300.0
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
def _ensure_flush_task() -> None:
|
|
global _flush_task
|
|
if _flush_task is not None and not _flush_task.done():
|
|
return
|
|
_flush_task = asyncio.create_task(_flush_loop())
|
|
|
|
|
|
def _record_drop() -> None:
|
|
global _dropped, _last_drop_log_at
|
|
_dropped += 1
|
|
now = time.monotonic()
|
|
if now - _last_drop_log_at < _DROP_LOG_INTERVAL_SECONDS:
|
|
return
|
|
_last_drop_log_at = now
|
|
logger.warning(
|
|
"auth observability buffer full, dropped "
|
|
f"{_dropped} records, auth_backlog={len(_auth_decision_buffer)}, "
|
|
f"backpressure_backlog={len(_backpressure_buffer)}",
|
|
LOG_COMMAND,
|
|
)
|
|
|
|
|
|
def _sample(rate: float) -> bool:
|
|
if rate >= 1:
|
|
return True
|
|
if rate <= 0:
|
|
return False
|
|
return random.random() < rate
|
|
|
|
|
|
def _auth_decision_sample_rate(effect: str, overloaded: bool) -> float:
|
|
if effect != "allow":
|
|
return _NON_ALLOW_SAMPLE_RATE
|
|
if overloaded:
|
|
return _OVERLOADED_ALLOW_SAMPLE_RATE
|
|
return _ALLOW_SAMPLE_RATE
|
|
|
|
|
|
def _backpressure_sample_rate(record: RuntimeBackpressureLogRecord) -> float:
|
|
if record.reason and record.reason.startswith("hooks_"):
|
|
return 1.0
|
|
if record.active_count >= _BACKPRESSURE_SEVERE_ACTIVE_THRESHOLD:
|
|
return 1.0
|
|
if record.action in {"skip", "defer"}:
|
|
return _BACKPRESSURE_SAMPLE_RATE
|
|
return min(_BACKPRESSURE_SAMPLE_RATE, 0.02)
|
|
|
|
|
|
async def _append_auth_decision_record(record: AuthDecisionLogRecord) -> None:
|
|
_ensure_flush_task()
|
|
async with _buffer_lock:
|
|
total = len(_auth_decision_buffer) + len(_backpressure_buffer)
|
|
if total >= _BUFFER_MAX_RETAIN:
|
|
if len(_auth_decision_buffer) >= len(_backpressure_buffer):
|
|
with contextlib.suppress(IndexError):
|
|
_auth_decision_buffer.popleft()
|
|
else:
|
|
with contextlib.suppress(IndexError):
|
|
_backpressure_buffer.popleft()
|
|
_record_drop()
|
|
_auth_decision_buffer.append(record)
|
|
should_flush = (
|
|
len(_auth_decision_buffer) + len(_backpressure_buffer)
|
|
>= _FLUSH_TRIGGER_SIZE
|
|
and not _flush_lock.locked()
|
|
)
|
|
if should_flush:
|
|
# Fire-and-forget keeps auth hot path independent of database stalls.
|
|
asyncio.create_task(flush_auth_observability_buffer("缓冲区触发")) # noqa: RUF006
|
|
|
|
|
|
async def _append_backpressure_record(record: RuntimeBackpressureLogRecord) -> None:
|
|
_ensure_flush_task()
|
|
async with _buffer_lock:
|
|
total = len(_auth_decision_buffer) + len(_backpressure_buffer)
|
|
if total >= _BUFFER_MAX_RETAIN:
|
|
if len(_auth_decision_buffer) >= len(_backpressure_buffer):
|
|
with contextlib.suppress(IndexError):
|
|
_auth_decision_buffer.popleft()
|
|
else:
|
|
with contextlib.suppress(IndexError):
|
|
_backpressure_buffer.popleft()
|
|
_record_drop()
|
|
_backpressure_buffer.append(record)
|
|
should_flush = (
|
|
len(_auth_decision_buffer) + len(_backpressure_buffer)
|
|
>= _FLUSH_TRIGGER_SIZE
|
|
and not _flush_lock.locked()
|
|
)
|
|
if should_flush:
|
|
# Fire-and-forget keeps auth hot path independent of database stalls.
|
|
asyncio.create_task(flush_auth_observability_buffer("缓冲区触发")) # noqa: RUF006
|
|
|
|
|
|
async def append_auth_decision_log(
|
|
*,
|
|
bot_id: str | None,
|
|
platform: str | None,
|
|
group_id: str | None,
|
|
user_id: str | None,
|
|
module: str | None,
|
|
effect: str,
|
|
reason: str | None = None,
|
|
shadow_effect: str | None = None,
|
|
shadow_reason: str | None = None,
|
|
side_effect_state: dict[str, Any] | None = None,
|
|
latency_ms: float = 0.0,
|
|
overloaded: bool = False,
|
|
) -> None:
|
|
if shadow_effect is None and not _sample(
|
|
_auth_decision_sample_rate(effect, overloaded)
|
|
):
|
|
return
|
|
record = AuthDecisionLogRecord(
|
|
bot_id=bot_id,
|
|
platform=platform,
|
|
group_id=group_id,
|
|
user_id=user_id,
|
|
module=module,
|
|
effect=effect,
|
|
reason=(reason or "")[:255] or None,
|
|
shadow_effect=(shadow_effect or "")[:32] or None,
|
|
shadow_reason=(shadow_reason or "")[:255] or None,
|
|
side_effect_state=side_effect_state,
|
|
latency_ms=latency_ms,
|
|
overloaded=overloaded,
|
|
)
|
|
await _append_auth_decision_record(record)
|
|
|
|
|
|
async def append_runtime_backpressure_log(
|
|
*,
|
|
scope_key: str | None,
|
|
reason: str | None,
|
|
lane: str | None,
|
|
action: str,
|
|
queue_size: int = 0,
|
|
active_count: int = 0,
|
|
duration_ms: float = 0.0,
|
|
) -> None:
|
|
record = RuntimeBackpressureLogRecord(
|
|
scope_key=(scope_key or "")[:255] or None,
|
|
reason=(reason or "")[:255] or None,
|
|
lane=(lane or "")[:64] or None,
|
|
action=action,
|
|
queue_size=queue_size,
|
|
active_count=active_count,
|
|
duration_ms=duration_ms,
|
|
)
|
|
if not _sample(_backpressure_sample_rate(record)):
|
|
return
|
|
await _append_backpressure_record(record)
|
|
|
|
|
|
async def _flush_loop() -> None:
|
|
while True:
|
|
await asyncio.sleep(_FLUSH_INTERVAL_SECONDS)
|
|
try:
|
|
await flush_auth_observability_buffer("定时")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.warning("定时批量写入权限观测日志失败", LOG_COMMAND, e=exc)
|
|
|
|
|
|
async def _drain_batch(buffer: deque[T]) -> list[T]:
|
|
batch: list[T] = []
|
|
async with _buffer_lock:
|
|
while buffer and len(batch) < _FLUSH_BATCH_SIZE:
|
|
batch.append(buffer.popleft())
|
|
return batch
|
|
|
|
|
|
async def _restore_batch(buffer: deque[T], batch: list[T]) -> None:
|
|
async with _buffer_lock:
|
|
retain_count = max(_BUFFER_MAX_RETAIN - len(buffer), 0)
|
|
for record in reversed(batch[-retain_count:]):
|
|
buffer.appendleft(record)
|
|
|
|
|
|
def _is_schema_mismatch_error(exc: Exception) -> bool:
|
|
message = str(exc).lower()
|
|
return any(
|
|
marker in message
|
|
for marker in (
|
|
"no column named",
|
|
"unknown column",
|
|
"column does not exist",
|
|
"no such column",
|
|
)
|
|
)
|
|
|
|
|
|
async def _try_repair_auth_schema_once() -> bool:
|
|
global _last_schema_repair_at
|
|
now = time.monotonic()
|
|
if now - _last_schema_repair_at < _SCHEMA_REPAIR_INTERVAL_SECONDS:
|
|
return False
|
|
_last_schema_repair_at = now
|
|
try:
|
|
from zhenxun.services.db_context.schema_guard import repair_table_schema
|
|
|
|
await repair_table_schema("auth_decision_log")
|
|
await repair_table_schema("runtime_backpressure_log")
|
|
return True
|
|
except Exception as exc:
|
|
logger.warning("权限观测日志表结构自修复失败", LOG_COMMAND, e=exc)
|
|
return False
|
|
|
|
|
|
async def flush_auth_observability_buffer(reason: str) -> int:
|
|
async with _flush_lock:
|
|
written = 0
|
|
while True:
|
|
auth_batch = await _drain_batch(_auth_decision_buffer)
|
|
backpressure_batch = await _drain_batch(_backpressure_buffer)
|
|
if not auth_batch and not backpressure_batch:
|
|
break
|
|
try:
|
|
if auth_batch:
|
|
await AuthDecisionLog.bulk_create(
|
|
[record.to_model() for record in auth_batch],
|
|
_FLUSH_BATCH_SIZE,
|
|
)
|
|
written += len(auth_batch)
|
|
if backpressure_batch:
|
|
await RuntimeBackpressureLog.bulk_create(
|
|
[record.to_model() for record in backpressure_batch],
|
|
_FLUSH_BATCH_SIZE,
|
|
)
|
|
written += len(backpressure_batch)
|
|
except Exception as exc:
|
|
if _is_schema_mismatch_error(exc):
|
|
if await _try_repair_auth_schema_once():
|
|
try:
|
|
if auth_batch:
|
|
await AuthDecisionLog.bulk_create(
|
|
[record.to_model() for record in auth_batch],
|
|
_FLUSH_BATCH_SIZE,
|
|
)
|
|
written += len(auth_batch)
|
|
if backpressure_batch:
|
|
await RuntimeBackpressureLog.bulk_create(
|
|
[
|
|
record.to_model()
|
|
for record in backpressure_batch
|
|
],
|
|
_FLUSH_BATCH_SIZE,
|
|
)
|
|
written += len(backpressure_batch)
|
|
continue
|
|
except Exception as retry_exc:
|
|
exc = retry_exc
|
|
dropped = len(auth_batch) + len(backpressure_batch)
|
|
logger.warning(
|
|
f"{reason}批量写入权限观测日志遇到表结构不匹配,"
|
|
f"已丢弃低优先级观测日志 {dropped} 条,等待下次启动修复",
|
|
LOG_COMMAND,
|
|
e=exc,
|
|
)
|
|
return written
|
|
await _restore_batch(_auth_decision_buffer, auth_batch)
|
|
await _restore_batch(_backpressure_buffer, backpressure_batch)
|
|
logger.error(f"{reason}批量写入权限观测日志失败", LOG_COMMAND, e=exc)
|
|
return written
|
|
if written:
|
|
logger.debug(f"{reason}批量写入权限观测日志 {written} 条", LOG_COMMAND)
|
|
return written
|
|
|
|
|
|
async def stop_auth_observability_buffer() -> int:
|
|
global _flush_task
|
|
task = _flush_task
|
|
_flush_task = None
|
|
if task is not None:
|
|
task.cancel()
|
|
with contextlib.suppress(BaseException):
|
|
await task
|
|
return await flush_auth_observability_buffer("关闭")
|
|
|
|
|
|
def _percentile(values: list[float], ratio: float) -> float:
|
|
if not values:
|
|
return 0.0
|
|
ordered = sorted(values)
|
|
index = min(max(round((len(ordered) - 1) * ratio), 0), len(ordered) - 1)
|
|
return round(ordered[index], 3)
|
|
|
|
|
|
def _bucket_counts(rows: list[dict[str, Any]], field: str) -> dict[str, int]:
|
|
counts: dict[str, int] = {}
|
|
for row in rows:
|
|
key = str(row.get(field) or "<none>")
|
|
counts[key] = counts.get(key, 0) + 1
|
|
return counts
|
|
|
|
|
|
def _lane_budget_advice(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
buckets: dict[str, list[dict[str, Any]]] = {}
|
|
for row in rows:
|
|
lane = str(row.get("lane") or "<unknown>")
|
|
buckets.setdefault(lane, []).append(row)
|
|
advice: dict[str, dict[str, Any]] = {}
|
|
for lane, items in buckets.items():
|
|
if lane == "<unknown>":
|
|
continue
|
|
durations = [float(item.get("duration_ms") or 0.0) for item in items]
|
|
slow_waits = sum(1 for value in durations if value >= 200.0)
|
|
active_max = max(
|
|
(int(item.get("active_count") or 0) for item in items), default=0
|
|
)
|
|
total = len(items)
|
|
if not total:
|
|
continue
|
|
pressure_ratio = slow_waits / total
|
|
if pressure_ratio >= 0.2 or active_max >= 5:
|
|
action = "increase_or_split"
|
|
elif pressure_ratio == 0 and active_max <= 1 and total >= 20:
|
|
action = "can_reduce"
|
|
else:
|
|
action = "keep"
|
|
advice[lane] = {
|
|
"samples": total,
|
|
"slow_waits": slow_waits,
|
|
"pressure_ratio": round(pressure_ratio, 3),
|
|
"active_max": active_max,
|
|
"p95_duration_ms": _percentile(durations, 0.95),
|
|
"action": action,
|
|
}
|
|
return advice
|
|
|
|
|
|
def _query_placeholder() -> str:
|
|
try:
|
|
connection = Tortoise.get_connection("default")
|
|
if (
|
|
getattr(connection, "capabilities", None)
|
|
and getattr(
|
|
connection.capabilities,
|
|
"dialect",
|
|
"",
|
|
)
|
|
== "postgres"
|
|
):
|
|
return "$1"
|
|
except Exception:
|
|
return "?"
|
|
return "?"
|
|
|
|
|
|
async def build_auth_observability_report(*, hours: float = 24.0) -> dict[str, Any]:
|
|
since = datetime.now() - timedelta(hours=hours)
|
|
db = Tortoise.get_connection("default")
|
|
placeholder = _query_placeholder()
|
|
auth_rows = await db.execute_query_dict(
|
|
"SELECT module, effect, reason, shadow_effect, shadow_reason, latency_ms, "
|
|
f"overloaded FROM auth_decision_log WHERE create_time >= {placeholder} "
|
|
"ORDER BY create_time DESC LIMIT 100000",
|
|
[since],
|
|
)
|
|
backpressure_rows = await db.execute_query_dict(
|
|
"SELECT scope_key, lane, reason, action, queue_size, active_count, duration_ms "
|
|
f"FROM runtime_backpressure_log WHERE create_time >= {placeholder} "
|
|
"ORDER BY create_time DESC LIMIT 100000",
|
|
[since],
|
|
)
|
|
|
|
module_buckets: dict[str, list[dict[str, Any]]] = {}
|
|
for row in auth_rows:
|
|
module_buckets.setdefault(str(row.get("module") or "<unknown>"), []).append(row)
|
|
module_stats: list[dict[str, Any]] = []
|
|
for module, items in module_buckets.items():
|
|
latencies = [float(item.get("latency_ms") or 0.0) for item in items]
|
|
module_stats.append(
|
|
{
|
|
"module": module,
|
|
"total": len(items),
|
|
"effects": _bucket_counts(items, "effect"),
|
|
"shadow_effects": _bucket_counts(items, "shadow_effect"),
|
|
"avg_latency_ms": round(sum(latencies) / len(latencies), 3)
|
|
if latencies
|
|
else 0.0,
|
|
"p95_latency_ms": _percentile(latencies, 0.95),
|
|
"overloaded": sum(1 for item in items if bool(item.get("overloaded"))),
|
|
}
|
|
)
|
|
|
|
backpressure_buckets: dict[str, list[dict[str, Any]]] = {}
|
|
for row in backpressure_rows:
|
|
key = f"{row.get('lane') or '<unknown>'}:{row.get('reason') or '<none>'}"
|
|
backpressure_buckets.setdefault(key, []).append(row)
|
|
backpressure_stats: list[dict[str, Any]] = []
|
|
for key, items in backpressure_buckets.items():
|
|
durations = [float(item.get("duration_ms") or 0.0) for item in items]
|
|
backpressure_stats.append(
|
|
{
|
|
"key": key,
|
|
"total": len(items),
|
|
"actions": _bucket_counts(items, "action"),
|
|
"avg_duration_ms": round(sum(durations) / len(durations), 3)
|
|
if durations
|
|
else 0.0,
|
|
"p95_duration_ms": _percentile(durations, 0.95),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
"window_hours": hours,
|
|
"auth_decisions": {
|
|
"total": len(auth_rows),
|
|
"effects": _bucket_counts(auth_rows, "effect"),
|
|
"shadow_effects": _bucket_counts(auth_rows, "shadow_effect"),
|
|
"top_modules_by_p95": sorted(
|
|
module_stats,
|
|
key=lambda item: (item["p95_latency_ms"], item["total"]),
|
|
reverse=True,
|
|
)[:30],
|
|
},
|
|
"backpressure": {
|
|
"total": len(backpressure_rows),
|
|
"lane_budget_advice": _lane_budget_advice(backpressure_rows),
|
|
"top_reasons": sorted(
|
|
backpressure_stats,
|
|
key=lambda item: (item["total"], item["p95_duration_ms"]),
|
|
reverse=True,
|
|
)[:30],
|
|
},
|
|
}
|
|
|
|
|
|
@PriorityLifecycle.on_shutdown(priority=90)
|
|
async def _flush_auth_observability_buffer_on_shutdown() -> None:
|
|
await stop_auth_observability_buffer()
|