fix(sheets): add TTL in-memory cache to prevent Sheets API 429 quota errors

Every /api/me call was reading the entire Users sheet via get_all_values(),
exhausting the 60 reads/minute quota under any load. Added a 90-second
TTL cache keyed by sheet name:
- _cached_get_all_values(): returns cached data or refreshes on miss/expiry
- _invalidate_cache(): called after every write (append_row, append_named_row,
  update_cell_by_key) so stale data is never served after mutations
- Patched: find_row, update_cell_by_key, list_users_with_role

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-17 00:33:52 +03:00
parent a3b0ff511c
commit cd587f846a

View File

@ -1,6 +1,7 @@
"""Тонкая обёртка над Google Sheets через gspread + service account.""" """Тонкая обёртка над Google Sheets через gspread + service account."""
from __future__ import annotations from __future__ import annotations
import threading import threading
import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
import gspread import gspread
@ -13,6 +14,36 @@ _lock = threading.Lock()
_client: gspread.Client | None = None _client: gspread.Client | None = None
_book: gspread.Spreadsheet | None = None _book: gspread.Spreadsheet | None = None
# ── In-memory TTL cache для листов ──────────────────────────────────────────
# Снижает число Read-запросов к Sheets API и предотвращает 429-ошибки.
# Кэш хранит последний get_all_values() каждого листа с меткой времени.
# При записи (append / update) кэш листа сбрасывается.
_CACHE_TTL = 90 # секунд
_sheet_cache: dict[str, tuple[list[list[str]], float]] = {}
_cache_lock = threading.Lock()
def _cached_get_all_values(ws: gspread.Worksheet) -> list[list[str]]:
"""Возвращает get_all_values() из кэша или обновляет кэш."""
name = ws.title
now = time.monotonic()
with _cache_lock:
if name in _sheet_cache:
data, expires_at = _sheet_cache[name]
if now < expires_at:
return data
# Промах или истёк — читаем свежее
data = ws.get_all_values()
with _cache_lock:
_sheet_cache[name] = (data, now + _CACHE_TTL)
return data
def _invalidate_cache(sheet_name: str) -> None:
"""Сбрасывает кэш листа после любой записи."""
with _cache_lock:
_sheet_cache.pop(sheet_name, None)
def _client_book() -> tuple[gspread.Client, gspread.Spreadsheet]: def _client_book() -> tuple[gspread.Client, gspread.Spreadsheet]:
global _client, _book global _client, _book
@ -58,6 +89,7 @@ def ensure_sheet(name: str, headers: list[str]) -> gspread.Worksheet:
def append_row(name: str, row: list[Any]) -> None: def append_row(name: str, row: list[Any]) -> None:
sheet(name).append_row(row, value_input_option="USER_ENTERED") sheet(name).append_row(row, value_input_option="USER_ENTERED")
_invalidate_cache(name)
def append_named_row(name: str, data: dict[str, Any]) -> None: def append_named_row(name: str, data: dict[str, Any]) -> None:
@ -70,12 +102,13 @@ def append_named_row(name: str, data: dict[str, Any]) -> None:
raise ValueError(f"Sheet {name!r} has no header row") raise ValueError(f"Sheet {name!r} has no header row")
row = [str(data.get(h, "") if data.get(h, "") is not None else "") for h in headers] row = [str(data.get(h, "") if data.get(h, "") is not None else "") for h in headers]
ws.append_row(row, value_input_option="RAW") ws.append_row(row, value_input_option="RAW")
_invalidate_cache(name)
def find_row(sheet_name: str, key_col: str, key_val: Any) -> dict[str, Any] | None: def find_row(sheet_name: str, key_col: str, key_val: Any) -> dict[str, Any] | None:
"""Линейный поиск по колонке-ключу. Возвращает строку как dict или None.""" """Линейный поиск по колонке-ключу. Возвращает строку как dict или None."""
s = sheet(sheet_name) s = sheet(sheet_name)
rows = s.get_all_values() rows = _cached_get_all_values(s)
if not rows: if not rows:
return None return None
headers = rows[0] headers = rows[0]
@ -90,7 +123,7 @@ def find_row(sheet_name: str, key_col: str, key_val: Any) -> dict[str, Any] | No
def update_cell_by_key(sheet_name: str, key_col: str, key_val: Any, target_col: str, new_val: Any) -> bool: def update_cell_by_key(sheet_name: str, key_col: str, key_val: Any, target_col: str, new_val: Any) -> bool:
s = sheet(sheet_name) s = sheet(sheet_name)
rows = s.get_all_values() rows = _cached_get_all_values(s)
if not rows: if not rows:
return False return False
headers = rows[0] headers = rows[0]
@ -101,6 +134,7 @@ def update_cell_by_key(sheet_name: str, key_col: str, key_val: Any, target_col:
for i, r in enumerate(rows[1:], start=2): for i, r in enumerate(rows[1:], start=2):
if len(r) > key_idx and str(r[key_idx]).strip() == str(key_val).strip(): if len(r) > key_idx and str(r[key_idx]).strip() == str(key_val).strip():
s.update_cell(i, target_idx + 1, new_val) s.update_cell(i, target_idx + 1, new_val)
_invalidate_cache(sheet_name)
return True return True
return False return False
@ -202,7 +236,7 @@ def revoke_role(tg_id: int, role: str) -> bool:
def list_users_with_role(role: str) -> list[dict[str, Any]]: def list_users_with_role(role: str) -> list[dict[str, Any]]:
"""Все пользователи, у которых есть указанная роль (для dropdown «выбрать замерщика»).""" """Все пользователи, у которых есть указанная роль (для dropdown «выбрать замерщика»)."""
s = sheet("Users") s = sheet("Users")
rows = s.get_all_values() rows = _cached_get_all_values(s)
if not rows: if not rows:
return [] return []
headers = rows[0] headers = rows[0]