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

@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy.orm import Session
from duty_teller.cache import duty_pin_cache
from duty_teller.db.repository import (
get_current_duty,
get_next_shift_end,
@@ -17,6 +18,31 @@ from duty_teller.i18n import t
from duty_teller.utils.dates import parse_utc_iso
def get_pin_refresh_data(
session: Session, chat_id: int, tz_name: str, lang: str = "en"
) -> tuple[int | None, str, datetime | None]:
"""Get all data needed for pin refresh in a single DB session.
Args:
session: DB session.
chat_id: Telegram chat id.
tz_name: Timezone name for display.
lang: Language code for i18n.
Returns:
(message_id, duty_message_text, next_shift_end_utc).
message_id is None if no pin record. next_shift_end_utc is naive UTC or None.
"""
pin = get_group_duty_pin(session, chat_id)
message_id = pin.message_id if pin else None
if message_id is None:
return (None, t(lang, "duty.no_duty"), None)
now = datetime.now(timezone.utc)
text = get_duty_message_text(session, tz_name, lang)
next_end = get_next_shift_end(session, now)
return (message_id, text, next_end)
def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
"""Build the text for the pinned duty message.
@@ -64,34 +90,31 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
def get_duty_message_text(session: Session, tz_name: str, lang: str = "en") -> str:
"""Get current duty from DB and return formatted message text.
Args:
session: DB session.
tz_name: Timezone name for display.
lang: Language code for i18n.
Returns:
Formatted duty message or "No duty" if none.
"""
"""Get current duty from DB and return formatted message text. Cached 90s."""
cache_key = ("duty_message_text", tz_name, lang)
text, found = duty_pin_cache.get(cache_key)
if found:
return text
now = datetime.now(timezone.utc)
result = get_current_duty(session, now)
if result is None:
return t(lang, "duty.no_duty")
duty, user = result
return format_duty_message(duty, user, tz_name, lang)
text = t(lang, "duty.no_duty")
else:
duty, user = result
text = format_duty_message(duty, user, tz_name, lang)
duty_pin_cache.set(cache_key, text)
return text
def get_next_shift_end_utc(session: Session) -> datetime | None:
"""Return next shift end as naive UTC datetime for job scheduling.
Args:
session: DB session.
Returns:
Next shift end (naive UTC) or None.
"""
return get_next_shift_end(session, datetime.now(timezone.utc))
"""Return next shift end as naive UTC datetime for job scheduling. Cached 90s."""
cache_key = ("next_shift_end",)
value, found = duty_pin_cache.get(cache_key)
if found:
return value
result = get_next_shift_end(session, datetime.now(timezone.utc))
duty_pin_cache.set(cache_key, result)
return result
def save_pin(session: Session, chat_id: int, message_id: int) -> None:

View File

@@ -4,10 +4,12 @@ from datetime import date, timedelta
from sqlalchemy.orm import Session
from duty_teller.cache import invalidate_duty_related_caches
from duty_teller.db.models import Duty
from duty_teller.db.repository import (
get_or_create_user_by_full_name,
delete_duties_in_range,
insert_duty,
get_or_create_user_by_full_name,
get_users_by_full_names,
)
from duty_teller.importers.duty_schedule import DutyScheduleResult
from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso
@@ -37,11 +39,10 @@ def run_import(
hour_utc: int,
minute_utc: int,
) -> tuple[int, int, int, int]:
"""Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.
"""Run duty-schedule import: delete range per user, bulk insert duties.
For each entry: get_or_create_user_by_full_name, delete_duties_in_range for
the result date range, then insert duties (handover time in UTC), unavailable
(all-day), and vacation (consecutive ranges).
Batched: users fetched in one query, missing created; bulk_insert_mappings.
One commit at end.
Args:
session: DB session.
@@ -55,31 +56,61 @@ def run_import(
from_date_str = result.start_date.isoformat()
to_date_str = result.end_date.isoformat()
num_duty = num_unavailable = num_vacation = 0
# Batch: get all users by full_name, create missing
names = [e.full_name for e in result.entries]
users_map = get_users_by_full_names(session, names)
for name in names:
if name not in users_map:
users_map[name] = get_or_create_user_by_full_name(session, name)
# Delete range per user (no commit)
for entry in result.entries:
user = get_or_create_user_by_full_name(session, entry.full_name)
delete_duties_in_range(session, user.id, from_date_str, to_date_str)
user = users_map[entry.full_name]
delete_duties_in_range(
session, user.id, from_date_str, to_date_str, commit=False
)
# Build rows for bulk insert
duty_rows: list[dict] = []
for entry in result.entries:
user = users_map[entry.full_name]
for d in entry.duty_dates:
start_at = duty_to_iso(d, hour_utc, minute_utc)
d_next = d + timedelta(days=1)
end_at = duty_to_iso(d_next, hour_utc, minute_utc)
insert_duty(session, user.id, start_at, end_at, event_type="duty")
duty_rows.append(
{
"user_id": user.id,
"start_at": start_at,
"end_at": end_at,
"event_type": "duty",
}
)
num_duty += 1
for d in entry.unavailable_dates:
insert_duty(
session,
user.id,
day_start_iso(d),
day_end_iso(d),
event_type="unavailable",
duty_rows.append(
{
"user_id": user.id,
"start_at": day_start_iso(d),
"end_at": day_end_iso(d),
"event_type": "unavailable",
}
)
num_unavailable += 1
for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):
insert_duty(
session,
user.id,
day_start_iso(start_d),
day_end_iso(end_d),
event_type="vacation",
duty_rows.append(
{
"user_id": user.id,
"start_at": day_start_iso(start_d),
"end_at": day_end_iso(end_d),
"event_type": "vacation",
}
)
num_vacation += 1
if duty_rows:
session.bulk_insert_mappings(Duty, duty_rows)
session.commit()
invalidate_duty_related_caches()
return (len(result.entries), num_duty, num_unavailable, num_vacation)