Download app/api/jobs.py from sitcod3/modul-x: direct link, hf CLI and curl.
- Browser
- Download file 15 kB
-
https://huggingface.co/spaces/sitcod3/modul-x/resolve/main/app/api/jobs.py
- Command line
-
hf download hf://spaces/sitcod3/modul-x/app/api/jobs.py
-
curl -L -o jobs.py https://huggingface.co/spaces/sitcod3/modul-x/resolve/main/app/api/jobs.py
15 kB
| """Server-driven bulk job runner β the fix for the "submitting client is gone" | |
| failure class (10 of the last 15 failed CSV runs on 2026-10-20/21). | |
| Before this module the BROWSER orchestrated every bulk run: it chunked the | |
| file, submitted each chunk to /validate/bulk, polled the engine, mirrored rows | |
| into verifier_checks and finalized the job. If the tab closed, throttled or | |
| slept mid-run, the job orphaned in 'processing' until the reaper closed it and | |
| refunded the unprocessed remainder. The engine itself was healthy β its chunk | |
| task even completed 500/500 while the browser row froze at 0/3000. | |
| Contract (v2): | |
| POST /api/v1/jobs {job_id, emails, deep?, final?} | |
| - JWT required (productFetch always attaches the Supabase session token). | |
| - verifier_jobs row must exist, belong to the JWT subject and still be | |
| open (queued/processing). | |
| - A background task validates the list in chunks (bulk_sync_chunk_size), | |
| upserts verifier_checks rows after EVERY chunk, heartbeats verifier_jobs | |
| (processed_count + counts + updated_at) so the dashboard and the reaper | |
| both see live progress, and β on the final batch β flips the job to | |
| 'completed' and refunds risky rows server-side. | |
| - Any chunk-level failure finalizes the job as 'error' and refunds BOTH | |
| the risky rows and the unprocessed remainder (same ledger mechanics as | |
| the client finalize and the pg_cron reaper; idempotent via | |
| refunded_credits). | |
| The client polls verifier_jobs directly after accepting 202 β it no longer | |
| needs to stay alive for the run to finish. | |
| """ | |
| import asyncio | |
| import json | |
| import logging | |
| import time | |
| from typing import List, Optional | |
| from fastapi import APIRouter, HTTPException, Request | |
| from pydantic import BaseModel | |
| from ..auth import verify_supabase_jwt | |
| from ..config import settings | |
| from ..db import repo | |
| from ..models import ValidationResult | |
| from ..validator.engine import engine | |
| logger = logging.getLogger("emailvalidator.jobs") | |
| router = APIRouter() | |
| # Strong references so the event loop cannot GC an in-flight runner, and a | |
| # semaphore so concurrent job submissions cannot spawn unbounded SMTP work. | |
| _job_tasks: set[asyncio.Task] = set() | |
| _job_semaphore = asyncio.Semaphore(settings.bulk_concurrency) | |
| GROUPS = ("good", "risky", "bad") | |
| class JobRequest(BaseModel): | |
| job_id: str | |
| emails: List[str] | |
| deep: bool = True | |
| final: bool = True | |
| user_id: Optional[str] = None # informational; ownership comes from the JWT | |
| # ---- classification (mirror of src/lib/verifier.ts classify()) ------------ | |
| def classify_engine_result(r: ValidationResult) -> dict: | |
| """Map an engine ValidationResult to the verifier_checks row shape the | |
| dashboard renders. Mirrors the frontend classify() branch order so a row | |
| never disagrees with a client-side re-classification: | |
| disposable β bad | dead domain β bad | dns inconclusive β risky | | |
| valid+smtp β good | engine invalid β bad | catch-all β risky | else risky. | |
| """ | |
| disposable = bool(getattr(r.disposable, "is_disposable", False)) | |
| catch_all = bool(getattr(r.catch_all, "is_catch_all", False)) | |
| dns = r.dns | |
| status = (r.status.value if hasattr(r.status, "value") else str(r.status)) or "unknown" | |
| checks_run = r.checks_run or [] | |
| dns_error = (dns.error or "") if dns else "" | |
| dns_failure = dns_error == "dns_timeout" or "timeout" in dns_error.lower() | |
| a_fallback_only = bool(dns and dns.domain_exists and not dns.has_mx_records) | |
| smtp_verified = bool(r.smtp and r.smtp.verified is True) | |
| reason = r.reason_code | |
| if disposable: | |
| out_status, result, grp = "disposable", "Disposable", "bad" | |
| reason = reason or "disposable" | |
| elif dns is not None and dns.domain_exists is False: | |
| # NXDOMAIN is definitive (the stale-build regression that showed a | |
| # 0% Bad column on a 3000-row list must never resurrect server-side). | |
| out_status, result, grp = "invalid", "Invalid", "bad" | |
| reason = reason or "nxdomain" | |
| elif dns_failure or a_fallback_only: | |
| out_status, result, grp = "unknown", "Unknown", "risky" | |
| reason = reason or ("dns_timeout" if dns_failure else "a_fallback_only") | |
| elif status == "valid" and smtp_verified: | |
| out_status, result, grp = "valid", "Ok", "good" | |
| elif status == "invalid": | |
| out_status, result, grp = "invalid", "Invalid", "bad" | |
| elif catch_all: | |
| out_status, result, grp = "catch_all", "Catch-all", "risky" | |
| reason = reason or "catch_all_detected" | |
| else: | |
| out_status, result, grp = "unknown", "Unknown", "risky" | |
| reason = reason or "smtp_unavailable" | |
| score = r.scoring.score if r.scoring is not None else None | |
| return { | |
| "email": r.email, | |
| "status": out_status, | |
| "result": result, | |
| "grp": grp, | |
| "score": score, | |
| "reason": reason, | |
| "pass_through": None, | |
| "checks_run": checks_run, | |
| "verified_at": r.validated_at, | |
| "run_id": r.run_id, | |
| "validator_version": r.validator_version, | |
| } | |
| # ---- persistence helpers (service-role writes, best-effort like repo) ------ | |
| async def _write_checks(rows: List[dict]) -> None: | |
| if not rows: | |
| return | |
| try: | |
| await asyncio.to_thread(repo.write_verifier_checks, rows) | |
| except Exception as exc: # never lose a run to one write blip | |
| logger.warning("job_checks_write_failed: %s", exc) | |
| async def _update_job(job_id: str, **fields) -> None: | |
| try: | |
| await asyncio.to_thread(repo.update_verifier_job, job_id, fields) | |
| except Exception as exc: | |
| logger.warning("job_update_failed job=%s: %s", job_id, exc) | |
| async def _finalize_failed(job_id: str, error: str, counts: dict, processed: int) -> None: | |
| """Close a failed job and refund risky + unprocessed server-side. | |
| Mirrors the ledger mechanics of public.refund_job_risky() (migration | |
| 20261020000000) minus its auth.uid() guard β the engine writes with the | |
| service-role key, under which auth.uid() is null and the SQL function | |
| would raise. Idempotency comes from verifier_jobs.refunded_credits. | |
| """ | |
| await _update_job( | |
| job_id, | |
| status="error", | |
| error=error[:500], | |
| processed_count=processed, | |
| valid_count=counts.get("good", 0), | |
| risky_count=counts.get("risky", 0), | |
| bad_count=counts.get("bad", 0), | |
| ) | |
| try: | |
| job = await asyncio.to_thread(repo.get_verifier_job, job_id) | |
| if not job: | |
| return | |
| total = int(job.get("total_count") or 0) | |
| charged = int(job.get("credits_charged") or 0) | |
| refunded = int(job.get("refunded_credits") or 0) | |
| risky = int(job.get("risky_count") or 0) | |
| unprocessed = max(total - int(job.get("processed_count") or 0), 0) | |
| due = min(risky + unprocessed, charged) - refunded | |
| if due > 0: | |
| await asyncio.to_thread(repo.refund_credits, job["user_id"], due, | |
| "Auto-refund β risky and unprocessed results are not billed") | |
| await _update_job(job_id, refunded_credits=refunded + due) | |
| except Exception as exc: | |
| logger.warning("job_refund_failed job=%s: %s", job_id, exc) | |
| # ---- the runner ------------------------------------------------------------ | |
| async def _run_verifier_job(job_id: str, user_id: str, emails: List[str], deep: bool, | |
| final: bool) -> None: | |
| started = time.monotonic() | |
| counts = {g: 0 for g in GROUPS} | |
| processed = 0 | |
| # Defensive dedupe (the frontend already deduped; a client bug must not | |
| # double-write rows β (job_id, email) upsert would silently dedupe them | |
| # while counts drift). | |
| seen: set[str] = set() | |
| ordered: List[str] = [] | |
| for e in emails: | |
| e = (e or "").strip().lower() | |
| if e and "@" in e and e not in seen: | |
| seen.add(e) | |
| ordered.append(e) | |
| emails = ordered | |
| total = len(emails) | |
| if total == 0: | |
| await _finalize_failed(job_id, "No valid addresses reached the engine.", counts, 0) | |
| return | |
| chunk_size = max(int(settings.bulk_sync_chunk_size or 50), 10) | |
| async with _job_semaphore: | |
| try: | |
| await _update_job(job_id, status="processing") | |
| for i in range(0, total, chunk_size): | |
| chunk = emails[i:i + chunk_size] | |
| try: | |
| results = await engine.validate_batch(chunk, deep=deep) | |
| except Exception as exc: # one bad stage must not kill the job | |
| logger.warning("job_chunk_failed job=%s: %s", job_id, exc) | |
| await _finalize_failed( | |
| job_id, | |
| f"Engine chunk failed after {processed}/{total} addresses: {exc}", | |
| counts, processed, | |
| ) | |
| return | |
| if len(results) != len(chunk): | |
| await _finalize_failed( | |
| job_id, | |
| f"Engine returned {len(results)} of {len(chunk)} results for one chunk", | |
| counts, processed, | |
| ) | |
| return | |
| rows = [classify_engine_result(r) for r in results] | |
| await _write_checks(rows) | |
| for row in rows: | |
| counts[row["grp"]] = counts.get(row["grp"], 0) + 1 | |
| processed += len(chunk) | |
| # Heartbeat + live progress in one write; the dashboard polls | |
| # this row instead of the engine, and the reaper reads it. | |
| await _update_job( | |
| job_id, | |
| processed_count=processed, | |
| valid_count=counts["good"], | |
| risky_count=counts["risky"], | |
| bad_count=counts["bad"], | |
| ) | |
| if final: | |
| await _update_job( | |
| job_id, | |
| status="completed", | |
| error=None, | |
| processed_count=processed, | |
| valid_count=counts["good"], | |
| risky_count=counts["risky"], | |
| bad_count=counts["bad"], | |
| ) | |
| # Risky rows are never billed; the completed-job refund covers | |
| # exactly the risky_count (unprocessed term is zero by design). | |
| try: | |
| job = await asyncio.to_thread(repo.get_verifier_job, job_id) | |
| if job: | |
| charged = int(job.get("credits_charged") or 0) | |
| refunded = int(job.get("refunded_credits") or 0) | |
| due = min(counts["risky"], charged) - refunded | |
| if due > 0: | |
| await asyncio.to_thread( | |
| repo.refund_credits, job["user_id"], due, | |
| "Auto-refund β risky results are not billed") | |
| await _update_job(job_id, refunded_credits=refunded + due) | |
| except Exception as exc: | |
| logger.warning("job_risky_refund_failed job=%s: %s", job_id, exc) | |
| logger.info("job_batch_done job=%s processed=%s/%s final=%s secs=%.1f", | |
| job_id, processed, total, final, time.monotonic() - started) | |
| except Exception as exc: # absolute safety net | |
| logger.exception("job_runner_crashed job=%s", job_id) | |
| await _finalize_failed(job_id, f"Runner crashed: {exc}", counts, processed) | |
| # ---- routes ---------------------------------------------------------------- | |
| async def create_server_job(request: Request, payload: JobRequest): | |
| """Accept a bulk job for server-side processing (202 + DB polling).""" | |
| token = request.headers.get("authorization", "") | |
| token = token[7:].strip() if token.lower().startswith("bearer ") else "" | |
| if not token: | |
| raise HTTPException(401, "Missing bearer token β sign in first.") | |
| try: | |
| claims = await verify_supabase_jwt(token) | |
| except Exception as exc: | |
| raise HTTPException(401, f"Invalid or expired token: {exc}") | |
| user_id = claims.get("sub", "") | |
| if not user_id: | |
| raise HTTPException(401, "Token has no subject claim.") | |
| if not payload.emails: | |
| raise HTTPException(400, "emails must not be empty.") | |
| if not settings.supabase_configured: | |
| raise HTTPException(503, "Server persistence is not configured (SUPABASE_URL/SERVICE key).") | |
| job = await asyncio.to_thread(repo.get_verifier_job, payload.job_id) | |
| if not job: | |
| raise HTTPException(404, f"Job {payload.job_id} not found β create it first.") | |
| if str(job.get("user_id")) != user_id: | |
| # Ownership mismatch: identical response for missing/foreign jobs. | |
| raise HTTPException(404, f"Job {payload.job_id} not found for this account.") | |
| if job.get("status") not in ("queued", "processing"): | |
| raise HTTPException(409, f"Job {payload.job_id} is already {job.get('status')}.") | |
| if not payload.emails: | |
| raise HTTPException(400, "emails must not be empty.") | |
| # Cap one submission so a giant file cannot pin the runner forever: the | |
| # client splits into final-flagged batches (20k β 600 KB JSON). | |
| MAX_PER_CALL = 20000 | |
| emails = payload.emails[:MAX_PER_CALL] | |
| task = asyncio.create_task( | |
| _run_verifier_job(payload.job_id, user_id, emails, payload.deep, payload.final) | |
| ) | |
| _job_tasks.add(task) | |
| task.add_done_callback(_job_tasks.discard) | |
| return { | |
| "accepted": len(emails), | |
| "job_id": payload.job_id, | |
| "status": "processing", | |
| "final": payload.final, | |
| "message": "Processing server-side β poll verifier_jobs for progress.", | |
| } | |
| async def job_status(job_id: str, request: Request): | |
| """Lightweight engine-side echo of the job row (debug aid; the dashboard | |
| polls the DB directly and does not depend on this).""" | |
| token = request.headers.get("authorization", "") | |
| token = token[7:].strip() if token.lower().startswith("bearer ") else "" | |
| if not token: | |
| raise HTTPException(401, "Missing bearer token β sign in first.") | |
| try: | |
| claims = await verify_supabase_jwt(token) | |
| except Exception as exc: | |
| raise HTTPException(401, f"Invalid or expired token: {exc}") | |
| job = await asyncio.to_thread(repo.get_verifier_job, job_id) | |
| if not job or str(job.get("user_id")) != claims.get("sub", ""): | |
| raise HTTPException(404, f"Job {job_id} not found for this account.") | |
| return { | |
| "job_id": job_id, | |
| "status": job.get("status"), | |
| "total": job.get("total_count"), | |
| "processed": job.get("processed_count"), | |
| "valid": job.get("valid_count"), | |
| "risky": job.get("risky_count"), | |
| "bad": job.get("bad_count"), | |
| } | |