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

@@ -403,6 +403,9 @@ def test_calendar_ical_ignores_unknown_query_params(
"""Unknown query params (e.g. events=all) are ignored; response is duty-only."""
from types import SimpleNamespace
from duty_teller.cache import ics_calendar_cache
ics_calendar_cache.invalidate(("personal_ics", 1))
mock_user = SimpleNamespace(id=1, full_name="User A")
mock_get_user.return_value = mock_user
duty = SimpleNamespace(

View File

@@ -103,6 +103,7 @@ class TestGetDutyMessageText:
"""Tests for get_duty_message_text."""
def test_no_current_duty_returns_no_duty(self, session):
svc.duty_pin_cache.invalidate_pattern(("duty_message_text",))
with patch(
"duty_teller.services.group_duty_pin_service.get_current_duty",
return_value=None,
@@ -113,6 +114,7 @@ class TestGetDutyMessageText:
assert result == "No duty"
def test_with_current_duty_returns_formatted(self, session, duty, user):
svc.duty_pin_cache.invalidate_pattern(("duty_message_text",))
with patch(
"duty_teller.services.group_duty_pin_service.get_current_duty",
return_value=(duty, user),
@@ -130,6 +132,7 @@ class TestGetNextShiftEndUtc:
"""Tests for get_next_shift_end_utc."""
def test_no_next_shift_returns_none(self, session):
svc.duty_pin_cache.invalidate(("next_shift_end",))
with patch(
"duty_teller.services.group_duty_pin_service.get_next_shift_end",
return_value=None,
@@ -138,6 +141,7 @@ class TestGetNextShiftEndUtc:
assert result is None
def test_has_next_shift_returns_naive_utc(self, session):
svc.duty_pin_cache.invalidate(("next_shift_end",))
naive = datetime(2025, 2, 21, 6, 0, 0)
with patch(
"duty_teller.services.group_duty_pin_service.get_next_shift_end",

View File

@@ -143,14 +143,12 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
context.application.job_queue.run_once = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(mod, "_sync_get_message_id", return_value=1):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Current duty"
):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(1, "Current duty", None)
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty")
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
context.bot.pin_chat_message.assert_called_once_with(
@@ -168,7 +166,9 @@ async def test_update_group_pin_no_message_id_skips():
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
):
await mod.update_group_pin(context)
context.bot.send_message.assert_not_called()
@@ -185,13 +185,11 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
with patch.object(mod, "_sync_get_message_id", return_value=2):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
await mod.update_group_pin(context)
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
):
with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule:
await mod.update_group_pin(context)
context.bot.unpin_chat_message.assert_not_called()
context.bot.pin_chat_message.assert_not_called()
mock_schedule.assert_called_once_with(context.application, 111, None)
@@ -214,15 +212,15 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(mod, "_sync_get_message_id", return_value=3):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=222, text="Text")
mock_save.assert_not_called()
mock_logger.warning.assert_called_once()
@@ -245,14 +243,14 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", False):
with patch.object(mod, "_sync_get_message_id", return_value=4):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text")
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
context.bot.pin_chat_message.assert_called_once_with(