99 lines
3.7 KiB
Python
99 lines
3.7 KiB
Python
"""Шаблоны сообщений из БД с 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)
|