Spaces:
Sleeping
Sleeping
Commit ·
274b7fb
1
Parent(s): b3fa71d
fix(worker): add recovery and bounded retries
Browse files- check_queue.py +5 -2
- db.py +38 -3
- main.py +69 -10
- run_worker.py +14 -17
- 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
| 116 |
rows = res.data or []
|
| 117 |
if not rows:
|
| 118 |
return None
|
| 119 |
row = rows[0]
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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(
|
| 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 |
-
|
| 759 |
-
|
|
|
|
|
|
|
| 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
|
| 885 |
ok += 1
|
| 886 |
-
elif status
|
| 887 |
failed += 1
|
| 888 |
errors.append({"url": url, "detail": row.get("error") or "Unknown error"})
|
| 889 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
if __name__ == "__main__":
|
| 20 |
logger.info("Standalone reels queue worker started locally.")
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 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 |
-
|
| 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=
|
| 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)
|
|
|
|
|
|
|
|
|