DispelEvilBot/common/texts/store.py

99 lines
3.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Шаблоны сообщений из БД с TTL-кешем (Блок 4.5).
Бот читает тексты из ``bot.message_templates``: правка в админке применяется
без перезапуска — в течение ``TEMPLATE_CACHE_TTL`` секунд. Если БД недоступна
(или таблицы ещё нет), используются тексты по умолчанию из ``common.texts.TEMPLATES``.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from common.config import get_settings
from common.texts import TEMPLATES
logger = logging.getLogger(__name__)
#: Загрузчик «ключ -> текст» (по умолчанию — из Postgres).
Loader = Callable[[], Awaitable[dict[str, str]]]
async def _load_from_db() -> dict[str, str]:
# Импорт внутри функции: не тянем слой БД на этапе импорта текстов.
from common.repositories.templates_repo import load_texts
return await load_texts()
class TemplateStore:
"""Кеш шаблонов с временем жизни; при ошибке БД — тексты по умолчанию."""
def __init__(self, loader: Loader | None = None, ttl: float | None = None) -> None:
self._loader = loader or _load_from_db
self._ttl = ttl
self._texts: dict[str, str] = {}
self._loaded_at: float | None = None
self._lock = asyncio.Lock()
@property
def ttl(self) -> float:
"""TTL кеша в секундах (из настроек, если не задан явно)."""
if self._ttl is None:
self._ttl = get_settings().template_cache_ttl
return max(self._ttl, 0.0)
async def get(self, key: str) -> str:
"""Вернуть текст шаблона (из БД или из кода, если его там нет)."""
await self._ensure_fresh()
text = self._texts.get(key)
if text is None:
return TEMPLATES.get(key, "")
return text
def clear(self) -> None:
"""Сбросить кеш: следующий ``get`` перечитает БД."""
self._loaded_at = None
async def _ensure_fresh(self) -> None:
if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
return
async with self._lock:
# Пока ждали лок, кеш мог обновить другой вызов.
if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
return
try:
texts = await self._loader()
except Exception:
logger.warning(
"Не удалось прочитать шаблоны из БД — использую тексты по умолчанию",
exc_info=True,
)
# Не долбим БД каждым сообщением: следующая попытка через TTL.
self._loaded_at = time.monotonic()
return
self._texts = texts
self._loaded_at = time.monotonic()
logger.debug("Шаблоны обновлены из БД: %s", len(texts))
_store: TemplateStore | None = None
def get_template_store() -> TemplateStore:
"""Вернуть общий кеш шаблонов процесса."""
global _store
if _store is None:
_store = TemplateStore()
return _store
async def get_text(key: str) -> str:
"""Короткий доступ к шаблону: ``await get_text("info.help")``."""
return await get_template_store().get(key)