Skip to content

Commit 9b1f502

Browse files
committed
feat(db): add in-memory cache for get_user() + fix connect messages
Cache: - Add _user_cache dict and invalidate_user_cache() to database/users.py - Cache get_user() results with TTL=3600s (1 hour) - Invalidate on every write: update_user_settings, upsert_user, save_session, clear_session - Add USER_CACHE_TTL constant to config.py - Add autouse fixture + 4 new cache tests Connect flow messages: - Remove redundant "This prevents Telegram from blocking the login" - Replace "letter" with "character" to match dash examples (12-345) - Remove duplicate /connect mention from connect_code_no_separator
1 parent 1e1a99a commit 9b1f502

File tree

4 files changed

+104
-6
lines changed

4 files changed

+104
-6
lines changed

config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
2828
if not SUPABASE_URL or not SUPABASE_KEY:
2929
print("⚠️ WARNING: SUPABASE_URL или SUPABASE_KEY не заданы!")
30+
USER_CACHE_TTL = 3600 # In-memory кэш get_user(), секунды
3031

3132
# ====== ШИФРОВАНИЕ СЕССИЙ ======
3233
SESSION_ENCRYPTION_KEY = os.getenv("SESSION_ENCRYPTION_KEY", "")

database/users.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
# database/users.py — CRUD для таблицы users
22

3+
import time
34
from datetime import datetime, timezone
45
from typing import Optional
56

6-
from config import DEBUG_PRINT
7+
from config import DEBUG_PRINT, USER_CACHE_TTL
78
from database import run_supabase, supabase
89
from utils.session_crypto import decrypt_session_string, encrypt_session_string
910
from utils.utils import get_timestamp
1011

12+
# In-memory кэш get_user(): {user_id: (expires_at, data)}
13+
_user_cache: dict[int, tuple[float, dict]] = {}
14+
15+
16+
def invalidate_user_cache(user_id: int) -> None:
17+
"""Удаляет пользователя из in-memory кэша."""
18+
_user_cache.pop(user_id, None)
19+
1120

1221
class UserStorageError(RuntimeError):
1322
"""Ошибка чтения/создания пользователя в БД."""
@@ -47,6 +56,7 @@ async def upsert_user(
4756

4857
if DEBUG_PRINT:
4958
print(f"{get_timestamp()} [DB] Upsert user {user_id} (@{username})")
59+
invalidate_user_cache(user_id)
5060
return True
5161
except Exception as e:
5262
print(f"{get_timestamp()} [DB] ERROR upsert_user {user_id}: {e}")
@@ -89,6 +99,7 @@ async def save_session(user_id: int, session_string: str) -> bool:
8999
).execute()
90100
)
91101

102+
invalidate_user_cache(user_id)
92103
if DEBUG_PRINT:
93104
print(f"{get_timestamp()} [DB] Session saved for user {user_id}")
94105
return True
@@ -150,6 +161,7 @@ async def clear_session(user_id: int) -> bool:
150161
).eq("user_id", user_id).execute()
151162
)
152163

164+
invalidate_user_cache(user_id)
153165
if DEBUG_PRINT:
154166
print(f"{get_timestamp()} [DB] Session cleared for user {user_id}")
155167
return True
@@ -175,7 +187,14 @@ async def has_saved_session(user_id: int) -> bool:
175187

176188

177189
async def get_user(user_id: int) -> Optional[dict]:
178-
"""Получает все поля пользователя из БД."""
190+
"""Получает все поля пользователя из БД (с in-memory кэшем)."""
191+
cached = _user_cache.get(user_id)
192+
if cached is not None:
193+
expires_at, data = cached
194+
if time.monotonic() < expires_at:
195+
return data
196+
_user_cache.pop(user_id, None)
197+
179198
try:
180199
result = await run_supabase(
181200
lambda: supabase.table("users").select(
@@ -184,7 +203,9 @@ async def get_user(user_id: int) -> Optional[dict]:
184203
)
185204

186205
if result.data and result.data[0]:
187-
return result.data[0]
206+
user = result.data[0]
207+
_user_cache[user_id] = (time.monotonic() + USER_CACHE_TTL, user)
208+
return user
188209
return None
189210
except Exception as e:
190211
print(f"{get_timestamp()} [DB] ERROR get_user {user_id}: {e}")
@@ -269,6 +290,7 @@ async def update_user_settings(user_id: int, settings: dict, *, current_settings
269290
).eq("user_id", user_id).execute()
270291
)
271292

293+
invalidate_user_cache(user_id)
272294
if DEBUG_PRINT:
273295
log_settings = {**merged}
274296
cp = log_settings.get("custom_prompt")

system_messages.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@
5454
# — Connect: Phone flow —
5555
"connect_phone_prompt": "📱 Send your phone number in international format (e.g. +1234567890).\n\n⚠️ The number will be used once for login and will NOT be stored.",
5656
"connect_phone_btn_qr": "📷 Connect via QR code",
57-
"connect_code_prompt": "📲 Enter the confirmation code you received from Telegram.\n\n⚠️ IMPORTANT: Add any letter or space anywhere in the code.\nExample: 12x345 or 123 45\n\nThis prevents Telegram from blocking the login.",
58-
"connect_code_invalid": "❌ Invalid code. Please try again.\nRemember: add any letter or space in the code (e.g. 12x345):",
59-
"connect_code_no_separator": "⚠️ It looks like you entered the code without separators. Telegram has blocked this code.\nPlease try /connect again, and this time add any letter or space in the code (e.g. 12x345).",
57+
"connect_code_prompt": "📲 Enter the confirmation code you received from Telegram.\n\n⚠️ IMPORTANT: Add any character or space anywhere in the code.\nExample: 12-345 or 1234 5",
58+
"connect_code_invalid": "❌ Invalid code. Please try again.\nRemember: add any character or space in the code (e.g. 12-345):",
59+
"connect_code_no_separator": "⚠️ It looks like you entered the code without separators. Telegram has blocked this code.\nNext time add any character or space in the code (e.g. 12-345).",
6060
"connect_code_expired": "⏰ Code expired. Please try /connect again.",
6161
"connect_phone_invalid": "❌ Invalid phone number. Please send your number in international format (e.g. +1234567890):",
6262
"connect_phone_timeout": "⏰ Login timed out. Please try /connect again.",

tests/test_database_users.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytest
66

7+
import database.users as users_module
78
from database.users import (
89
clear_session,
910
ensure_user_exists,
@@ -21,6 +22,14 @@
2122
from utils.session_crypto import decrypt_session_string, encrypt_session_string
2223

2324

25+
@pytest.fixture(autouse=True)
26+
def _clear_user_cache():
27+
"""Очищает кэш get_user() перед каждым тестом."""
28+
users_module._user_cache.clear()
29+
yield
30+
users_module._user_cache.clear()
31+
32+
2433
def _make_mock_table():
2534
"""Создаёт мок таблицы Supabase с чейнингом."""
2635
mock_table = MagicMock()
@@ -393,3 +402,69 @@ async def test_upserts_settings_for_new_user(self):
393402
)
394403
mock_table.update.assert_not_called()
395404

405+
406+
class TestUserCache:
407+
"""Тесты для in-memory кэша get_user()."""
408+
409+
@pytest.mark.asyncio
410+
async def test_cache_hit_skips_db(self):
411+
"""Повторный вызов отдаёт данные из кэша без запроса в БД."""
412+
mock_table = _make_mock_table()
413+
mock_table.execute.return_value = MagicMock(
414+
data=[{"user_id": 42, "settings": {}}]
415+
)
416+
with patch("database.users.supabase") as mock_sb:
417+
mock_sb.table.return_value = mock_table
418+
first = await get_user(42)
419+
second = await get_user(42)
420+
421+
assert first == second == {"user_id": 42, "settings": {}}
422+
# select вызван один раз — второй раз из кэша
423+
assert mock_table.select.call_count == 1
424+
425+
@pytest.mark.asyncio
426+
async def test_cache_expires_after_ttl(self):
427+
"""После TTL данные перечитываются из БД."""
428+
mock_table = _make_mock_table()
429+
mock_table.execute.return_value = MagicMock(
430+
data=[{"user_id": 42, "settings": {}}]
431+
)
432+
with patch("database.users.supabase") as mock_sb, \
433+
patch("database.users.time") as mock_time:
434+
mock_sb.table.return_value = mock_table
435+
mock_time.monotonic.side_effect = [0, 3600, 3601, 3601, 7201]
436+
# ^put ^check(ok) ^check(expired) ^put ^put(new)
437+
await get_user(42) # cache miss → DB query, monotonic returns 0 (put)
438+
await get_user(42) # monotonic returns 3600 → < 0+3600=3600? No, == 3600, not <
439+
# 3600 is NOT < 3600, so cache expired → DB query again
440+
441+
assert mock_table.select.call_count == 2
442+
443+
@pytest.mark.asyncio
444+
async def test_invalidate_on_update_settings(self):
445+
"""update_user_settings() сбрасывает кэш."""
446+
mock_table = _make_mock_table()
447+
user_data = {"user_id": 42, "settings": {"style": "friend"}}
448+
mock_table.execute.return_value = MagicMock(data=[user_data])
449+
with patch("database.users.supabase") as mock_sb:
450+
mock_sb.table.return_value = mock_table
451+
await get_user(42) # populate cache
452+
assert 42 in users_module._user_cache
453+
454+
await update_user_settings(42, {"style": "romance"})
455+
assert 42 not in users_module._user_cache
456+
457+
@pytest.mark.asyncio
458+
async def test_invalidate_on_upsert(self):
459+
"""upsert_user() сбрасывает кэш."""
460+
mock_table = _make_mock_table()
461+
mock_table.execute.return_value = MagicMock(
462+
data=[{"user_id": 42, "settings": {}}]
463+
)
464+
with patch("database.users.supabase") as mock_sb:
465+
mock_sb.table.return_value = mock_table
466+
await get_user(42) # populate cache
467+
assert 42 in users_module._user_cache
468+
469+
await upsert_user(42, username="new_name")
470+
assert 42 not in users_module._user_cache

0 commit comments

Comments
 (0)