Study-Buddy / store.py
Hamdy005's picture
refactor: replace daily usage increment with atomic reservation
746f263
Raw
History Blame Contribute Delete
37.1 kB
import os
import time
import json
from httpx import RemoteProtocolError
from typing import Optional
from loguru import logger
from datetime import datetime, timezone, date, timedelta
from langchain.memory import ConversationBufferMemory, ConversationBufferWindowMemory
from src.database import get_supabase
from src.redis_client import get_redis
# ── Redis Key Prefixes & TTLs ──────────────────────────────────────────────────
MEMORY_KEY_PREFIX = "mem:"
MEMORY_REDIS_TTL = 48 * 3600 # 48 hours β€” keeps active sessions warm
MEMORY_MAX_MESSAGES = 20 # Max message history retained in Redis cache (10 turns)
DAILY_RATE_LIMIT_KEY_PREFIX = "rate:"
DAILY_RATE_LIMIT_REDIS_TTL = 86400 # 24 hours β€” daily usage counter auto-expiration
def _get_today_date_str() -> str:
# Shift UTC time by 3 hours to match Egypt timezone (UTC+3), so daily limits reset at 12 AM Egypt time.
egypt_tz = timezone(timedelta(hours=3))
return datetime.now(egypt_tz).date().isoformat()
_in_memory: dict = {
"materials": {},
"material_chunks": {},
"summaries": {},
"quizzes": {},
"users": {},
"next_id": 0,
}
ADMIN_EMAILS = set(
email.strip()
for email in os.environ.get("ADMIN_EMAILS", "").split(",")
if email.strip()
)
def _get_next_id() -> str:
_in_memory["next_id"] += 1
return str(_in_memory["next_id"])
_supabase_client = None
def _db():
global _supabase_client
if _supabase_client is None:
_supabase_client = get_supabase()
if _supabase_client is not None:
return _supabase_client
return None
class _FakeTable:
def __init__(self, name):
self.name = name
self._pending_insert: list | None = None
self._eq_field: str | None = None
self._eq_value = None
self._update_data: dict | None = None
self._single = False
def insert(self, data):
if isinstance(data, list):
for item in data:
item["id"] = item.get("id", _get_next_id())
_in_memory.setdefault(self.name, {})[item["id"]] = item
self._pending_insert = data
else:
data["id"] = data.get("id", _get_next_id())
_in_memory.setdefault(self.name, {})[data["id"]] = data
self._pending_insert = [data]
return self
def select(self, *args):
return self
def eq(self, field, value):
self._eq_field = field
self._eq_value = value
return self
def order(self, field):
return self
def maybe_single(self):
self._single = True
return self
def update(self, data):
self._update_data = data
return self
def delete(self):
"""Mark this query for deletion."""
self._delete = True
return self
def execute(self):
if getattr(self, '_delete', False):
store = _in_memory.get(self.name, {})
if self._eq_field:
keys = [k for k, v in store.items() if v.get(self._eq_field) == self._eq_value]
for k in keys:
store.pop(k, None)
return self._make_response([])
if self._pending_insert is not None:
return self._make_response(self._pending_insert)
records = list(_in_memory.get(self.name, {}).values())
if self._eq_field:
records = [r for r in records if r.get(self._eq_field) == self._eq_value]
if self._update_data is not None:
for r in records:
r.update(self._update_data)
if self._single:
data = records[0] if records else None
else:
data = records
return self._make_response(data)
def _make_response(self, data):
class R:
def execute(self):
return self
r = R()
r.data = data
return r
def _table_supabase(table: str):
client = _db()
if client is not None:
return client.table(table)
return _FakeTable(table)
def _robust_execute(query):
for attempt in range(3):
try:
return query.execute()
except RemoteProtocolError as e:
if attempt == 2:
raise e
time.sleep(0.5 * (attempt + 1))
return query.execute()
# ── Materials ──────────────────────────────────────────
def list_materials(user_id: str) -> list[dict]:
client = _db()
if client is not None:
try:
result = _robust_execute(client.table("materials").select("*").eq("user_id", user_id).order("created_at"))
data = list(reversed(result.data))
for r in data:
if r.get("source_type") == "url" and not r.get("url"):
r["source_type"] = "topic"
return data
except Exception:
pass
records = list(_in_memory.get("materials", {}).values())
data = list(reversed([r for r in records if r.get("user_id") == user_id]))
for r in data:
if r.get("source_type") == "url" and not r.get("url"):
r["source_type"] = "topic"
return data
def is_title_taken(title: str, exclude_id: Optional[str] = None, user_id: Optional[str] = None) -> bool:
"""Check whether *title* is already used by *user_id* (or globally when user_id is None)."""
normalized = title.strip().lower()
if not normalized:
return False
try:
query = _table_supabase("materials").select("id,title")
if user_id:
query = query.eq("user_id", user_id)
result = _robust_execute(query)
for row in result.data:
if exclude_id and row.get("id") == exclude_id:
continue
if row.get("title", "").strip().lower() == normalized:
return True
except Exception:
pass
for row in _in_memory.get("materials", {}).values():
if exclude_id and row.get("id") == exclude_id:
continue
if user_id and row.get("user_id") != user_id:
continue
if row.get("title", "").strip().lower() == normalized:
return True
return False
def create_material(user_id: str, source_type: str, title: str,
file_path: Optional[str] = None,
url: Optional[str] = None,
topic: Optional[str] = None) -> dict:
now = datetime.now(timezone.utc).isoformat()
# Ensure profile exists to avoid foreign key violations (Key (user_id) not present in table "profiles")
try:
# We use a direct check to avoid circular imports or complex logic
client = _db()
if client:
res = client.table("profiles").select("id").eq("id", user_id).execute()
if not res.data:
# Create a minimal profile if missing
client.table("profiles").insert({
"id": user_id,
"display_name": f"User_{user_id[:8]}",
"email": f"{user_id}@placeholder.ai"
}).execute()
except Exception as e:
logger.error(f"Failed to ensure profile for user {user_id}: {e}")
# Workaround for DB check constraint that restricts source_type to 'pdf' or 'url'
actual_source_type = source_type
if source_type == "topic":
actual_source_type = "url"
# Auto-resolve duplicate titles PER USER so each user's material list
original_title = title
counter = 1
while is_title_taken(title, user_id=user_id):
title = f"{original_title} ({counter})"
counter += 1
data = {"user_id": user_id, "source_type": actual_source_type, "title": title, "status": "pending",
"created_at": now, "updated_at": now}
if file_path:
data["file_path"] = file_path
if url:
data["url"] = url
# Insert assuming the global constraint on `title` has been replaced with a per-user one
result = _robust_execute(_table_supabase("materials").insert(data))
ret_data = result.data[0]
if ret_data.get("source_type") == "url" and not ret_data.get("url"):
ret_data["source_type"] = "topic"
return ret_data
def update_material_status(material_id: str, status: str,
error_message: Optional[str] = None):
data = {"status": status}
if error_message:
data["error_message"] = error_message
_robust_execute(_table_supabase("materials").update(data).eq("id", material_id))
def get_material(material_id: str) -> Optional[dict]:
if material_id.startswith("temp-"):
return None
result = _robust_execute(_table_supabase("materials").select("*").eq("id", material_id))
if result.data:
data = result.data[0]
if data.get("source_type") == "url" and not data.get("url"):
data["source_type"] = "topic"
return data
return None
def rename_material(material_id: str, title: str):
if material_id.startswith("temp-"):
return
_robust_execute(_table_supabase("materials").update({
"title": title,
"updated_at": datetime.now(timezone.utc).isoformat(),
}).eq("id", material_id))
def delete_material(material_id: str):
if material_id.startswith("temp-"):
return
# Due to cascading or manual deletion, we delete child records first
# chat_messages don't have material_id, so we must fetch session_ids first
sessions_res = _robust_execute(_table_supabase("chat_sessions").select("id").eq("material_id", material_id))
session_ids = [s["id"] for s in sessions_res.data] if sessions_res.data else []
for sid in session_ids:
_robust_execute(_table_supabase("chat_messages").delete().eq("session_id", sid))
_robust_execute(_table_supabase("chat_sessions").delete().eq("material_id", material_id))
_robust_execute(_table_supabase("summaries").delete().eq("material_id", material_id))
_robust_execute(_table_supabase("quizzes").delete().eq("material_id", material_id))
# Delete embeddings before chunks (FK dependency)
_robust_execute(_table_supabase("material_embeddings").delete().eq("material_id", material_id))
_robust_execute(_table_supabase("material_chunks").delete().eq("material_id", material_id))
_robust_execute(_table_supabase("materials").delete().eq("id", material_id))
# ── Material Chunks ────────────────────────────────────
def save_chunks(material_id: str, chunks: list[str]) -> list[str]:
cleaned_chunks = [c.replace("\x00", "").replace("\u0000", "") for c in chunks if c]
records = [
{"material_id": material_id, "chunk_index": i, "content": c}
for i, c in enumerate(cleaned_chunks)
]
result = _robust_execute(_table_supabase("material_chunks").insert(records))
return [r["id"] for r in result.data]
def get_chunks(material_id: str) -> list[dict]:
result = (
_table_supabase("material_chunks")
.select("*")
.eq("material_id", material_id)
.order("chunk_index")
.execute()
)
return result.data
# ── Summaries ──────────────────────────────────────────
def save_summary(material_id: str, user_id: str, summary: str,
time_taken: float, model_name: str = ""):
data = {
"material_id": material_id,
"user_id": user_id,
"summary": summary,
"status": "completed",
"time_taken": time_taken,
"model_name": model_name,
}
# Single atomic upsert — eliminates the race between select→insert/update
# under concurrent summarization requests (both see no row β†’ both insert β†’ 500).
client = _db()
if client is not None:
_robust_execute(client.table("summaries").upsert(data, on_conflict="material_id"))
else:
# Offline / dev fallback: manual check-then-write (no concurrency risk in dev)
existing = _FakeTable("summaries").select("*").eq("material_id", material_id).execute()
if existing.data:
_FakeTable("summaries").update(data).eq("material_id", material_id).execute()
else:
_FakeTable("summaries").insert(data).execute()
def get_summary(material_id: str) -> Optional[dict]:
try:
result = (
_table_supabase("summaries")
.select("*")
.eq("material_id", material_id)
.execute()
)
if not result or not result.data:
return None
# Return the most recent one if duplicates exist
return result.data[0]
except Exception:
return None
# ── Quizzes ────────────────────────────────────────────
def save_quiz(user_id: str, material_id: Optional[str], source_type: str,
difficulty: str, mcq_count: int, tf_count: int,
quiz_data: dict, model_name: str = "") -> dict:
data = {
"user_id": user_id,
"source_type": source_type,
"difficulty": difficulty,
"mcq_count": mcq_count,
"tf_count": tf_count,
"quiz_data": quiz_data,
"status": "completed",
"model_name": model_name,
}
if material_id:
data["material_id"] = material_id
result = _table_supabase("quizzes").insert(data).execute()
return result.data[0]
def get_quizzes(material_id: Optional[str] = None, user_id: Optional[str] = None) -> list[dict]:
query = _table_supabase("quizzes").select("*")
if user_id:
query = query.eq("user_id", user_id)
if material_id:
query = query.eq("material_id", material_id)
result = _robust_execute(query)
return result.data
# ── Users (maps to Supabase `profiles` table) ─────────
def _map_profile(profile: dict) -> dict:
today = _get_today_date_str()
used = profile.get("daily_requests", 0) if profile.get("last_request_date") == today else 0
return {
"id": profile["id"],
"name": profile.get("display_name", ""),
"email": profile.get("email", ""),
"avatar": profile.get("avatar_url", ""),
"theme": profile.get("theme", "system"),
"usage": {
"used": used,
"limit": 20,
"remaining": max(0, 20 - used)
}
}
def create_user(name: str, email: str, password: str, user_id: Optional[str] = None) -> dict:
existing = get_user_by_email(email)
if existing:
return existing
data = {"display_name": name, "email": email}
if user_id:
data["id"] = user_id
try:
result = _table_supabase("profiles").insert(data).execute()
# insert().execute().data is always a list
return _map_profile(result.data[0] if isinstance(result.data, list) and result.data else result.data)
except Exception:
result = _FakeTable("profiles").insert(data).execute()
return _map_profile(result.data[0] if isinstance(result.data, list) and result.data else result.data)
def get_user_by_email(email: str) -> Optional[dict]:
try:
# Standard select instead of maybe_single to be more robust against 406 errors
result = _table_supabase("profiles").select("*").eq("email", email).execute()
if result.data:
return _map_profile(result.data[0])
except Exception:
pass
fake = _FakeTable("profiles")
result = fake.select("*").eq("email", email).execute()
if result.data:
return _map_profile(result.data[0])
return None
def get_user_by_id(user_id: str) -> Optional[dict]:
try:
# Standard select instead of maybe_single to be more robust against 406 errors
result = _table_supabase("profiles").select("*").eq("id", user_id).execute()
if result.data:
return _map_profile(result.data[0])
except Exception:
pass
fake = _FakeTable("profiles")
result = fake.select("*").eq("id", user_id).execute()
if result.data:
return _map_profile(result.data[0])
return None
def update_user_profile(user_id: str, name: Optional[str] = None, avatar_url: Optional[str] = None, theme: Optional[str] = None) -> dict:
"""
Updates the user profile in the database.
"""
data = {}
if name is not None:
data["display_name"] = name
if avatar_url is not None:
data["avatar_url"] = avatar_url
if theme is not None:
data["theme"] = theme
if not data:
user = get_user_by_id(user_id)
if not user:
raise ValueError("User not found")
return user
try:
result = _robust_execute(_table_supabase("profiles").update(data).eq("id", user_id))
if result.data:
return _map_profile(result.data[0])
except Exception:
pass
# Fake fallback
store = _in_memory.get("profiles", {})
if user_id in store:
store[user_id].update(data)
return _map_profile(store[user_id])
raise ValueError("User not found")
# ── Chat Messages (persistent) ──────────────────────────
def save_chat_messages(material_id: str, user_id: str, messages: list[dict]):
existing = get_chat_messages(material_id)
data = {
"material_id": material_id,
"user_id": user_id,
"messages": messages,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
tbl = _table_supabase("chat_messages")
if existing:
tbl.update(data).eq("material_id", material_id).execute()
else:
data["created_at"] = data["updated_at"]
tbl.insert(data).execute()
def get_chat_messages(material_id: str) -> list[dict]:
result = (
_table_supabase("chat_messages")
.select("*")
.eq("material_id", material_id)
.execute()
)
if isinstance(result.data, list):
data = result.data[0] if result.data else {}
else:
data = result.data or {}
return data.get("messages", [])
# ── Quiz Results ────────────────────────────────────────
def save_quiz_result(quiz_id: str, user_id: str, result_data: dict):
data = {
"quiz_id": quiz_id,
"user_id": user_id,
"score": int(result_data.get("score", 0)),
"total": int(result_data.get("total", 0)),
"results": result_data,
"created_at": datetime.now(timezone.utc).isoformat(),
}
_table_supabase("quiz_attempts").insert(data).execute()
def get_quiz_results(quiz_id: str) -> list[dict]:
result = (
_table_supabase("quiz_attempts")
.select("*")
.eq("quiz_id", quiz_id)
.order("created_at", desc=False)
.execute()
)
return result.data
# ── Chat Sessions (proper DB structure) ───────────────
def create_chat_session(user_id: str, material_id: str, title: str = "New Chat") -> dict:
now = datetime.now(timezone.utc).isoformat()
data = {
"user_id": user_id,
"material_id": material_id,
"title": title,
"created_at": now,
"updated_at": now,
}
result = _table_supabase("chat_sessions").insert(data).execute()
return result.data[0]
def list_chat_sessions(material_id: str, user_id: str) -> list[dict]:
result = (
_table_supabase("chat_sessions")
.select("*")
.eq("material_id", material_id)
.eq("user_id", user_id)
.order("created_at")
.execute()
)
return result.data
def get_chat_session(session_id: str) -> Optional[dict]:
result = _table_supabase("chat_sessions").select("*").eq("id", session_id).execute()
if isinstance(result.data, list):
return result.data[0] if result.data else None
return result.data or None
def rename_chat_session(session_id: str, title: str):
_table_supabase("chat_sessions").update({
"title": title,
"updated_at": datetime.now(timezone.utc).isoformat(),
}).eq("id", session_id).execute()
def delete_chat_session(session_id: str):
_robust_execute(_table_supabase("chat_messages").delete().eq("session_id", session_id))
_robust_execute(_table_supabase("chat_sessions").delete().eq("id", session_id))
def append_session_message(session_id: str, role: str, content: str) -> dict:
data = {
"session_id": session_id,
"role": role,
"content": content,
"created_at": datetime.now(timezone.utc).isoformat(),
}
result = _table_supabase("chat_messages").insert(data).execute()
# Update session's updated_at
_table_supabase("chat_sessions").update({
"updated_at": data["created_at"]
}).eq("id", session_id).execute()
return result.data[0]
def get_session_messages(session_id: str) -> list[dict]:
result = (
_table_supabase("chat_messages")
.select("*")
.eq("session_id", session_id)
.order("created_at")
.execute()
)
return result.data
# ── Conversation Memory β€” Redis-backed, Python-dict fallback ─────────────────
import uuid as _uuid
# Fallback in-process dict for when Redis is unavailable
_memories: dict[str, ConversationBufferMemory] = {}
def _mem_redis_key(memory_id: str) -> str:
return f"{MEMORY_KEY_PREFIX}{memory_id}"
def _load_memory_from_messages(messages: list[dict]) -> ConversationBufferWindowMemory:
"""Build a fresh ConversationBufferWindowMemory from a flat message list."""
mem = ConversationBufferWindowMemory(
input_key="input", memory_key="chat_history", return_messages=True, k=5
)
for i in range(0, len(messages) - 1, 2):
user_msg = messages[i]
ai_msg = messages[i + 1] if i + 1 < len(messages) else None
if user_msg.get("role") == "user" and ai_msg and ai_msg.get("role") == "assistant":
mem.save_context(
{"input": user_msg["content"]},
{"output": ai_msg["content"]},
)
return mem
def get_or_create_memory(memory_id: Optional[str] = None, seed_messages: list[dict] | None = None):
"""
Get or create a ConversationBufferWindowMemory.
Redis path (fast):
Checks `mem:{memory_id}` in Redis first. If present, deserialises the
cached message list and builds memory from it β€” no Supabase query needed.
TTL is refreshed on each access so active conversations stay warm.
Fallback path (Supabase seed / in-process dict):
Falls back to `seed_messages` from Supabase (as before) and caches the
result in Redis so the next call skips Supabase entirely.
"""
mid = memory_id or str(_uuid.uuid4())
r = get_redis()
# ── Redis path ────────────────────────────────────────────────────────────
if r is not None:
try:
rkey = _mem_redis_key(mid)
raw = r.get(rkey)
if raw:
cached_msgs: list[dict] = json.loads(raw)
mem = _load_memory_from_messages(cached_msgs)
r.expire(rkey, MEMORY_REDIS_TTL) # refresh TTL on each use
return mem, mid
except Exception as e:
logger.warning("Redis get_or_create_memory read failed: %s", e)
# ── Seed from DB messages (Supabase / provided list) ─────────────────────
source_msgs: list[dict] = seed_messages or []
mem = _load_memory_from_messages(source_msgs)
# Cache in Redis so next request skips Supabase
if r is not None and source_msgs:
try:
rkey = _mem_redis_key(mid)
r.set(rkey, json.dumps(source_msgs), ex=MEMORY_REDIS_TTL)
except Exception as e:
logger.warning("Redis get_or_create_memory write failed: %s", e)
# Also keep the in-process fallback dict warm
_memories[mid] = mem
return mem, mid
def append_memory_message(memory_id: str, role: str, content: str) -> None:
"""
Append a single message to the Redis-cached message list for a session.
Called after each AI turn so Redis stays in sync without a full Supabase
round-trip. Silently no-ops if Redis is unavailable.
"""
r = get_redis()
if r is None:
return
try:
rkey = _mem_redis_key(memory_id)
raw = r.get(rkey)
msgs: list[dict] = json.loads(raw) if raw else []
msgs.append({"role": role, "content": content})
# Keep only the last N messages to cap memory usage
msgs = msgs[-MEMORY_MAX_MESSAGES:]
r.set(rkey, json.dumps(msgs), ex=MEMORY_REDIS_TTL)
except Exception as e:
logger.warning("append_memory_message Redis failed: %s", e)
def check_daily_limit(user_id: str, email: Optional[str] = None, limit: int = 20) -> bool:
"""
Checks if the user is under the daily limit. Returns True if allowed, False if exceeded.
Does NOT increment the count.
Redis path: atomic GET on `rate:{user_id}:{date}` (<5 ms, no Supabase hit).
Fallback: existing Supabase profiles query.
"""
if email and email in ADMIN_EMAILS:
return True
today = _get_today_date_str()
# ── Redis path ────────────────────────────────────────────────────────────
r = get_redis()
if r is not None:
try:
rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
val = r.get(rkey)
count = int(val) if val is not None else 0
return count < limit
except Exception as e:
logger.warning("Redis check_daily_limit failed: %s β€” falling back to Supabase", e)
# ── Supabase fallback ─────────────────────────────────────────────────────
try:
result = _robust_execute(
_table_supabase("profiles")
.select("daily_requests, last_request_date")
.eq("id", user_id)
)
if not result.data:
return True
profile = result.data[0] if result.data else None
if not profile:
return True
last_date = profile.get("last_request_date")
count = profile.get("daily_requests", 0) or 0
if last_date != today:
count = 0
return count < limit
except Exception as e:
logger.error(f"Rate limit check failed: {e}")
return True
def increment_daily_usage(user_id: str) -> None:
"""
Increments the daily request count for the user.
Redis path: atomic INCR + EXPIRE on `rate:{user_id}:{date}` (microseconds).
Always also updates Supabase so the dashboard & DB stay in sync.
"""
today = _get_today_date_str()
# ── Redis path β€” atomic INCR ──────────────────────────────────────────────
r = get_redis()
if r is not None:
try:
rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
pipe = r.pipeline()
pipe.incr(rkey)
pipe.expire(rkey, DAILY_RATE_LIMIT_REDIS_TTL) # auto-expire at next calendar day
pipe.execute()
except Exception as e:
logger.warning("Redis increment_daily_usage failed: %s", e)
# ── Supabase β€” keep DB in sync for audit / dashboard ─────────────────────
try:
result = _robust_execute(
_table_supabase("profiles")
.select("daily_requests, last_request_date")
.eq("id", user_id)
)
if not result.data:
return
profile = result.data[0] if result.data else None
if not profile:
return
last_date = profile.get("last_request_date")
count = profile.get("daily_requests", 0) or 0
if last_date != today:
count = 0
_robust_execute(
_table_supabase("profiles")
.update({"daily_requests": count + 1, "last_request_date": today})
.eq("id", user_id)
)
except Exception as e:
logger.error(f"Failed to increment daily usage: {e}")
def decrement_daily_usage(user_id: str) -> None:
"""
Rollback helper β€” decrements the daily counter by 1 (clamped to 0).
Called when a generation request was reserved (incremented) but failed
before producing a usable result, so the user is not penalised for a
server-side error.
"""
today = _get_today_date_str()
# ── Redis path β€” atomic DECR clamped to 0 ────────────────────────────────
r = get_redis()
if r is not None:
try:
rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
# DECR is atomic; clamp to 0 so we never go negative
current = r.decr(rkey)
if current < 0:
r.set(rkey, 0)
r.expire(rkey, DAILY_RATE_LIMIT_REDIS_TTL)
except Exception as e:
logger.warning("Redis decrement_daily_usage failed: %s", e)
# ── Supabase β€” keep DB in sync ────────────────────────────────────────────
try:
result = _robust_execute(
_table_supabase("profiles")
.select("daily_requests, last_request_date")
.eq("id", user_id)
)
if not result.data:
return
profile = result.data[0] if result.data else None
if not profile:
return
last_date = profile.get("last_request_date")
count = profile.get("daily_requests", 0) or 0
if last_date != today:
count = 0
new_count = max(0, count - 1)
_robust_execute(
_table_supabase("profiles")
.update({"daily_requests": new_count, "last_request_date": today})
.eq("id", user_id)
)
except Exception as e:
logger.error(f"Failed to decrement daily usage: {e}")
# Lua script: atomically check count < limit, then INCR + EXPIRE if allowed.
# Returns 1 (allowed and incremented) or 0 (limit exceeded, no change).
_ATOMIC_RATE_LIMIT_LUA = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local count = tonumber(redis.call('GET', key) or 0)
if count >= limit then
return 0
end
redis.call('INCR', key)
redis.call('EXPIRE', key, ttl)
return 1
"""
def atomic_check_and_increment_daily_limit(
user_id: str, email: Optional[str] = None, limit: int = 20
) -> bool:
"""
Atomically check the daily limit AND increment in one operation.
This eliminates the TOCTOU race condition that existed when `check_daily_limit`
and `increment_daily_usage` were called as two separate steps: concurrent
requests could both pass the check before either had incremented the counter.
Redis path: executes a Lua script so the GET + conditional INCR is one
indivisible command β€” Redis serialises all commands within a script.
Supabase fallback: increment first, then verify; rollback if over limit.
This is safe because Supabase operations are individually atomic (but not
as tight as the Lua approach under extreme concurrency).
Returns True if the request is allowed (counter was incremented),
False if the daily limit is already reached (counter unchanged).
"""
# Admins are always allowed and never counted
if email and email in ADMIN_EMAILS:
return True
today = _get_today_date_str()
# ── Redis path: true atomic check-and-increment via Lua ───────────────────
r = get_redis()
if r is not None:
try:
rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
result = r.eval(_ATOMIC_RATE_LIMIT_LUA, 1, rkey, limit, DAILY_RATE_LIMIT_REDIS_TTL)
return bool(result) # 1 β†’ allowed, 0 β†’ exceeded
except Exception as e:
logger.warning(
"Redis atomic_check_and_increment failed: %s β€” falling back to Supabase", e
)
# ── Supabase fallback: increment-first strategy ───────────────────────────
try:
result = _robust_execute(
_table_supabase("profiles")
.select("daily_requests, last_request_date")
.eq("id", user_id)
)
if not result.data:
# No profile row yet β€” treat as first request (allowed)
_robust_execute(
_table_supabase("profiles")
.update({"daily_requests": 1, "last_request_date": today})
.eq("id", user_id)
)
return True
profile = result.data[0]
last_date = profile.get("last_request_date")
count = profile.get("daily_requests", 0) or 0
if last_date != today:
count = 0 # day rolled over β€” reset
if count >= limit:
return False # already at limit β€” do not increment
# Increment in Supabase
_robust_execute(
_table_supabase("profiles")
.update({"daily_requests": count + 1, "last_request_date": today})
.eq("id", user_id)
)
return True
except Exception as e:
logger.error(f"Supabase atomic_check_and_increment failed: {e}")
return True # fail open rather than block the user on a DB error
def check_and_increment_daily_limit(user_id: str, email: Optional[str] = None, limit: int = 20) -> bool:
"""
Legacy wrapper kept for backward compatibility (used in rag/routes.py).
Delegates to the new atomic implementation.
"""
return atomic_check_and_increment_daily_limit(user_id, email, limit)
def get_usage(user_id: str) -> dict:
"""
Returns current usage for a user.
Redis path: read rate counter directly (<5 ms).
Fallback: Supabase profiles query.
"""
today = _get_today_date_str()
# ── Redis path ────────────────────────────────────────────────────────────
r = get_redis()
if r is not None:
try:
rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
val = r.get(rkey)
used = int(val) if val is not None else 0
return {"used": used, "limit": 20, "remaining": max(0, 20 - used)}
except Exception as e:
logger.warning("Redis get_usage failed: %s β€” falling back to Supabase", e)
# ── Supabase fallback ─────────────────────────────────────────────────────
try:
result = _robust_execute(
_table_supabase("profiles")
.select("daily_requests, last_request_date")
.eq("id", user_id)
)
if not result.data:
return {"used": 0, "limit": 20, "remaining": 20}
profile = result.data[0]
if not profile:
return {"used": 0, "limit": 20, "remaining": 20}
used = profile.get("daily_requests", 0) if profile.get("last_request_date") == today else 0
return {
"used": used,
"limit": 20,
"remaining": max(0, 20 - used)
}
except Exception:
return {"used": 0, "limit": 20, "remaining": 20}
def delete_user_data(user_id: str):
"""
Deletes all data associated with a user.
"""
# 1. Get all materials for this user and delete them one by one to ensure cascading deletes
materials_res = _robust_execute(_table_supabase("materials").select("id").eq("user_id", user_id))
material_ids = [m["id"] for m in materials_res.data] if materials_res.data else []
for mid in material_ids:
delete_material(mid)
# 2. Delete any quizzes that might not be tied to a specific material
_robust_execute(_table_supabase("quizzes").delete().eq("user_id", user_id))
# 3. Delete the user profile
_robust_execute(_table_supabase("profiles").delete().eq("id", user_id))