Upload folder using huggingface_hub
Browse files- app/validator/mailbox_provider.py +5 -1
- app/validator/mx_batch.py +64 -10
- tests/test_mx_batch.py +69 -0
app/validator/mailbox_provider.py
CHANGED
|
@@ -178,7 +178,11 @@ class AutonomousSmtpProvider(MailboxProvider):
|
|
| 178 |
pairs = [(e, mx) for e, mx in pairs if e and mx]
|
| 179 |
if not pairs:
|
| 180 |
return {}, {}
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
domains = {e.rsplit("@", 1)[-1].lower(): mx for e, mx in pairs}
|
| 183 |
catch_all = {
|
| 184 |
domain: self.batch.catch_all_cache.get(domain) for domain in domains
|
|
|
|
| 178 |
pairs = [(e, mx) for e, mx in pairs if e and mx]
|
| 179 |
if not pairs:
|
| 180 |
return {}, {}
|
| 181 |
+
# Prefer the concurrent path when the batch engine supports it.
|
| 182 |
+
if hasattr(self.batch, "verify_batch_async"):
|
| 183 |
+
results = await self.batch.verify_batch_async(pairs)
|
| 184 |
+
else:
|
| 185 |
+
results = self.batch.verify_batch(pairs)
|
| 186 |
domains = {e.rsplit("@", 1)[-1].lower(): mx for e, mx in pairs}
|
| 187 |
catch_all = {
|
| 188 |
domain: self.batch.catch_all_cache.get(domain) for domain in domains
|
app/validator/mx_batch.py
CHANGED
|
@@ -18,6 +18,7 @@ bulk job.
|
|
| 18 |
"""
|
| 19 |
from __future__ import annotations
|
| 20 |
|
|
|
|
| 21 |
import random
|
| 22 |
import time
|
| 23 |
from dataclasses import dataclass
|
|
@@ -81,6 +82,7 @@ class MxBatchVerifier:
|
|
| 81 |
max_connections_per_host: int = 2,
|
| 82 |
fail_threshold: int = 3,
|
| 83 |
cooldown_seconds: int = 300,
|
|
|
|
| 84 |
):
|
| 85 |
self.timeout = timeout
|
| 86 |
self.helo_domain = helo_domain
|
|
@@ -96,6 +98,7 @@ class MxBatchVerifier:
|
|
| 96 |
# `cooldown_seconds` and report rows unavailable immediately instead.
|
| 97 |
self.fail_threshold = max(1, fail_threshold)
|
| 98 |
self.cooldown_seconds = cooldown_seconds
|
|
|
|
| 99 |
self._consecutive_failures = 0
|
| 100 |
self._open_until = 0.0
|
| 101 |
|
|
@@ -123,22 +126,73 @@ class MxBatchVerifier:
|
|
| 123 |
def verify_batch(
|
| 124 |
self, pairs: Iterable[tuple[str, str]]
|
| 125 |
) -> dict[str, SMTPResult]:
|
| 126 |
-
"""Verify pairs of (email, mx_host). Returns email ->
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
return {}
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
groups: dict[tuple[str, str], list[str]] = {}
|
| 134 |
for email, mx_host in pairs:
|
|
|
|
|
|
|
| 135 |
domain = email.rsplit("@", 1)[-1].lower()
|
| 136 |
groups.setdefault((domain, mx_host), []).append(email)
|
| 137 |
-
|
| 138 |
-
results: dict[str, SMTPResult] = {}
|
| 139 |
-
for (domain, mx_host), emails in groups.items():
|
| 140 |
-
results.update(self._verify_domain(domain, mx_host, emails))
|
| 141 |
-
return results
|
| 142 |
|
| 143 |
def _verify_domain(
|
| 144 |
self, domain: str, mx_host: str, emails: list[str]
|
|
|
|
| 18 |
"""
|
| 19 |
from __future__ import annotations
|
| 20 |
|
| 21 |
+
import asyncio
|
| 22 |
import random
|
| 23 |
import time
|
| 24 |
from dataclasses import dataclass
|
|
|
|
| 82 |
max_connections_per_host: int = 2,
|
| 83 |
fail_threshold: int = 3,
|
| 84 |
cooldown_seconds: int = 300,
|
| 85 |
+
bulk_concurrency: int = 20,
|
| 86 |
):
|
| 87 |
self.timeout = timeout
|
| 88 |
self.helo_domain = helo_domain
|
|
|
|
| 98 |
# `cooldown_seconds` and report rows unavailable immediately instead.
|
| 99 |
self.fail_threshold = max(1, fail_threshold)
|
| 100 |
self.cooldown_seconds = cooldown_seconds
|
| 101 |
+
self.bulk_concurrency = max(1, bulk_concurrency)
|
| 102 |
self._consecutive_failures = 0
|
| 103 |
self._open_until = 0.0
|
| 104 |
|
|
|
|
| 126 |
def verify_batch(
|
| 127 |
self, pairs: Iterable[tuple[str, str]]
|
| 128 |
) -> dict[str, SMTPResult]:
|
| 129 |
+
"""Verify pairs of (email, mx_host), sequentially. Returns email -> result."""
|
| 130 |
+
groups = self._group(pairs)
|
| 131 |
+
results: dict[str, SMTPResult] = {}
|
| 132 |
+
for (domain, mx_host), emails in groups.items():
|
| 133 |
+
results.update(self._verify_domain(domain, mx_host, emails))
|
| 134 |
+
return results
|
| 135 |
+
|
| 136 |
+
async def verify_batch_async(
|
| 137 |
+
self, pairs: Iterable[tuple[str, str]]
|
| 138 |
+
) -> dict[str, SMTPResult]:
|
| 139 |
+
"""Verify many pairs with DOMAINS PROBED CONCURRENTLY.
|
| 140 |
+
|
| 141 |
+
Blocking SMTP work runs in a thread pool bounded by `bulk_concurrency`.
|
| 142 |
+
Sequential probing makes wall-clock the sum of every domain's latency
|
| 143 |
+
(timeout x domains on a blocked host); real bulk throughput comes from
|
| 144 |
+
parallelism across domains, not speed per domain.
|
| 145 |
+
"""
|
| 146 |
+
groups = self._group(pairs)
|
| 147 |
+
if not groups:
|
| 148 |
return {}
|
| 149 |
|
| 150 |
+
semaphore = asyncio.Semaphore(self.bulk_concurrency)
|
| 151 |
+
|
| 152 |
+
async def run_domain(domain: str, mx_host: str, emails: list[str]):
|
| 153 |
+
async with semaphore:
|
| 154 |
+
return await asyncio.to_thread(
|
| 155 |
+
self._verify_domain, domain, mx_host, emails
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
chunks = await asyncio.gather(
|
| 159 |
+
*(run_domain(d, m, e) for (d, m), e in groups.items()),
|
| 160 |
+
return_exceptions=True,
|
| 161 |
+
)
|
| 162 |
+
results: dict[str, SMTPResult] = {}
|
| 163 |
+
for chunk in chunks:
|
| 164 |
+
if isinstance(chunk, Exception):
|
| 165 |
+
continue
|
| 166 |
+
results.update(chunk)
|
| 167 |
+
# Any group that raised must still return a row per address.
|
| 168 |
+
missing = [
|
| 169 |
+
(d, m, e)
|
| 170 |
+
for (d, m), e in groups.items()
|
| 171 |
+
if not any(addr in results for addr in e)
|
| 172 |
+
]
|
| 173 |
+
for domain, mx_host, emails in missing:
|
| 174 |
+
for email in emails:
|
| 175 |
+
results.setdefault(
|
| 176 |
+
email,
|
| 177 |
+
SMTPResult(
|
| 178 |
+
verified=None,
|
| 179 |
+
error=f"smtp_unreachable: {mx_host} (probe aborted)",
|
| 180 |
+
),
|
| 181 |
+
)
|
| 182 |
+
return results
|
| 183 |
+
|
| 184 |
+
@staticmethod
|
| 185 |
+
def _group(
|
| 186 |
+
pairs: Iterable[tuple[str, str]]
|
| 187 |
+
) -> dict[tuple[str, str], list[str]]:
|
| 188 |
+
"""Group (email, mx_host) pairs by (domain, mx) — the session unit."""
|
| 189 |
groups: dict[tuple[str, str], list[str]] = {}
|
| 190 |
for email, mx_host in pairs:
|
| 191 |
+
if not email or not mx_host:
|
| 192 |
+
continue
|
| 193 |
domain = email.rsplit("@", 1)[-1].lower()
|
| 194 |
groups.setdefault((domain, mx_host), []).append(email)
|
| 195 |
+
return groups
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
def _verify_domain(
|
| 198 |
self, domain: str, mx_host: str, emails: list[str]
|
tests/test_mx_batch.py
CHANGED
|
@@ -197,5 +197,74 @@ class EgressCircuitBreakerTests(unittest.TestCase):
|
|
| 197 |
self.assertGreaterEqual(v.fail_threshold, 2)
|
| 198 |
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
if __name__ == "__main__":
|
| 201 |
unittest.main()
|
|
|
|
| 197 |
self.assertGreaterEqual(v.fail_threshold, 2)
|
| 198 |
|
| 199 |
|
| 200 |
+
class ParallelDomainTests(unittest.IsolatedAsyncioTestCase):
|
| 201 |
+
"""Domains must be probed CONCURRENTLY (with a cap).
|
| 202 |
+
|
| 203 |
+
Sequential probing means wall-clock = sum of every domain's latency; on a
|
| 204 |
+
blocked host that is timeout x domains. Commercial validators get bulk
|
| 205 |
+
throughput from parallelism across domains, not speed per domain.
|
| 206 |
+
"""
|
| 207 |
+
|
| 208 |
+
async def test_domains_are_probed_in_parallel(self):
|
| 209 |
+
import asyncio
|
| 210 |
+
import time
|
| 211 |
+
|
| 212 |
+
active = {"now": 0, "peak": 0}
|
| 213 |
+
|
| 214 |
+
class SlowConn(FakeConnection):
|
| 215 |
+
def open(self):
|
| 216 |
+
active["now"] += 1
|
| 217 |
+
active["peak"] = max(active["peak"], active["now"])
|
| 218 |
+
time.sleep(0.25)
|
| 219 |
+
active["now"] -= 1
|
| 220 |
+
self.ready = True
|
| 221 |
+
return True
|
| 222 |
+
|
| 223 |
+
FakeConnection.registry = {f"mx{i}.test": {"*": 550} for i in range(8)}
|
| 224 |
+
v = MxBatchVerifier(
|
| 225 |
+
connection_factory=SlowConn, bulk_concurrency=8, max_connections_per_host=1
|
| 226 |
+
)
|
| 227 |
+
pairs = [(f"u{i}@dom{i}.test", f"mx{i}.test") for i in range(8)]
|
| 228 |
+
|
| 229 |
+
t0 = time.time()
|
| 230 |
+
out = await v.verify_batch_async(pairs)
|
| 231 |
+
elapsed = time.time() - t0
|
| 232 |
+
|
| 233 |
+
self.assertEqual(len(out), 8)
|
| 234 |
+
# 8 x 0.25s sequential = 2.0s; parallel should be well under 1s.
|
| 235 |
+
self.assertLess(elapsed, 1.2, f"domains ran sequentially ({elapsed:.2f}s)")
|
| 236 |
+
self.assertGreater(active["peak"], 2, "no real concurrency observed")
|
| 237 |
+
|
| 238 |
+
async def test_concurrency_cap_is_respected(self):
|
| 239 |
+
import time
|
| 240 |
+
|
| 241 |
+
active = {"now": 0, "peak": 0}
|
| 242 |
+
|
| 243 |
+
class SlowConn(FakeConnection):
|
| 244 |
+
def open(self):
|
| 245 |
+
active["now"] += 1
|
| 246 |
+
active["peak"] = max(active["peak"], active["now"])
|
| 247 |
+
time.sleep(0.15)
|
| 248 |
+
active["now"] -= 1
|
| 249 |
+
self.ready = True
|
| 250 |
+
return True
|
| 251 |
+
|
| 252 |
+
FakeConnection.registry = {f"mx{i}.test": {"*": 550} for i in range(10)}
|
| 253 |
+
v = MxBatchVerifier(
|
| 254 |
+
connection_factory=SlowConn, bulk_concurrency=3, max_connections_per_host=1
|
| 255 |
+
)
|
| 256 |
+
pairs = [(f"u{i}@dom{i}.test", f"mx{i}.test") for i in range(10)]
|
| 257 |
+
out = await v.verify_batch_async(pairs)
|
| 258 |
+
|
| 259 |
+
self.assertEqual(len(out), 10)
|
| 260 |
+
self.assertLessEqual(active["peak"], 3, "exceeded the concurrency cap")
|
| 261 |
+
|
| 262 |
+
async def test_sync_verify_batch_still_works(self):
|
| 263 |
+
FakeConnection.registry = {"mx.corp.test": {"a@corp.test": 250, "*": 550}}
|
| 264 |
+
v = MxBatchVerifier(connection_factory=FakeConnection)
|
| 265 |
+
out = v.verify_batch([("a@corp.test", "mx.corp.test")])
|
| 266 |
+
self.assertTrue(out["a@corp.test"].verified)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
if __name__ == "__main__":
|
| 270 |
unittest.main()
|