Hamdy005 commited on
Commit
746f263
Β·
1 Parent(s): f4b73f4

refactor: replace daily usage increment with atomic reservation

Browse files
Files changed (5) hide show
  1. main.py +17 -17
  2. quiz_generator/routes.py +15 -7
  3. rag/rag.py +47 -27
  4. store.py +145 -5
  5. summary_generator/routes.py +15 -7
main.py CHANGED
@@ -50,23 +50,23 @@ async def lifespan(app: FastAPI):
50
  logger.warning(f"Embedder failed to load: {e}")
51
 
52
  # Eagerly load ASR models so warmup runs at startup, not on first request
53
- try:
54
- from src.asr.models import get_audio_model_en
55
- get_audio_model_en()
56
- except Exception as e:
57
- logger.warning(f"English ASR model failed to load: {e}")
58
-
59
- try:
60
- from src.asr.models import get_audio_model_ar
61
- get_audio_model_ar()
62
- except Exception as e:
63
- logger.warning(f"Arabic ASR model failed to load: {e}")
64
-
65
- from src.rag.batch_workers import start_workers
66
- start_workers()
67
-
68
- from src.asr.batch_workers import start_asr_workers
69
- start_asr_workers()
70
 
71
  yield
72
 
 
50
  logger.warning(f"Embedder failed to load: {e}")
51
 
52
  # Eagerly load ASR models so warmup runs at startup, not on first request
53
+ # try:
54
+ # from src.asr.models import get_audio_model_en
55
+ # get_audio_model_en()
56
+ # except Exception as e:
57
+ # logger.warning(f"English ASR model failed to load: {e}")
58
+
59
+ # try:
60
+ # from src.asr.models import get_audio_model_ar
61
+ # get_audio_model_ar()
62
+ # except Exception as e:
63
+ # logger.warning(f"Arabic ASR model failed to load: {e}")
64
+
65
+ # from src.rag.batch_workers import start_workers
66
+ # start_workers()
67
+
68
+ # from src.asr.batch_workers import start_asr_workers
69
+ # start_asr_workers()
70
 
71
  yield
72
 
quiz_generator/routes.py CHANGED
@@ -3,7 +3,7 @@ from loguru import logger
3
  from fastapi import APIRouter, HTTPException, Depends
4
  from typing import Optional
5
  from src.quiz_generator.quiz import smart_quiz_generator
6
- from src.store import get_material, get_chunks, save_chunks, get_summary, save_quiz, get_quizzes, save_quiz_result, get_quiz_results, check_daily_limit, increment_daily_usage, ADMIN_EMAILS
7
  from src.dependencies import get_current_user_id, get_current_user
8
  from src.config import settings
9
  from .schemas import QuizRequest, QuizResponse, SaveQuizResultRequest
@@ -31,9 +31,10 @@ async def generate_quiz(
31
  user_id: str = Depends(get_current_user_id),
32
  current_user=Depends(get_current_user),
33
  ):
34
- # Rate limit check
35
  user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
36
- if not check_daily_limit(user_id, email=user_email, limit=20):
 
37
  raise HTTPException(429, "Daily limit of 20 requests reached. Come back tomorrow!")
38
 
39
  body.difficulty = body.difficulty.capitalize()
@@ -158,16 +159,23 @@ async def generate_quiz(
158
  model_name=settings.model_name,
159
  )
160
 
161
- # Only increment limit if the quiz was generated successfully and saved without error
162
- if not (user_email and user_email in ADMIN_EMAILS):
163
- increment_daily_usage(user_id)
164
-
165
  return QuizResponse(quiz=quiz, quiz_id=saved["id"])
166
  except ValueError as e:
167
  logger.warning(f"Validation error in generate_quiz: {str(e)}")
 
 
 
168
  raise HTTPException(400, str(e))
 
 
 
 
 
169
  except Exception as e:
170
  logger.error(f"Quiz generation failed: {str(e)}", exc_info=True)
 
 
 
171
  raise HTTPException(500, f"Quiz generation failed: {e}")
172
 
173
 
 
3
  from fastapi import APIRouter, HTTPException, Depends
4
  from typing import Optional
5
  from src.quiz_generator.quiz import smart_quiz_generator
6
+ from src.store import get_material, get_chunks, save_chunks, get_summary, save_quiz, get_quizzes, save_quiz_result, get_quiz_results, atomic_check_and_increment_daily_limit, decrement_daily_usage, ADMIN_EMAILS
7
  from src.dependencies import get_current_user_id, get_current_user
8
  from src.config import settings
9
  from .schemas import QuizRequest, QuizResponse, SaveQuizResultRequest
 
31
  user_id: str = Depends(get_current_user_id),
32
  current_user=Depends(get_current_user),
33
  ):
34
+ # Atomically reserve a daily request slot before starting expensive generation.
35
  user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
36
+ is_admin = bool(user_email and user_email in ADMIN_EMAILS)
37
+ if not atomic_check_and_increment_daily_limit(user_id, email=user_email, limit=20):
38
  raise HTTPException(429, "Daily limit of 20 requests reached. Come back tomorrow!")
39
 
40
  body.difficulty = body.difficulty.capitalize()
 
159
  model_name=settings.model_name,
160
  )
161
 
 
 
 
 
162
  return QuizResponse(quiz=quiz, quiz_id=saved["id"])
163
  except ValueError as e:
164
  logger.warning(f"Validation error in generate_quiz: {str(e)}")
165
+ # Refund the reserved slot β€” validation errors are our fault, not the user's.
166
+ if not is_admin:
167
+ decrement_daily_usage(user_id)
168
  raise HTTPException(400, str(e))
169
+ except HTTPException:
170
+ # Re-raise HTTP exceptions as-is (403, 400, etc.).
171
+ if not is_admin:
172
+ decrement_daily_usage(user_id)
173
+ raise
174
  except Exception as e:
175
  logger.error(f"Quiz generation failed: {str(e)}", exc_info=True)
176
+ # Refund: generation/save failed through no fault of the user.
177
+ if not is_admin:
178
+ decrement_daily_usage(user_id)
179
  raise HTTPException(500, f"Quiz generation failed: {e}")
180
 
181
 
rag/rag.py CHANGED
@@ -75,40 +75,60 @@ async def store_embeddings_async(material_id: str, chunk_ids: list[str], chunks:
75
  """
76
  Async variant of store_embeddings that routes embedding inference through
77
  the batch worker queue for batching across concurrent requests.
 
78
  """
79
- from src.rag.batch_workers import embedding_queue, job_store
80
- from .schemas import EmbeddingJob
 
81
 
82
- job = EmbeddingJob(job_id=str(uuid.uuid4()), texts=chunks)
83
- job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
84
- await embedding_queue.put(job)
85
- await job.done.wait()
86
 
87
- entry = job_store.pop(job.job_id)
88
- if entry["status"] == "error":
89
- raise RuntimeError(f"Embedding failed: {entry['error']}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
- embeddings = entry["result"]
 
 
 
92
 
93
- records = [
94
- {"chunk_id": cid, "material_id": material_id, "embedding": emb}
95
- for cid, emb in zip(chunk_ids, embeddings)
96
- ]
97
 
98
- db = get_supabase()
99
- if db is None:
100
- logger.warning("Supabase not connected β€” embeddings computed but NOT stored (no DB).")
101
- return
102
 
103
- logger.info(f"Storing {len(records)} embeddings in Supabase for material {material_id}...")
104
-
105
- def _insert_records():
106
- for i in range(0, len(records), 50):
107
- db.table("material_embeddings").insert(records[i:i + 50]).execute()
108
-
109
- loop = asyncio.get_event_loop()
110
- await loop.run_in_executor(None, _insert_records)
111
- logger.info(f"Embeddings stored successfully for material {material_id}.")
112
 
113
 
114
  def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
 
75
  """
76
  Async variant of store_embeddings that routes embedding inference through
77
  the batch worker queue for batching across concurrent requests.
78
+ Falls back to the synchronous path if the batch workers are not running.
79
  """
80
+ try:
81
+ from src.rag.batch_workers import embedding_queue, job_store
82
+ from .schemas import EmbeddingJob
83
 
84
+ job = EmbeddingJob(job_id=str(uuid.uuid4()), texts=chunks)
85
+ job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
86
+ await embedding_queue.put(job)
 
87
 
88
+ # Wait for the worker to process the job, but with a timeout.
89
+ # If batch workers are not running (e.g. commented out in main.py),
90
+ # this would hang forever β€” the timeout triggers a fallback to the sync path.
91
+ try:
92
+ await asyncio.wait_for(job.done.wait(), timeout=30.0)
93
+ except asyncio.TimeoutError:
94
+ # Workers not running β€” clean up and fall back to sync embedding
95
+ job_store.pop(job.job_id, None)
96
+ logger.warning(
97
+ f"store_embeddings_async timed out waiting for batch worker "
98
+ f"(material={material_id}). Falling back to synchronous embedding."
99
+ )
100
+ await asyncio.to_thread(store_embeddings, material_id, chunk_ids, chunks)
101
+ return
102
+
103
+ entry = job_store.pop(job.job_id)
104
+ if entry["status"] == "error":
105
+ raise RuntimeError(f"Embedding failed: {entry['error']}")
106
+
107
+ embeddings = entry["result"]
108
+
109
+ records = [
110
+ {"chunk_id": cid, "material_id": material_id, "embedding": emb}
111
+ for cid, emb in zip(chunk_ids, embeddings)
112
+ ]
113
 
114
+ db = get_supabase()
115
+ if db is None:
116
+ logger.warning("Supabase not connected β€” embeddings computed but NOT stored (no DB).")
117
+ return
118
 
119
+ logger.info(f"Storing {len(records)} embeddings in Supabase for material {material_id}...")
 
 
 
120
 
121
+ def _insert_records():
122
+ for i in range(0, len(records), 50):
123
+ db.table("material_embeddings").insert(records[i:i + 50]).execute()
 
124
 
125
+ await asyncio.to_thread(_insert_records)
126
+ logger.info(f"Embeddings stored successfully for material {material_id}.")
127
+
128
+ except Exception as e:
129
+ # If anything unexpected fails, fall back to sync to avoid blocking the caller.
130
+ logger.warning(f"store_embeddings_async failed ({e}); falling back to sync.")
131
+ await asyncio.to_thread(store_embeddings, material_id, chunk_ids, chunks)
 
 
132
 
133
 
134
  def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
store.py CHANGED
@@ -816,14 +816,154 @@ def increment_daily_usage(user_id: str) -> None:
816
  logger.error(f"Failed to increment daily usage: {e}")
817
 
818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
  def check_and_increment_daily_limit(user_id: str, email: Optional[str] = None, limit: int = 20) -> bool:
820
  """
821
- Check limit and increment if allowed. Deprecated/kept for compatibility.
 
822
  """
823
- allowed = check_daily_limit(user_id, email, limit)
824
- if allowed and not (email and email in ADMIN_EMAILS):
825
- increment_daily_usage(user_id)
826
- return allowed
827
 
828
 
829
  def get_usage(user_id: str) -> dict:
 
816
  logger.error(f"Failed to increment daily usage: {e}")
817
 
818
 
819
+ def decrement_daily_usage(user_id: str) -> None:
820
+ """
821
+ Rollback helper β€” decrements the daily counter by 1 (clamped to 0).
822
+
823
+ Called when a generation request was reserved (incremented) but failed
824
+ before producing a usable result, so the user is not penalised for a
825
+ server-side error.
826
+ """
827
+ today = _get_today_date_str()
828
+
829
+ # ── Redis path β€” atomic DECR clamped to 0 ────────────────────────────────
830
+ r = get_redis()
831
+ if r is not None:
832
+ try:
833
+ rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
834
+ # DECR is atomic; clamp to 0 so we never go negative
835
+ current = r.decr(rkey)
836
+ if current < 0:
837
+ r.set(rkey, 0)
838
+ r.expire(rkey, DAILY_RATE_LIMIT_REDIS_TTL)
839
+ except Exception as e:
840
+ logger.warning("Redis decrement_daily_usage failed: %s", e)
841
+
842
+ # ── Supabase β€” keep DB in sync ────────────────────────────────────────────
843
+ try:
844
+ result = _robust_execute(
845
+ _table_supabase("profiles")
846
+ .select("daily_requests, last_request_date")
847
+ .eq("id", user_id)
848
+ )
849
+ if not result.data:
850
+ return
851
+ profile = result.data[0] if result.data else None
852
+ if not profile:
853
+ return
854
+
855
+ last_date = profile.get("last_request_date")
856
+ count = profile.get("daily_requests", 0) or 0
857
+ if last_date != today:
858
+ count = 0
859
+ new_count = max(0, count - 1)
860
+
861
+ _robust_execute(
862
+ _table_supabase("profiles")
863
+ .update({"daily_requests": new_count, "last_request_date": today})
864
+ .eq("id", user_id)
865
+ )
866
+ except Exception as e:
867
+ logger.error(f"Failed to decrement daily usage: {e}")
868
+
869
+
870
+ # Lua script: atomically check count < limit, then INCR + EXPIRE if allowed.
871
+ # Returns 1 (allowed and incremented) or 0 (limit exceeded, no change).
872
+ _ATOMIC_RATE_LIMIT_LUA = """
873
+ local key = KEYS[1]
874
+ local limit = tonumber(ARGV[1])
875
+ local ttl = tonumber(ARGV[2])
876
+ local count = tonumber(redis.call('GET', key) or 0)
877
+ if count >= limit then
878
+ return 0
879
+ end
880
+ redis.call('INCR', key)
881
+ redis.call('EXPIRE', key, ttl)
882
+ return 1
883
+ """
884
+
885
+
886
+ def atomic_check_and_increment_daily_limit(
887
+ user_id: str, email: Optional[str] = None, limit: int = 20
888
+ ) -> bool:
889
+ """
890
+ Atomically check the daily limit AND increment in one operation.
891
+
892
+ This eliminates the TOCTOU race condition that existed when `check_daily_limit`
893
+ and `increment_daily_usage` were called as two separate steps: concurrent
894
+ requests could both pass the check before either had incremented the counter.
895
+
896
+ Redis path: executes a Lua script so the GET + conditional INCR is one
897
+ indivisible command β€” Redis serialises all commands within a script.
898
+
899
+ Supabase fallback: increment first, then verify; rollback if over limit.
900
+ This is safe because Supabase operations are individually atomic (but not
901
+ as tight as the Lua approach under extreme concurrency).
902
+
903
+ Returns True if the request is allowed (counter was incremented),
904
+ False if the daily limit is already reached (counter unchanged).
905
+ """
906
+ # Admins are always allowed and never counted
907
+ if email and email in ADMIN_EMAILS:
908
+ return True
909
+
910
+ today = _get_today_date_str()
911
+
912
+ # ── Redis path: true atomic check-and-increment via Lua ───────────────────
913
+ r = get_redis()
914
+ if r is not None:
915
+ try:
916
+ rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
917
+ result = r.eval(_ATOMIC_RATE_LIMIT_LUA, 1, rkey, limit, DAILY_RATE_LIMIT_REDIS_TTL)
918
+ return bool(result) # 1 β†’ allowed, 0 β†’ exceeded
919
+ except Exception as e:
920
+ logger.warning(
921
+ "Redis atomic_check_and_increment failed: %s β€” falling back to Supabase", e
922
+ )
923
+
924
+ # ── Supabase fallback: increment-first strategy ───────────────────────────
925
+ try:
926
+ result = _robust_execute(
927
+ _table_supabase("profiles")
928
+ .select("daily_requests, last_request_date")
929
+ .eq("id", user_id)
930
+ )
931
+ if not result.data:
932
+ # No profile row yet β€” treat as first request (allowed)
933
+ _robust_execute(
934
+ _table_supabase("profiles")
935
+ .update({"daily_requests": 1, "last_request_date": today})
936
+ .eq("id", user_id)
937
+ )
938
+ return True
939
+
940
+ profile = result.data[0]
941
+ last_date = profile.get("last_request_date")
942
+ count = profile.get("daily_requests", 0) or 0
943
+ if last_date != today:
944
+ count = 0 # day rolled over β€” reset
945
+
946
+ if count >= limit:
947
+ return False # already at limit β€” do not increment
948
+
949
+ # Increment in Supabase
950
+ _robust_execute(
951
+ _table_supabase("profiles")
952
+ .update({"daily_requests": count + 1, "last_request_date": today})
953
+ .eq("id", user_id)
954
+ )
955
+ return True
956
+ except Exception as e:
957
+ logger.error(f"Supabase atomic_check_and_increment failed: {e}")
958
+ return True # fail open rather than block the user on a DB error
959
+
960
+
961
  def check_and_increment_daily_limit(user_id: str, email: Optional[str] = None, limit: int = 20) -> bool:
962
  """
963
+ Legacy wrapper kept for backward compatibility (used in rag/routes.py).
964
+ Delegates to the new atomic implementation.
965
  """
966
+ return atomic_check_and_increment_daily_limit(user_id, email, limit)
 
 
 
967
 
968
 
969
  def get_usage(user_id: str) -> dict:
summary_generator/routes.py CHANGED
@@ -9,7 +9,7 @@ from src.rag.rag import store_embeddings_async
9
  from src.store import (
10
  get_material, get_chunks, save_chunks, save_summary,
11
  get_summary as get_stored_summary, update_material_status,
12
- check_daily_limit, increment_daily_usage,
13
  )
14
  from src.dependencies import get_current_user_id, get_current_user
15
  from src.config import settings
@@ -25,15 +25,20 @@ async def generate_summary(
25
  user_id: str = Depends(get_current_user_id),
26
  current_user=Depends(get_current_user),
27
  ):
28
- # Rate limit check
29
  user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
30
- if not check_daily_limit(user_id, email=user_email, limit=20):
 
31
  raise HTTPException(429, "Daily limit of 20 requests reached. Come back tomorrow!")
32
 
33
  mat = get_material(body.material_id)
34
  if not mat:
 
 
35
  raise HTTPException(404, "Material not found")
36
  if mat.get("user_id") != user_id:
 
 
37
  raise HTTPException(403, "Access denied")
38
 
39
  try:
@@ -106,12 +111,15 @@ async def generate_summary(
106
  model_name=settings.model_name,
107
  )
108
 
109
- # Only increment limit if the summary was generated successfully and saved without error
110
- if not (user_email and user_email in settings.admin_emails if hasattr(settings, "admin_emails") else False):
111
- increment_daily_usage(user_id)
112
-
113
  return SummarizeResponse(summary=summary, time_taken=elapsed)
 
 
 
 
 
114
  except Exception as e:
 
 
115
  raise HTTPException(500, f"Summarization failed: {e}")
116
 
117
 
 
9
  from src.store import (
10
  get_material, get_chunks, save_chunks, save_summary,
11
  get_summary as get_stored_summary, update_material_status,
12
+ atomic_check_and_increment_daily_limit, decrement_daily_usage, ADMIN_EMAILS,
13
  )
14
  from src.dependencies import get_current_user_id, get_current_user
15
  from src.config import settings
 
25
  user_id: str = Depends(get_current_user_id),
26
  current_user=Depends(get_current_user),
27
  ):
28
+ # Atomically reserve a daily request slot before starting expensive generation.
29
  user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
30
+ is_admin = bool(user_email and user_email in ADMIN_EMAILS)
31
+ if not atomic_check_and_increment_daily_limit(user_id, email=user_email, limit=20):
32
  raise HTTPException(429, "Daily limit of 20 requests reached. Come back tomorrow!")
33
 
34
  mat = get_material(body.material_id)
35
  if not mat:
36
+ if not is_admin:
37
+ decrement_daily_usage(user_id)
38
  raise HTTPException(404, "Material not found")
39
  if mat.get("user_id") != user_id:
40
+ if not is_admin:
41
+ decrement_daily_usage(user_id)
42
  raise HTTPException(403, "Access denied")
43
 
44
  try:
 
111
  model_name=settings.model_name,
112
  )
113
 
 
 
 
 
114
  return SummarizeResponse(summary=summary, time_taken=elapsed)
115
+ except HTTPException:
116
+ # Refund on any HTTP exception raised inside the try block (e.g. 400).
117
+ if not is_admin:
118
+ decrement_daily_usage(user_id)
119
+ raise
120
  except Exception as e:
121
+ if not is_admin:
122
+ decrement_daily_usage(user_id)
123
  raise HTTPException(500, f"Summarization failed: {e}")
124
 
125