sitcod3 commited on
Commit
d82b7e3
·
verified ·
1 Parent(s): 9649ce6

Upload folder using huggingface_hub

Browse files
app/api/routes.py CHANGED
@@ -15,6 +15,12 @@ from ..utils.cache import get_cache, set_cache
15
  from ..config import settings
16
  from ..db import repo
17
 
 
 
 
 
 
 
18
  router = APIRouter()
19
 
20
 
@@ -184,31 +190,47 @@ async def validate_bulk(request: BulkEmailRequest):
184
  task_id = uuid.uuid4().hex
185
  print(f"[BULK] Processing {len(request.emails)} emails in background (task {task_id})")
186
 
187
- async def _run_and_store():
 
 
 
188
  try:
189
- results = await process_bulk_sync(request.emails, request.webhook_url, deep=request.deep)
190
- if settings.enable_db_persistence:
191
- try:
192
- repo.finish_bulk_job(task_id, results)
193
- except Exception as exc:
194
- print(f"[BULK] finish_bulk_job failed: {exc}")
195
- await set_cache(
196
- f"bulk_result:{task_id}",
197
- json.dumps({"status": "completed", "total": len(request.emails),
198
- "results": results}),
199
- ttl=settings.redis_cache_ttl,
200
- )
201
- print(f"[BULK] task {task_id} completed: {len(results)} results")
202
  except Exception as exc:
203
- print(f"[BULK] task {task_id} failed: {exc}")
204
- await set_cache(
205
- f"bulk_result:{task_id}",
206
- json.dumps({"status": "error", "error": str(exc),
207
- "results": []}),
208
- ttl=settings.redis_cache_ttl,
209
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
- asyncio.create_task(_run_and_store())
 
 
 
 
212
 
213
  return JSONResponse(content={
214
  "task_id": task_id,
@@ -288,6 +310,11 @@ async def get_bulk_status(task_id: str):
288
  total=job.get("total"),
289
  results=_normalize_results(job.get("results"))
290
  )
 
 
 
 
 
291
  raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
292
 
293
  try:
 
15
  from ..config import settings
16
  from ..db import repo
17
 
18
+ # Strong references to in-flight background bulk jobs (the event loop only
19
+ # keeps weak ones, so a task can vanish mid-run and its results are lost), and
20
+ # a cap so a burst of bulk requests cannot spawn unbounded SMTP work.
21
+ _background_tasks: set[asyncio.Task] = set()
22
+ _bulk_semaphore = asyncio.Semaphore(settings.bulk_concurrency)
23
+
24
  router = APIRouter()
25
 
26
 
 
190
  task_id = uuid.uuid4().hex
191
  print(f"[BULK] Processing {len(request.emails)} emails in background (task {task_id})")
192
 
193
+ # Record the job BEFORE processing: without this row, finish_bulk_job is an
194
+ # UPDATE that matches nothing, and in-flight polls hit the "Redis down"
195
+ # branch and 404 — which the frontend treats as terminal.
196
+ if settings.enable_db_persistence:
197
  try:
198
+ repo.create_bulk_job(task_id, request.emails)
 
 
 
 
 
 
 
 
 
 
 
 
199
  except Exception as exc:
200
+ print(f"[BULK] create_bulk_job failed: {exc}")
201
+
202
+ async def _run_and_store():
203
+ async with _bulk_semaphore:
204
+ try:
205
+ results = await process_bulk_sync(
206
+ request.emails, request.webhook_url, deep=request.deep
207
+ )
208
+ if settings.enable_db_persistence:
209
+ try:
210
+ repo.finish_bulk_job(task_id, results)
211
+ except Exception as exc:
212
+ print(f"[BULK] finish_bulk_job failed: {exc}")
213
+ await set_cache(
214
+ f"bulk_result:{task_id}",
215
+ json.dumps({"status": "completed", "total": len(request.emails),
216
+ "results": results}),
217
+ ttl=settings.redis_cache_ttl,
218
+ )
219
+ print(f"[BULK] task {task_id} completed: {len(results)} results")
220
+ except Exception as exc:
221
+ print(f"[BULK] task {task_id} failed: {exc}")
222
+ await set_cache(
223
+ f"bulk_result:{task_id}",
224
+ json.dumps({"status": "error", "error": str(exc),
225
+ "results": []}),
226
+ ttl=settings.redis_cache_ttl,
227
+ )
228
 
229
+ # The loop holds only a WEAK reference to tasks: without a strong ref the
230
+ # job can be garbage-collected mid-run and its results silently lost.
231
+ task = asyncio.create_task(_run_and_store())
232
+ _background_tasks.add(task)
233
+ task.add_done_callback(_background_tasks.discard)
234
 
235
  return JSONResponse(content={
236
  "task_id": task_id,
 
310
  total=job.get("total"),
311
  results=_normalize_results(job.get("results"))
312
  )
313
+ # An in-flight background job has no cached result yet. Report
314
+ # "processing" instead of 404 — the frontend treats 404 as terminal and
315
+ # would abandon a job that is still running.
316
+ if any(not t.done() for t in _background_tasks):
317
+ return TaskStatusResponse(task_id=task_id, status="processing")
318
  raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
319
 
320
  try:
app/main.py CHANGED
@@ -68,6 +68,20 @@ async def lifespan(app: FastAPI):
68
  except Exception as exc: # pragma: no cover - defensive
69
  print(f"[RETRY] drain loop skipped — {exc}")
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  yield
72
 
73
  if drain_task is not None:
 
68
  except Exception as exc: # pragma: no cover - defensive
69
  print(f"[RETRY] drain loop skipped — {exc}")
70
 
71
+ # Warm the advisory model at startup: the first request otherwise pays the
72
+ # torch + checkpoint load (measured 42.6s cold start) even though inference
73
+ # now runs off the event loop. Fail-open and never fatal.
74
+ try:
75
+ from .ml.runtime import get_model_async
76
+
77
+ model = await get_model_async()
78
+ if model is not None and getattr(model, "loaded", False):
79
+ print("[ML] advisory model preloaded")
80
+ else:
81
+ print("[ML] advisory model unavailable - deterministic mode")
82
+ except Exception as exc: # pragma: no cover - defensive
83
+ print(f"[ML] preload skipped — {exc}")
84
+
85
  yield
86
 
87
  if drain_task is not None:
app/ml/runtime.py CHANGED
@@ -1,16 +1,24 @@
1
- """Lean ONNX inference runtime for the email-risk model.
2
 
3
  Strictly optional: `load()` returning False (or `predict()` returning None)
4
- means "no ML this run" — the deterministic pipeline continues unchanged. Imports
5
- are lazy so the API boots with or without onnxruntime installed, and the test
6
- suite never requires torch.
7
-
8
- The deployed Space image must NOT contain torch (~2 GB); inference is ONNX only.
 
 
 
 
 
 
 
9
  """
10
  from __future__ import annotations
11
 
 
12
  import threading
13
- from typing import Optional
14
 
15
  from ..config import settings
16
 
@@ -82,6 +90,15 @@ class EmailRiskModel:
82
  self.last_error = f"{type(exc).__name__}: {exc}"
83
  return False
84
 
 
 
 
 
 
 
 
 
 
85
  def predict(self, texts: list[str]) -> Optional[list[tuple[str, float]]]:
86
  """Classify texts. Returns (LABEL, confidence) tuples, or None on failure.
87
 
@@ -93,29 +110,61 @@ class EmailRiskModel:
93
  return None
94
  try:
95
  preds = self._pipeline(texts, batch_size=self.batch_size)
96
- except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  return None
98
- out: list[tuple[str, float]] = []
99
- for batch in preds:
100
- # transformers may return a flat list or one list per batch element.
101
- if isinstance(batch, list):
102
- preds_nested = batch
103
- else:
104
- preds_nested = [batch]
105
- for p in preds_nested:
106
- label = str(p.get("label", "")).upper()
107
- try:
108
- score = float(p.get("score", 0.0))
109
- except (TypeError, ValueError):
110
- score = 0.0
111
- out.append((label, score))
112
- return out if len(out) == len(texts) else None
113
 
114
 
115
  _model: Optional[EmailRiskModel] = None
116
  _model_lock = threading.Lock()
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def get_model() -> Optional[EmailRiskModel]:
120
  """Process-wide model, built AND loaded when flag + repo are configured."""
121
  global _model
 
1
+ """Inference runtime for the email-risk model (PyTorch / transformers).
2
 
3
  Strictly optional: `load()` returning False (or `predict()` returning None)
4
+ means "no ML this run" — the deterministic pipeline continues unchanged.
5
+ Imports are lazy so the API boots with or without torch installed, and the test
6
+ suite never requires a model download.
7
+
8
+ The deployed Space image DOES contain CPU torch (Dockerfile installs it from
9
+ the PyTorch CPU index) and ships the bundled model at `app/ml/model`. Do not
10
+ strip either: the loader fails open, so removing them silently disables the
11
+ advisory layer instead of raising.
12
+
13
+ CPU inference is offloaded to a worker thread (`predict_async`, `load_async`)
14
+ because running it inline stalls every concurrent request — measured at 42.6s
15
+ cold start before this.
16
  """
17
  from __future__ import annotations
18
 
19
+ import asyncio
20
  import threading
21
+ from typing import Callable, Optional
22
 
23
  from ..config import settings
24
 
 
90
  self.last_error = f"{type(exc).__name__}: {exc}"
91
  return False
92
 
93
+ async def load_async(self) -> bool:
94
+ """Load the model without blocking the event loop.
95
+
96
+ Loading torch + a checkpoint takes seconds; doing it inline in an async
97
+ handler stalls every concurrent request (measured: 42.6s cold start
98
+ before this).
99
+ """
100
+ return await asyncio.to_thread(self.load)
101
+
102
  def predict(self, texts: list[str]) -> Optional[list[tuple[str, float]]]:
103
  """Classify texts. Returns (LABEL, confidence) tuples, or None on failure.
104
 
 
110
  return None
111
  try:
112
  preds = self._pipeline(texts, batch_size=self.batch_size)
113
+ out: list[tuple[str, float]] = []
114
+ for batch in preds:
115
+ # transformers may return a flat list or one list per element.
116
+ if isinstance(batch, list):
117
+ preds_nested = batch
118
+ else:
119
+ preds_nested = [batch]
120
+ for p in preds_nested:
121
+ # An unexpected payload shape must fail open, not raise:
122
+ # the batch path calls predict() outside its own try.
123
+ if not isinstance(p, dict):
124
+ raise TypeError(f"unexpected prediction: {type(p).__name__}")
125
+ label = str(p.get("label", "")).upper()
126
+ try:
127
+ score = float(p.get("score", 0.0))
128
+ except (TypeError, ValueError):
129
+ score = 0.0
130
+ out.append((label, score))
131
+ return out if len(out) == len(texts) else None
132
+ except Exception as exc:
133
+ self.last_error = f"predict: {type(exc).__name__}: {exc}"
134
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
 
137
  _model: Optional[EmailRiskModel] = None
138
  _model_lock = threading.Lock()
139
 
140
 
141
+ async def predict_async(model, texts: list[str]):
142
+ """Classify texts without blocking the event loop.
143
+
144
+ Works with either an `EmailRiskModel` (CPU inference in a worker thread) or
145
+ any injected test double exposing a sync `predict` (called directly).
146
+ """
147
+ if model is None or not texts:
148
+ return None
149
+ predict = getattr(model, "predict", None)
150
+ if predict is None:
151
+ return None
152
+ if isinstance(model, EmailRiskModel):
153
+ return await asyncio.to_thread(predict, texts)
154
+ return predict(texts)
155
+
156
+
157
+ async def get_model_async() -> Optional[EmailRiskModel]:
158
+ """`get_model()` with the (slow) first load kept off the event loop."""
159
+ global _model
160
+ if not settings.ml_disposable_enabled or not settings.ml_model_repo:
161
+ return None
162
+ if _model is not None:
163
+ return _model
164
+ await asyncio.to_thread(get_model)
165
+ return _model
166
+
167
+
168
  def get_model() -> Optional[EmailRiskModel]:
169
  """Process-wide model, built AND loaded when flag + repo are configured."""
170
  global _model
app/validator/engine.py CHANGED
@@ -20,7 +20,7 @@ from .syntax import validate_syntax
20
  from .dns_check import check_dns
21
  from .disposable import check_disposable, domain_in_blocklist, domain_is_known
22
  from .mailbox_provider import MailboxProvider, resolve_provider
23
- from ..ml.runtime import get_model
24
  from .mx_intel import mx_is_trusted
25
  from .retry_queue import is_greylist_result
26
  from .scoring import calculate_score
@@ -233,7 +233,7 @@ class EmailValidationEngine:
233
  # learned 'unfamiliar domain string = disposable' from an imbalanced
234
  # dataset and flags legitimate small businesses on Google Workspace /
235
  # M365 — the deterministic MX intelligence is the better signal there.
236
- ml_model = get_model()
237
  mx_host = (
238
  dns_result.mx_records[0]["host"]
239
  if dns_result.mx_records
@@ -245,10 +245,9 @@ class EmailValidationEngine:
245
  and deep
246
  and not mx_is_trusted(mx_host)
247
  ):
248
- try:
249
- preds = ml_model.predict([f"john.smith [SEP] {domain}"])
250
- except Exception:
251
- preds = None
252
  if preds:
253
  disposable = disposable.model_copy(
254
  update={"ml_label": preds[0][0], "ml_confidence": preds[0][1]}
@@ -546,7 +545,7 @@ class EmailValidationEngine:
546
  # there, and the model's 'unfamiliar string' bias would otherwise
547
  # flag legitimate businesses as disposable.
548
  ml_map: dict[str, tuple[str, float]] = {}
549
- ml_model = get_model()
550
  if ml_model is not None:
551
  unknown_domains = sorted(
552
  {
@@ -558,8 +557,11 @@ class EmailValidationEngine:
558
  }
559
  )
560
  if unknown_domains:
561
- preds = ml_model.predict(
562
- [f"john.smith [SEP] {d}" for d in unknown_domains]
 
 
 
563
  )
564
  if preds:
565
  ml_map = dict(zip(unknown_domains, preds))
 
20
  from .dns_check import check_dns
21
  from .disposable import check_disposable, domain_in_blocklist, domain_is_known
22
  from .mailbox_provider import MailboxProvider, resolve_provider
23
+ from ..ml.runtime import get_model_async, predict_async
24
  from .mx_intel import mx_is_trusted
25
  from .retry_queue import is_greylist_result
26
  from .scoring import calculate_score
 
233
  # learned 'unfamiliar domain string = disposable' from an imbalanced
234
  # dataset and flags legitimate small businesses on Google Workspace /
235
  # M365 — the deterministic MX intelligence is the better signal there.
236
+ ml_model = await get_model_async()
237
  mx_host = (
238
  dns_result.mx_records[0]["host"]
239
  if dns_result.mx_records
 
245
  and deep
246
  and not mx_is_trusted(mx_host)
247
  ):
248
+ # Off the event loop: CPU inference inline would stall every other
249
+ # concurrent request (measured 42.6s cold start before this).
250
+ preds = await predict_async(ml_model, [f"john.smith [SEP] {domain}"])
 
251
  if preds:
252
  disposable = disposable.model_copy(
253
  update={"ml_label": preds[0][0], "ml_confidence": preds[0][1]}
 
545
  # there, and the model's 'unfamiliar string' bias would otherwise
546
  # flag legitimate businesses as disposable.
547
  ml_map: dict[str, tuple[str, float]] = {}
548
+ ml_model = await get_model_async()
549
  if ml_model is not None:
550
  unknown_domains = sorted(
551
  {
 
557
  }
558
  )
559
  if unknown_domains:
560
+ # One batched call, off the event loop (CPU inference inline
561
+ # stalls concurrent requests), and fail-open by construction:
562
+ # predict_async never raises.
563
+ preds = await predict_async(
564
+ ml_model, [f"john.smith [SEP] {d}" for d in unknown_domains]
565
  )
566
  if preds:
567
  ml_map = dict(zip(unknown_domains, preds))
tests/test_engine_ml.py CHANGED
@@ -54,7 +54,8 @@ def _engine_with_ml(flag=True, repo="user/model", predict=None):
54
  fake_model.predict = MagicMock(side_effect=predict)
55
  fake_model.load = MagicMock(return_value=True)
56
  engine.mailbox = engine.mailbox # unchanged
57
- patcher = patch("app.validator.engine.get_model", return_value=fake_model)
 
58
  patcher.start()
59
  engine._ml_patcher = patcher # keep alive until stopAll
60
  engine._ml_model = fake_model
 
54
  fake_model.predict = MagicMock(side_effect=predict)
55
  fake_model.load = MagicMock(return_value=True)
56
  engine.mailbox = engine.mailbox # unchanged
57
+ patcher = patch("app.validator.engine.get_model_async",
58
+ new=AsyncMock(return_value=fake_model))
59
  patcher.start()
60
  engine._ml_patcher = patcher # keep alive until stopAll
61
  engine._ml_model = fake_model
tests/test_ml_async.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+ import unittest
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ from app.ml.runtime import EmailRiskModel, predict_async
7
+
8
+
9
+ def _slow_pipeline(texts, batch_size=None):
10
+ time.sleep(0.4)
11
+ return [[{"label": "NORMAL", "score": 0.9}] for _ in texts]
12
+
13
+
14
+ class NonBlockingInferenceTests(unittest.IsolatedAsyncioTestCase):
15
+ """CPU inference must run OFF the event loop.
16
+
17
+ Measured in production: the first request after a cold start took 42.6s and
18
+ warm requests ~2s because torch inference ran synchronously inside `async
19
+ def` handlers, stalling every concurrent request (including /health).
20
+ """
21
+
22
+ async def test_predict_async_does_not_block_the_event_loop(self):
23
+ m = EmailRiskModel(repo="user/model")
24
+ m._pipeline = _slow_pipeline
25
+ m._loaded = True
26
+
27
+ ticks = 0
28
+
29
+ async def ticker():
30
+ nonlocal ticks
31
+ while True:
32
+ await asyncio.sleep(0.02)
33
+ ticks += 1
34
+
35
+ t = asyncio.create_task(ticker())
36
+ out = await predict_async(m, ["a [SEP] b.test"])
37
+ t.cancel()
38
+
39
+ self.assertEqual(out, [("NORMAL", 0.9)])
40
+ # A blocking implementation would leave the loop frozen for ~0.4s
41
+ # (ticks == 0); off-loop it keeps ticking throughout.
42
+ self.assertGreaterEqual(
43
+ ticks, 5, f"event loop was blocked during inference (ticks={ticks})"
44
+ )
45
+
46
+ async def test_load_async_runs_the_factory_off_the_event_loop(self):
47
+ def slow_factory(repo, token, batch_size):
48
+ time.sleep(0.3)
49
+ return _slow_pipeline
50
+
51
+ m = EmailRiskModel(repo="user/model", pipeline_factory=slow_factory)
52
+ ticks = 0
53
+
54
+ async def ticker():
55
+ nonlocal ticks
56
+ while True:
57
+ await asyncio.sleep(0.02)
58
+ ticks += 1
59
+
60
+ t = asyncio.create_task(ticker())
61
+ self.assertTrue(await m.load_async())
62
+ t.cancel()
63
+ self.assertGreaterEqual(
64
+ ticks, 4, f"model load blocked the event loop (ticks={ticks})"
65
+ )
66
+
67
+ async def test_predict_async_is_fail_open(self):
68
+ m = EmailRiskModel(repo="user/model")
69
+ m._pipeline = MagicMock(side_effect=RuntimeError("boom"))
70
+ m._loaded = True
71
+ self.assertIsNone(await predict_async(m, ["a [SEP] b.test"]))
72
+
73
+ async def test_predict_async_handles_unloaded_model(self):
74
+ m = EmailRiskModel(repo="user/model")
75
+ self.assertIsNone(await predict_async(m, ["a [SEP] b.test"]))
76
+
77
+ async def test_predict_async_matches_sync_results_for_fakes(self):
78
+ # Engine tests inject MagicMock models with a sync predict; the async
79
+ # helper must work with those unchanged.
80
+ fake = MagicMock()
81
+ fake.predict = MagicMock(return_value=[("DISPOSABLE", 0.97)])
82
+ out = await predict_async(fake, ["x [SEP] y.test"])
83
+ self.assertEqual(out, [("DISPOSABLE", 0.97)])
84
+ fake.predict.assert_called_once()
85
+
86
+ async def test_concurrent_predictions_are_parallelised(self):
87
+ """Two 0.3s inferences should overlap, not serialise (thread pool)."""
88
+ m = EmailRiskModel(repo="user/model")
89
+ m._pipeline = _slow_pipeline
90
+ m._loaded = True
91
+ t0 = time.perf_counter()
92
+ await asyncio.gather(
93
+ predict_async(m, ["a [SEP] b.test"]),
94
+ predict_async(m, ["c [SEP] d.test"]),
95
+ )
96
+ elapsed = time.perf_counter() - t0
97
+ self.assertLess(elapsed, 0.55, f"inferences serialised ({elapsed:.2f}s)")
98
+
99
+
100
+ if __name__ == "__main__":
101
+ unittest.main()
tests/test_ml_mx_gate.py CHANGED
@@ -61,8 +61,8 @@ class TrustedMxGateTests(unittest.IsolatedAsyncioTestCase):
61
 
62
  def setUp(self):
63
  self.patchers = [
64
- patch("app.validator.engine.get_model",
65
- return_value=_fake_model("DISPOSABLE")),
66
  ]
67
  for p in self.patchers:
68
  p.start()
 
61
 
62
  def setUp(self):
63
  self.patchers = [
64
+ patch("app.validator.engine.get_model_async",
65
+ new=AsyncMock(return_value=_fake_model("DISPOSABLE"))),
66
  ]
67
  for p in self.patchers:
68
  p.start()