WhoIsLuwah commited on
Commit
274b7fb
·
1 Parent(s): b3fa71d

fix(worker): add recovery and bounded retries

Browse files
Files changed (5) hide show
  1. check_queue.py +5 -2
  2. db.py +38 -3
  3. main.py +69 -10
  4. run_worker.py +14 -17
  5. verify_pipeline.py +101 -4
check_queue.py CHANGED
@@ -11,14 +11,17 @@ c.table('saved_reels').delete().eq('url', 'https://www.instagram.com/reel/DIAGTE
11
  print('Test row deleted.')
12
 
13
  # Show any non-done rows
14
- res = c.table('saved_reels').select('id,url,status,source,created_at,error').in_('status', ['pending','processing','failed']).order('created_at', desc=True).limit(10).execute()
15
  rows = res.data or []
16
  print(f'Non-done rows in queue: {len(rows)}')
17
  for r in rows:
18
  url = (r['url'] or 'NO URL')[:60]
19
  err = (r.get('error') or '')[:60]
20
  src = (r.get('source') or '')[:8]
21
- print(f" {r['status']:<12} | {src:<8} | {url} | err={err}")
 
 
 
22
 
23
  # Also show total row count
24
  total = c.table('saved_reels').select('id', count='exact').execute()
 
11
  print('Test row deleted.')
12
 
13
  # Show any non-done rows
14
+ res = c.table('saved_reels').select('id,url,status,source,created_at,error,attempt_count,processing_started_at,next_attempt_at').in_('status', ['pending','processing','failed','cookies_expired','unsupported_format']).order('created_at', desc=True).limit(10).execute()
15
  rows = res.data or []
16
  print(f'Non-done rows in queue: {len(rows)}')
17
  for r in rows:
18
  url = (r['url'] or 'NO URL')[:60]
19
  err = (r.get('error') or '')[:60]
20
  src = (r.get('source') or '')[:8]
21
+ attempts = r.get("attempt_count") or 0
22
+ started = r.get("processing_started_at") or "-"
23
+ next_at = r.get("next_attempt_at") or "-"
24
+ print(f" {r['status']:<18} | tries={attempts} | started={started} | next={next_at} | {src:<8} | {url} | err={err}")
25
 
26
  # Also show total row count
27
  total = c.table('saved_reels').select('id', count='exact').execute()
db.py CHANGED
@@ -111,17 +111,27 @@ def claim_next_pending() -> Optional[dict]:
111
  return the raw row. Returns None if the queue is empty or another worker
112
  won the claim."""
113
  c = get_client()
 
114
  res = (c.table(TABLE).select("*")
115
- .eq("status", "pending").order("created_at").limit(1).execute())
 
 
116
  rows = res.data or []
117
  if not rows:
118
  return None
119
  row = rows[0]
120
- upd = (c.table(TABLE).update({"status": "processing"})
 
 
 
 
 
 
 
121
  .eq("id", row["id"]).eq("status", "pending").execute())
122
  if not upd.data:
123
  return None # lost the race
124
- return row
125
 
126
 
127
  def update_reel_result(reel_id: str, title: str, raw_transcript, post_caption,
@@ -134,6 +144,8 @@ def update_reel_result(reel_id: str, title: str, raw_transcript, post_caption,
134
  "extracted_json": extracted_json,
135
  "status": status,
136
  "error": None,
 
 
137
  }).eq("id", reel_id).execute()
138
 
139
 
@@ -142,6 +154,8 @@ def mark_failed_with_status(reel_id: str, error, status: str = "failed") -> None
142
  get_client().table(TABLE).update({
143
  "status": status,
144
  "error": str(error)[:500],
 
 
145
  }).eq("id", reel_id).execute()
146
 
147
 
@@ -150,6 +164,27 @@ def mark_failed(reel_id: str, error) -> None:
150
  mark_failed_with_status(reel_id, error, "failed")
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  def cluster_counts() -> list:
154
  res = get_client().table(TABLE).select("cluster").execute()
155
  counts = Counter((r.get("cluster") or "Unclustered") for r in (res.data or []))
 
111
  return the raw row. Returns None if the queue is empty or another worker
112
  won the claim."""
113
  c = get_client()
114
+ now = datetime.now(timezone.utc).isoformat()
115
  res = (c.table(TABLE).select("*")
116
+ .eq("status", "pending")
117
+ .or_(f"next_attempt_at.is.null,next_attempt_at.lte.{now}")
118
+ .order("created_at").limit(1).execute())
119
  rows = res.data or []
120
  if not rows:
121
  return None
122
  row = rows[0]
123
+ attempt_count = int(row.get("attempt_count") or 0) + 1
124
+ payload = {
125
+ "status": "processing",
126
+ "processing_started_at": now,
127
+ "next_attempt_at": None,
128
+ "attempt_count": attempt_count,
129
+ }
130
+ upd = (c.table(TABLE).update(payload)
131
  .eq("id", row["id"]).eq("status", "pending").execute())
132
  if not upd.data:
133
  return None # lost the race
134
+ return {**row, **payload}
135
 
136
 
137
  def update_reel_result(reel_id: str, title: str, raw_transcript, post_caption,
 
144
  "extracted_json": extracted_json,
145
  "status": status,
146
  "error": None,
147
+ "processing_started_at": None,
148
+ "next_attempt_at": None,
149
  }).eq("id", reel_id).execute()
150
 
151
 
 
154
  get_client().table(TABLE).update({
155
  "status": status,
156
  "error": str(error)[:500],
157
+ "processing_started_at": None,
158
+ "next_attempt_at": None,
159
  }).eq("id", reel_id).execute()
160
 
161
 
 
164
  mark_failed_with_status(reel_id, error, "failed")
165
 
166
 
167
+ def recover_stale_processing(stale_before: datetime) -> int:
168
+ """Return abandoned claims older than stale_before to the queue."""
169
+ res = (get_client().table(TABLE).update({
170
+ "status": "pending",
171
+ "error": "Recovered stale processing claim",
172
+ "processing_started_at": None,
173
+ }).eq("status", "processing")
174
+ .lt("processing_started_at", stale_before.astimezone(timezone.utc).isoformat())
175
+ .execute())
176
+ return len(res.data or [])
177
+
178
+
179
+ def schedule_retry(reel_id: str, error: str, next_attempt_at: datetime) -> None:
180
+ """Release a claim for a future retry while preserving its attempt count."""
181
+ get_client().table(TABLE).update({
182
+ "status": "pending",
183
+ "error": str(error)[:500],
184
+ "processing_started_at": None,
185
+ "next_attempt_at": next_attempt_at.astimezone(timezone.utc).isoformat(),
186
+ }).eq("id", reel_id).execute()
187
+
188
  def cluster_counts() -> list:
189
  res = get_client().table(TABLE).select("cluster").execute()
190
  counts = Counter((r.get("cluster") or "Unclustered") for r in (res.data or []))
main.py CHANGED
@@ -5,14 +5,16 @@ import uuid
5
  import threading
6
  import tempfile
7
  import logging
8
- from datetime import datetime, timezone
 
9
  from contextlib import asynccontextmanager
10
  from typing import List, Optional
11
  from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query, BackgroundTasks, Depends, Header
12
  from fastapi.middleware.cors import CORSMiddleware
13
  from pydantic import BaseModel, Field, field_validator
14
  import yt_dlp
15
- from groq import Groq
 
16
  from dotenv import load_dotenv
17
  import re
18
  import glob
@@ -51,6 +53,21 @@ if not GROQ_API_KEY:
51
 
52
  API_AUTH_TOKEN = os.getenv("API_AUTH_TOKEN")
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  def verify_api_key(x_api_key: Optional[str] = Header(None)):
55
  """Access gate: enforce token check if API_AUTH_TOKEN environment variable is set."""
56
  if API_AUTH_TOKEN:
@@ -70,7 +87,6 @@ def init_local_db():
70
  logger.info("Supabase configuration detected.")
71
 
72
  groq_client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
73
-
74
  # Initialize main database
75
  init_local_db()
76
 
@@ -83,6 +99,10 @@ async def lifespan(app: FastAPI):
83
  else:
84
  logger.info("ENABLE_WORKER=0 — queue worker disabled.")
85
  else:
 
 
 
 
86
  threading.Thread(target=_worker_loop, daemon=True).start()
87
  yield
88
 
@@ -727,6 +747,8 @@ def process_pending_reel(row: dict) -> None:
727
  logger.info(f"Worker processed reel {reel_id} ({url})")
728
  except HTTPException as e:
729
  logger.warning(f"Worker failed reel {reel_id}: {e.detail}")
 
 
730
  status = "failed"
731
  if e.status_code == 403:
732
  status = "cookies_expired"
@@ -735,9 +757,38 @@ def process_pending_reel(row: dict) -> None:
735
  db.mark_failed_with_status(reel_id, e.detail, status)
736
  except Exception as e:
737
  logger.error(f"Worker error on reel {reel_id}: {str(e)}")
 
 
738
  db.mark_failed_with_status(reel_id, str(e), "failed")
739
 
740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  def worker_tick() -> bool:
742
  """Process at most one pending reel. Returns True if one was claimed."""
743
  row = db.claim_next_pending()
@@ -747,7 +798,7 @@ def worker_tick() -> bool:
747
  return True
748
 
749
 
750
- def _worker_loop(poll_interval: float = 5.0, idle_interval: float = 20.0) -> None:
751
  """Background loop: drain the queue, backing off when it's empty."""
752
  logger.info("Queue worker started.")
753
  while True:
@@ -755,8 +806,10 @@ def _worker_loop(poll_interval: float = 5.0, idle_interval: float = 20.0) -> Non
755
  did_work = worker_tick()
756
  except Exception as e:
757
  logger.error(f"Worker tick crashed: {str(e)}")
758
- did_work = False
759
- time.sleep(poll_interval if did_work else idle_interval)
 
 
760
 
761
 
762
  @app.post("/extract/file", response_model=ExtractionResponse, dependencies=[Depends(verify_api_key)])
@@ -834,6 +887,10 @@ async def extract_text(payload: dict):
834
  return db_record
835
 
836
 
 
 
 
 
837
  BATCH_JOB = {
838
  "status": "idle", # idle | running | done | error
839
  "total": 0,
@@ -881,15 +938,18 @@ async def update_batch_job_status():
881
  processing += 1
882
  else:
883
  status = row.get("status") or "done"
884
- if status == "done":
885
  ok += 1
886
- elif status == "failed":
887
  failed += 1
888
  errors.append({"url": url, "detail": row.get("error") or "Unknown error"})
889
- else:
890
  processing += 1
891
  if status == "processing":
892
  current_url = url
 
 
 
893
 
894
  done = ok + failed
895
  BATCH_JOB.update({
@@ -1002,4 +1062,3 @@ async def get_reels_status(payload: dict):
1002
  db_rows.extend(res.data or [])
1003
 
1004
  return db_rows
1005
-
 
5
  import threading
6
  import tempfile
7
  import logging
8
+ import random
9
+ from datetime import datetime, timedelta, timezone
10
  from contextlib import asynccontextmanager
11
  from typing import List, Optional
12
  from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query, BackgroundTasks, Depends, Header
13
  from fastapi.middleware.cors import CORSMiddleware
14
  from pydantic import BaseModel, Field, field_validator
15
  import yt_dlp
16
+ from groq import Groq, APIConnectionError, APIStatusError
17
+ import httpx
18
  from dotenv import load_dotenv
19
  import re
20
  import glob
 
53
 
54
  API_AUTH_TOKEN = os.getenv("API_AUTH_TOKEN")
55
 
56
+
57
+ def _positive_int_env(name: str, default: int) -> int:
58
+ try:
59
+ value = int(os.getenv(name, str(default)))
60
+ if value <= 0:
61
+ raise ValueError
62
+ return value
63
+ except ValueError:
64
+ logger.warning("%s must be a positive integer; using %s.", name, default)
65
+ return default
66
+
67
+
68
+ WORKER_STALE_MINUTES = _positive_int_env("WORKER_STALE_MINUTES", 30)
69
+ MAX_WORKER_ATTEMPTS = 3
70
+
71
  def verify_api_key(x_api_key: Optional[str] = Header(None)):
72
  """Access gate: enforce token check if API_AUTH_TOKEN environment variable is set."""
73
  if API_AUTH_TOKEN:
 
87
  logger.info("Supabase configuration detected.")
88
 
89
  groq_client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
 
90
  # Initialize main database
91
  init_local_db()
92
 
 
99
  else:
100
  logger.info("ENABLE_WORKER=0 — queue worker disabled.")
101
  else:
102
+ recovered = db.recover_stale_processing(
103
+ datetime.now(timezone.utc) - timedelta(minutes=WORKER_STALE_MINUTES)
104
+ )
105
+ logger.info("Recovered %s stale queue item(s).", recovered)
106
  threading.Thread(target=_worker_loop, daemon=True).start()
107
  yield
108
 
 
747
  logger.info(f"Worker processed reel {reel_id} ({url})")
748
  except HTTPException as e:
749
  logger.warning(f"Worker failed reel {reel_id}: {e.detail}")
750
+ if is_retryable_worker_error(e) and _schedule_worker_retry(row, e.detail):
751
+ return
752
  status = "failed"
753
  if e.status_code == 403:
754
  status = "cookies_expired"
 
757
  db.mark_failed_with_status(reel_id, e.detail, status)
758
  except Exception as e:
759
  logger.error(f"Worker error on reel {reel_id}: {str(e)}")
760
+ if is_retryable_worker_error(e) and _schedule_worker_retry(row, str(e)):
761
+ return
762
  db.mark_failed_with_status(reel_id, str(e), "failed")
763
 
764
 
765
+ def is_retryable_worker_error(error: Exception) -> bool:
766
+ """Classify only known transient API and network failures as retryable."""
767
+ if isinstance(error, HTTPException):
768
+ return error.status_code == 429 or 500 <= error.status_code <= 599
769
+ if isinstance(error, APIStatusError):
770
+ return error.status_code == 429 or 500 <= error.status_code <= 599
771
+ return isinstance(error, (APIConnectionError, httpx.TimeoutException, httpx.NetworkError))
772
+
773
+
774
+ def retry_delay_seconds(attempt_count: int, jitter: float | None = None) -> float:
775
+ """Return bounded exponential retry delay: about 30s, then about 2m."""
776
+ base = 30 * (4 ** max(0, attempt_count - 1))
777
+ factor = random.uniform(0.9, 1.1) if jitter is None else jitter
778
+ return base * factor
779
+
780
+
781
+ def _schedule_worker_retry(row: dict, error: str) -> bool:
782
+ attempt_count = int(row.get("attempt_count") or 0)
783
+ if attempt_count >= MAX_WORKER_ATTEMPTS:
784
+ return False
785
+ next_attempt = datetime.now(timezone.utc) + timedelta(seconds=retry_delay_seconds(attempt_count))
786
+ db.schedule_retry(row["id"], error, next_attempt)
787
+ logger.warning("Scheduled retry %s/%s for reel %s at %s.",
788
+ attempt_count + 1, MAX_WORKER_ATTEMPTS, row["id"], next_attempt.isoformat())
789
+ return True
790
+
791
+
792
  def worker_tick() -> bool:
793
  """Process at most one pending reel. Returns True if one was claimed."""
794
  row = db.claim_next_pending()
 
798
  return True
799
 
800
 
801
+ def _worker_loop(idle_interval: float = 20.0) -> None:
802
  """Background loop: drain the queue, backing off when it's empty."""
803
  logger.info("Queue worker started.")
804
  while True:
 
806
  did_work = worker_tick()
807
  except Exception as e:
808
  logger.error(f"Worker tick crashed: {str(e)}")
809
+ time.sleep(idle_interval)
810
+ continue
811
+ if not did_work:
812
+ time.sleep(idle_interval)
813
 
814
 
815
  @app.post("/extract/file", response_model=ExtractionResponse, dependencies=[Depends(verify_api_key)])
 
887
  return db_record
888
 
889
 
890
+ ACTIVE_REEL_STATUSES = frozenset({"pending", "processing"})
891
+ SUCCESS_REEL_STATUSES = frozenset({"done"})
892
+ FAILURE_REEL_STATUSES = frozenset({"failed", "cookies_expired", "unsupported_format"})
893
+
894
  BATCH_JOB = {
895
  "status": "idle", # idle | running | done | error
896
  "total": 0,
 
938
  processing += 1
939
  else:
940
  status = row.get("status") or "done"
941
+ if status in SUCCESS_REEL_STATUSES:
942
  ok += 1
943
+ elif status in FAILURE_REEL_STATUSES:
944
  failed += 1
945
  errors.append({"url": url, "detail": row.get("error") or "Unknown error"})
946
+ elif status in ACTIVE_REEL_STATUSES:
947
  processing += 1
948
  if status == "processing":
949
  current_url = url
950
+ else:
951
+ failed += 1
952
+ errors.append({"url": url, "detail": f"Unknown queue status: {status}"})
953
 
954
  done = ok + failed
955
  BATCH_JOB.update({
 
1062
  db_rows.extend(res.data or [])
1063
 
1064
  return db_rows
 
run_worker.py CHANGED
@@ -14,29 +14,26 @@ parent_env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(_
14
  if os.path.exists(parent_env_path):
15
  load_dotenv(parent_env_path)
16
 
17
- from main import worker_tick
 
 
 
 
18
 
19
  if __name__ == "__main__":
20
  logger.info("Standalone reels queue worker started locally.")
21
 
22
- # Auto-update yt-dlp quietly at startup to bypass Meta/Instagram scraper blocks
23
- logger.info("Checking for yt-dlp updates...")
24
- try:
25
- import subprocess
26
- subprocess.run(
27
- [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
28
- stdout=subprocess.DEVNULL,
29
- stderr=subprocess.DEVNULL,
30
- check=True
31
- )
32
- logger.info("yt-dlp is up to date.")
33
- except Exception as ue:
34
- logger.warning(f"Could not auto-update yt-dlp: {str(ue)}")
35
-
36
  if not os.getenv("SUPABASE_URL") or not os.getenv("SUPABASE_SERVICE_KEY"):
37
  logger.error("SUPABASE_URL / SUPABASE_SERVICE_KEY env vars not set. Exiting.")
38
  sys.exit(1)
39
 
 
 
 
 
 
40
  drain_mode = "--drain" in sys.argv
41
  if drain_mode:
42
  logger.info("Running in DRAIN mode. Will exit once queue is empty.")
@@ -54,5 +51,5 @@ if __name__ == "__main__":
54
  logger.info("No more pending reels in queue. Exiting drain worker.")
55
  break
56
 
57
- time.sleep(5.0 if did_work else 20.0)
58
-
 
14
  if os.path.exists(parent_env_path):
15
  load_dotenv(parent_env_path)
16
 
17
+ from datetime import datetime, timedelta, timezone
18
+ import yt_dlp
19
+
20
+ from main import WORKER_STALE_MINUTES, worker_tick
21
+ from db import recover_stale_processing
22
 
23
  if __name__ == "__main__":
24
  logger.info("Standalone reels queue worker started locally.")
25
 
26
+ logger.info("yt-dlp version: %s", getattr(yt_dlp.version, "__version__", "unknown"))
27
+
 
 
 
 
 
 
 
 
 
 
 
 
28
  if not os.getenv("SUPABASE_URL") or not os.getenv("SUPABASE_SERVICE_KEY"):
29
  logger.error("SUPABASE_URL / SUPABASE_SERVICE_KEY env vars not set. Exiting.")
30
  sys.exit(1)
31
 
32
+ recovered = recover_stale_processing(
33
+ datetime.now(timezone.utc) - timedelta(minutes=WORKER_STALE_MINUTES)
34
+ )
35
+ logger.info("Recovered %s stale queue item(s).", recovered)
36
+
37
  drain_mode = "--drain" in sys.argv
38
  if drain_mode:
39
  logger.info("Running in DRAIN mode. Will exit once queue is empty.")
 
51
  logger.info("No more pending reels in queue. Exiting drain worker.")
52
  break
53
 
54
+ if not did_work:
55
+ time.sleep(20.0)
verify_pipeline.py CHANGED
@@ -297,7 +297,7 @@ def test_worker_tick_failure():
297
  orig = (main.db.claim_next_pending, main.download_and_extract_audio, main.db.mark_failed, main.db.mark_failed_with_status)
298
  main.db.claim_next_pending = lambda: {"id": "row2", "url": "https://www.instagram.com/reel/F/"}
299
  def _boom(url):
300
- raise HTTPException(status_code=500, detail="Meta blocking request")
301
  main.download_and_extract_audio = _boom
302
 
303
  mock_mark = lambda reel_id, error, status="failed": captured.update({"id": reel_id, "error": str(error), "status": status})
@@ -703,6 +703,103 @@ def test_api_auth_token_check():
703
  main.API_AUTH_TOKEN = orig_token
704
 
705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  if __name__ == "__main__":
707
  print("--- Starting Transcriber Pipeline Test ---")
708
  test_health()
@@ -734,8 +831,8 @@ if __name__ == "__main__":
734
  test_worker_tick_invalid_url()
735
  test_download_cleanup_on_failure()
736
  test_api_auth_token_check()
 
 
 
737
  print("--- All tests completed successfully! ---")
738
  sys.exit(0)
739
-
740
-
741
-
 
297
  orig = (main.db.claim_next_pending, main.download_and_extract_audio, main.db.mark_failed, main.db.mark_failed_with_status)
298
  main.db.claim_next_pending = lambda: {"id": "row2", "url": "https://www.instagram.com/reel/F/"}
299
  def _boom(url):
300
+ raise HTTPException(status_code=400, detail="Meta blocking request")
301
  main.download_and_extract_audio = _boom
302
 
303
  mock_mark = lambda reel_id, error, status="failed": captured.update({"id": reel_id, "error": str(error), "status": status})
 
703
  main.API_AUTH_TOKEN = orig_token
704
 
705
 
706
+
707
+ def test_batch_terminal_statuses():
708
+ print("Testing all terminal and unknown batch statuses...")
709
+ import asyncio, main
710
+ original_client = main.db.get_client
711
+ previous = dict(main.BATCH_JOB)
712
+
713
+ class Query:
714
+ def select(self, *args): return self
715
+ def in_(self, column, values): return self
716
+ def execute(self):
717
+ return type("Result", (), {"data": [{"url": "u", "status": self.status, "error": "boom"}]})()
718
+
719
+ try:
720
+ for status in ("cookies_expired", "unsupported_format", "mystery"):
721
+ query = Query()
722
+ query.status = status
723
+ main.db.get_client = lambda q=query: type("Client", (), {"table": lambda self, name: q})()
724
+ main.BATCH_JOB.update({"status": "running", "urls": ["u"], "finished_at": None})
725
+ asyncio.run(main.update_batch_job_status())
726
+ assert main.BATCH_JOB["status"] == "done"
727
+ assert main.BATCH_JOB["failed"] == 1
728
+ if status == "mystery":
729
+ assert "Unknown queue status: mystery" in main.BATCH_JOB["errors"][0]["detail"]
730
+ finally:
731
+ main.db.get_client = original_client
732
+ main.BATCH_JOB.clear()
733
+ main.BATCH_JOB.update(previous)
734
+ print("[OK] terminal batch statuses passed!")
735
+
736
+
737
+ def test_worker_retry_policy():
738
+ print("Testing bounded worker retry policy...")
739
+ import main, httpx
740
+ from fastapi import HTTPException
741
+ assert main.is_retryable_worker_error(HTTPException(status_code=429))
742
+ assert main.is_retryable_worker_error(HTTPException(status_code=503))
743
+ assert main.is_retryable_worker_error(httpx.ConnectError("offline"))
744
+ assert not main.is_retryable_worker_error(HTTPException(status_code=400))
745
+ assert not main.is_retryable_worker_error(HTTPException(status_code=403))
746
+ assert not main.is_retryable_worker_error(HTTPException(status_code=415))
747
+ assert 27 <= main.retry_delay_seconds(1, .9) <= 33
748
+ assert 108 <= main.retry_delay_seconds(2, .9) <= 132
749
+ calls = []
750
+ original = main.db.schedule_retry
751
+ main.db.schedule_retry = lambda reel_id, error, when: calls.append((reel_id, error, when))
752
+ try:
753
+ assert main._schedule_worker_retry({"id": "a", "attempt_count": 1}, "temporary")
754
+ assert main._schedule_worker_retry({"id": "b", "attempt_count": 2}, "temporary")
755
+ assert not main._schedule_worker_retry({"id": "c", "attempt_count": 3}, "temporary")
756
+ assert [call[0] for call in calls] == ["a", "b"]
757
+ finally:
758
+ main.db.schedule_retry = original
759
+ print("[OK] bounded retry policy passed!")
760
+
761
+
762
+ def test_db_queue_metadata_contract():
763
+ print("Testing queue metadata data-layer contract...")
764
+ import db
765
+ from datetime import datetime, timezone
766
+
767
+ class Result:
768
+ def __init__(self, data): self.data = data
769
+ class Query:
770
+ def __init__(self):
771
+ self.calls = []
772
+ self.selected_rows = [{"id": "r1", "attempt_count": 1}]
773
+ self.updated = None
774
+ def select(self, *args): self.calls.append(("select", args)); return self
775
+ def update(self, payload): self.updated = payload; self.calls.append(("update", payload)); return self
776
+ def eq(self, *args): self.calls.append(("eq", args)); return self
777
+ def or_(self, value): self.calls.append(("or", value)); return self
778
+ def order(self, *args, **kwargs): return self
779
+ def limit(self, value): return self
780
+ def lt(self, *args): self.calls.append(("lt", args)); return self
781
+ def execute(self):
782
+ return Result(self.selected_rows if self.updated is None else [{"id": "r1"}])
783
+
784
+ query = Query()
785
+ original = db.get_client
786
+ db.get_client = lambda: type("Client", (), {"table": lambda self, name: query})()
787
+ try:
788
+ claimed = db.claim_next_pending()
789
+ assert claimed["attempt_count"] == 2
790
+ assert any(call[0] == "or" and "next_attempt_at.is.null" in call[1] and "next_attempt_at.lte." in call[1] for call in query.calls)
791
+ assert query.updated["status"] == "processing"
792
+ assert query.updated["processing_started_at"]
793
+ assert query.updated["next_attempt_at"] is None
794
+ query.calls.clear()
795
+ assert db.recover_stale_processing(datetime.now(timezone.utc)) == 1
796
+ assert ("eq", ("status", "processing")) in query.calls
797
+ assert any(call[0] == "lt" and call[1][0] == "processing_started_at" for call in query.calls)
798
+ finally:
799
+ db.get_client = original
800
+ print("[OK] queue metadata contract passed!")
801
+
802
+
803
  if __name__ == "__main__":
804
  print("--- Starting Transcriber Pipeline Test ---")
805
  test_health()
 
831
  test_worker_tick_invalid_url()
832
  test_download_cleanup_on_failure()
833
  test_api_auth_token_check()
834
+ test_batch_terminal_statuses()
835
+ test_worker_retry_policy()
836
+ test_db_queue_metadata_contract()
837
  print("--- All tests completed successfully! ---")
838
  sys.exit(0)