feat: implement caching for duty-related data and enhance performance
All checks were successful
CI / lint-and-test (push) Successful in 24s
Docker Build and Release / build-and-push (push) Successful in 49s
Docker Build and Release / release (push) Successful in 8s

- Added a TTLCache class for in-memory caching of duty-related data, improving performance by reducing database queries.
- Integrated caching into the group duty pin functionality, allowing for efficient retrieval of message text and next shift end times.
- Introduced new methods to invalidate caches when relevant data changes, ensuring data consistency.
- Created a new Alembic migration to add indexes on the duties table for improved query performance.
- Updated tests to cover the new caching behavior and ensure proper functionality.
This commit is contained in:
2026-02-25 13:25:34 +03:00
parent 5334a4aeac
commit 0e8d1453e2
14 changed files with 413 additions and 113 deletions

View File

@@ -19,7 +19,7 @@ from duty_teller.db.repository import (
ROLE_USER,
ROLE_ADMIN,
)
from duty_teller.handlers.common import is_admin_async
from duty_teller.handlers.common import invalidate_is_admin_cache, is_admin_async
from duty_teller.i18n import get_lang, t
from duty_teller.utils.user import build_full_name
@@ -230,6 +230,7 @@ async def set_role(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
ok = await asyncio.get_running_loop().run_in_executor(None, do_set_role)
if ok:
invalidate_is_admin_cache(target_user.telegram_user_id)
await update.message.reply_text(
t(lang, "set_role.done", name=target_user.full_name, role=role_name)
)

View File

@@ -3,22 +3,35 @@
import asyncio
import duty_teller.config as config
from duty_teller.cache import is_admin_cache
from duty_teller.db.repository import is_admin_for_telegram_user
from duty_teller.db.session import session_scope
async def is_admin_async(telegram_user_id: int) -> bool:
"""Check if Telegram user is admin (username or phone). Runs DB check in executor.
"""Check if Telegram user is admin. Cached 60s. Invalidated on set_user_role.
Args:
telegram_user_id: Telegram user id.
Returns:
True if user is in ADMIN_USERNAMES or their stored phone is in ADMIN_PHONES.
True if user is admin (DB role or env fallback).
"""
cache_key = ("is_admin", telegram_user_id)
value, found = is_admin_cache.get(cache_key)
if found:
return value
def _check() -> bool:
with session_scope(config.DATABASE_URL) as session:
return is_admin_for_telegram_user(session, telegram_user_id)
return await asyncio.get_running_loop().run_in_executor(None, _check)
result = await asyncio.get_running_loop().run_in_executor(None, _check)
is_admin_cache.set(cache_key, result)
return result
def invalidate_is_admin_cache(telegram_user_id: int | None) -> None:
"""Invalidate is_admin cache for user. Call after set_user_role."""
if telegram_user_id is not None:
is_admin_cache.invalidate(("is_admin", telegram_user_id))

View File

@@ -15,10 +15,11 @@ from duty_teller.db.session import session_scope
from duty_teller.i18n import get_lang, t
from duty_teller.services.group_duty_pin_service import (
get_duty_message_text,
get_message_id,
get_next_shift_end_utc,
get_pin_refresh_data,
save_pin,
delete_pin,
get_message_id,
get_all_pin_chat_ids,
)
@@ -28,6 +29,14 @@ JOB_NAME_PREFIX = "duty_pin_"
RETRY_WHEN_NO_DUTY_MINUTES = 15
def _sync_get_pin_refresh_data(
chat_id: int, lang: str = "en"
) -> tuple[int | None, str, datetime | None]:
"""Get message_id, duty text, next_shift_end in one DB session."""
with session_scope(config.DATABASE_URL) as session:
return get_pin_refresh_data(session, chat_id, config.DUTY_DISPLAY_TZ, lang)
def _get_duty_message_text_sync(lang: str = "en") -> str:
with session_scope(config.DATABASE_URL) as session:
return get_duty_message_text(session, config.DUTY_DISPLAY_TZ, lang)
@@ -96,26 +105,27 @@ async def _refresh_pin_for_chat(
) -> Literal["updated", "no_message", "failed"]:
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id.
Uses single DB session for message_id, text, next_shift_end (consolidated).
Returns:
"updated" if the message was sent, pinned and saved successfully;
"no_message" if there is no pin record for this chat;
"failed" if send_message or permissions failed.
"""
loop = asyncio.get_running_loop()
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
message_id, text, next_end = await loop.run_in_executor(
None,
lambda: _sync_get_pin_refresh_data(chat_id, config.DEFAULT_LANGUAGE),
)
if message_id is None:
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
return "no_message"
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(config.DEFAULT_LANGUAGE)
)
try:
msg = await context.bot.send_message(chat_id=chat_id, text=text)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e
)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
return "failed"
try:
@@ -127,11 +137,9 @@ async def _refresh_pin_for_chat(
)
except (BadRequest, Forbidden) as e:
logger.warning("Unpin or pin after refresh failed chat_id=%s: %s", chat_id, e)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
return "failed"
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
return "updated"