sync-v2 §10.7: mail-injecting mode for the soak harness

Optional SOAK_MAIL=1 makes the mock deliver synthetic messages (unique UIDs,
well-formed RFC822) on INBOX polls, so the full parse -> blob -> DB -> index path
actually runs and CPU/RSS move under real throughput. Tunables: SOAK_MAIL_PER_POLL,
SOAK_MSG_KB, SOAK_BIG_MSG_PCT (percent above STREAM_PARSE_MB -> streaming/§8 path).
Blob store + search backend are isolated (temp BLOB_ROOT, SEARCH_BACKEND=pg) and
torn down. Output gains msgs + msg/s columns and a delivered/peak-RSS summary.

RSS check is now a trend (early-third vs late-third mean, not min-to-max) so it
detects a real leak without flaking on transient large-message parse spikes.

Validated: mail run delivers ~4.2 msg/s (incl. 5% 10MB messages); RSS oscillates
123-267MB but its mean is flat (+3.7% over the run) -> no leak, parse memory
bound holds (§8); tier re-promotion on new mail exercised; all §10.7 checks pass.
Full gate green (238 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 09:28:48 +02:00
co-authored by Claude Opus 4.8
parent ee4dd99ad1
commit 3d962a183c
2 changed files with 115 additions and 10 deletions
+50 -1
View File
@@ -16,9 +16,13 @@ so the harness can compute the oldest sync lag across all accounts.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import random
import time import time
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime
from email.message import EmailMessage
from email.utils import format_datetime
from app.providers.base import MailboxDelta, RemoteMailbox, RemoteMessage from app.providers.base import MailboxDelta, RemoteMailbox, RemoteMessage
@@ -28,11 +32,40 @@ class MockStats:
persistent_open = 0 persistent_open = 0
last_sync: dict[uuid.UUID, float] = {} last_sync: dict[uuid.UUID, float] = {}
# --- optional mail injection (exercises parse/blob/DB/index under load) ----
mail_enabled = False
mail_per_poll = 0.0 # expected new INBOX messages per poll (fractional => prob)
msg_bytes = 10_000
big_pct = 0 # percent of messages that exceed STREAM_PARSE_MB (streaming path)
big_bytes = 10_000_000
delivered = 0 # total synthetic messages handed to the sync engine
_uid: dict[uuid.UUID, int] = {}
@classmethod @classmethod
def reset(cls) -> None: def reset(cls) -> None:
cls.open = 0 cls.open = 0
cls.persistent_open = 0 cls.persistent_open = 0
cls.last_sync = {} cls.last_sync = {}
cls.delivered = 0
cls._uid = {}
def _make_raw(size_bytes: int) -> bytes:
"""A unique, well-formed RFC822 message padded to roughly ``size_bytes``."""
msg = EmailMessage()
msg["Subject"] = f"soak {uuid.uuid4().hex[:12]}"
msg["From"] = "sender@load.local"
msg["To"] = "rcpt@load.local"
msg["Message-ID"] = f"<{uuid.uuid4().hex}@load.local>"
msg["Date"] = format_datetime(datetime.now(UTC))
msg.set_content("x" * max(0, size_bytes - 256))
return msg.as_bytes()
def _draw_count() -> int:
n = int(MockStats.mail_per_poll)
frac = MockStats.mail_per_poll - n
return n + (1 if random.random() < frac else 0)
class MockProvider: class MockProvider:
@@ -61,8 +94,24 @@ class MockProvider:
) -> MailboxDelta: ) -> MailboxDelta:
self._open_socket() self._open_socket()
MockStats.last_sync[self.account_id] = time.time() MockStats.last_sync[self.account_id] = time.time()
new: list[RemoteMessage] = []
if MockStats.mail_enabled and mailbox == "INBOX":
for _ in range(_draw_count()):
uid = MockStats._uid.get(self.account_id, 0) + 1
MockStats._uid[self.account_id] = uid
big = random.random() < MockStats.big_pct / 100.0
size = MockStats.big_bytes if big else MockStats.msg_bytes
new.append(
RemoteMessage(
uid=uid,
raw=_make_raw(size),
flags=frozenset(),
internal_date=datetime.now(UTC),
)
)
MockStats.delivered += len(new)
return MailboxDelta( return MailboxDelta(
new=[], new=new,
flag_changes=[], flag_changes=[],
expunged_uids=[], expunged_uids=[],
new_uidvalidity=None, new_uidvalidity=None,
+65 -9
View File
@@ -21,6 +21,15 @@ Fast smoke run (a couple of minutes, shortened cadences):
cd backend && SOAK_ACCOUNTS=200 SOAK_DURATION_S=150 POLL_COLD=30 POLL_WARM=15 \ cd backend && SOAK_ACCOUNTS=200 SOAK_DURATION_S=150 POLL_COLD=30 POLL_WARM=15 \
SOAK_TIER_EVAL_S=5 SOAK_IDLE_S=10 python -m loadtest.soak_sync SOAK_TIER_EVAL_S=5 SOAK_IDLE_S=10 python -m loadtest.soak_sync
Mail-injecting mode (SOAK_MAIL=1) delivers synthetic messages so the parse /
blob / DB / index path actually runs and RSS + CPU move under throughput. Tunables:
SOAK_MAIL_PER_POLL (expected new INBOX msgs per poll), SOAK_MSG_KB, SOAK_BIG_MSG_PCT
(percent above STREAM_PARSE_MB, exercising the streaming/§8 path). Example:
cd backend && SOAK_MAIL=1 SOAK_MAIL_PER_POLL=0.5 SOAK_BIG_MSG_PCT=5 \
SOAK_ACCOUNTS=300 SOAK_DURATION_S=300 POLL_COLD=60 POLL_WARM=30 \
SOAK_TIER_EVAL_S=15 SOAK_IDLE_S=20 python -m loadtest.soak_sync
Exit code 0 = all targets met, 1 = a target missed (usable in CI-nightly). Exit code 0 = all targets met, 1 = a target missed (usable in CI-nightly).
""" """
@@ -28,10 +37,12 @@ from __future__ import annotations
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import tempfile
# --- isolation: point DB + Redis at throwaway locations BEFORE importing app --- # --- isolation: point DB + Redis + blobs at throwaway locations BEFORE app import
_DEV_DB = os.environ.get( _DEV_DB = os.environ.get(
"DATABASE_URL", "postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev" "DATABASE_URL", "postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev"
@@ -41,6 +52,11 @@ _SOAK_DB_URL = re.sub(r"/[^/?]+(\?|$)", f"/{_SOAK_DB_NAME}\\1", _DEV_DB)
os.environ["DATABASE_URL"] = _SOAK_DB_URL os.environ["DATABASE_URL"] = _SOAK_DB_URL
os.environ.setdefault("SOAK_REDIS_URL", "redis://127.0.0.1:6380/14") os.environ.setdefault("SOAK_REDIS_URL", "redis://127.0.0.1:6380/14")
os.environ["REDIS_URL"] = os.environ["SOAK_REDIS_URL"] os.environ["REDIS_URL"] = os.environ["SOAK_REDIS_URL"]
# Isolate the blob store (mail injection writes real blobs) and force the in-DB
# search backend so nothing leaks to the dev blob dir or an external Meilisearch.
_SOAK_BLOBS = tempfile.mkdtemp(prefix="soak-blobs-")
os.environ["BLOB_ROOT"] = _SOAK_BLOBS
os.environ["SEARCH_BACKEND"] = "pg"
import asyncio # noqa: E402 import asyncio # noqa: E402
import logging # noqa: E402 import logging # noqa: E402
@@ -77,6 +93,14 @@ class Config:
idle_s: int = field(default_factory=lambda: _int("SOAK_IDLE_S", 60)) idle_s: int = field(default_factory=lambda: _int("SOAK_IDLE_S", 60))
claim_s: int = field(default_factory=lambda: _int("SOAK_CLAIM_S", 5)) claim_s: int = field(default_factory=lambda: _int("SOAK_CLAIM_S", 5))
tier_eval_s: int = field(default_factory=lambda: _int("SOAK_TIER_EVAL_S", 120)) tier_eval_s: int = field(default_factory=lambda: _int("SOAK_TIER_EVAL_S", 120))
# Mail injection (off by default): synthetic messages exercise the
# parse/blob/DB/index path so RSS + CPU move under real throughput.
mail: bool = field(default_factory=lambda: bool(_int("SOAK_MAIL", 0)))
mail_per_poll: float = field(
default_factory=lambda: float(os.environ.get("SOAK_MAIL_PER_POLL", 0.3))
)
msg_kb: int = field(default_factory=lambda: _int("SOAK_MSG_KB", 12))
big_pct: int = field(default_factory=lambda: _int("SOAK_BIG_MSG_PCT", 0))
def __post_init__(self) -> None: def __post_init__(self) -> None:
s = get_settings() s = get_settings()
@@ -194,6 +218,7 @@ class Sample:
rss_mb: float rss_mb: float
synced: int # accounts that have completed >=1 poll (steady-state gate) synced: int # accounts that have completed >=1 poll (steady-state gate)
dead: int # owned runners whose work loops have died (zombies) dead: int # owned runners whose work loops have died (zombies)
delivered: int # cumulative synthetic messages persisted (mail mode)
async def _tier_counts(coords: list[SyncCoordinator]) -> tuple[int, int, int, int, int]: async def _tier_counts(coords: list[SyncCoordinator]) -> tuple[int, int, int, int, int]:
@@ -240,6 +265,12 @@ async def _soak(cfg: Config) -> int:
# Route every provider build to the mock (no real IMAP for the whole run). # Route every provider build to the mock (no real IMAP for the whole run).
engine_mod.build_provider = lambda account: MockProvider(account.id, cfg.idle_s) # type: ignore[assignment] engine_mod.build_provider = lambda account: MockProvider(account.id, cfg.idle_s) # type: ignore[assignment]
MockStats.reset() MockStats.reset()
if cfg.mail:
MockStats.mail_enabled = True
MockStats.mail_per_poll = cfg.mail_per_poll
MockStats.msg_bytes = cfg.msg_kb * 1024
MockStats.big_pct = cfg.big_pct
MockStats.big_bytes = (get_settings().stream_parse_mb + 2) * 1024 * 1024
redis = get_redis() redis = get_redis()
await redis.flushdb() await redis.flushdb()
@@ -274,15 +305,21 @@ async def _soak(cfg: Config) -> int:
deadline = start + cfg.duration_s deadline = start + cfg.duration_s
next_session_refresh = start + cfg.session_grace_s / 2 next_session_refresh = start + cfg.session_grace_s / 2
samples: list[Sample] = [] samples: list[Sample] = []
mail_desc = (
f"mail ON ~{cfg.mail_per_poll}/poll {cfg.msg_kb}KB big={cfg.big_pct}%"
if cfg.mail
else "mail OFF"
)
print( print(
f"soak: {cfg.accounts} accounts, {len(active_users)} active sessions, " f"soak: {cfg.accounts} accounts, {len(active_users)} active sessions, "
f"{cfg.workers} workers, duration {cfg.duration_s}s, warmup {cfg.warmup_s}s, " f"{cfg.workers} workers, duration {cfg.duration_s}s, warmup {cfg.warmup_s}s, "
f"POLL_COLD={cfg.poll_cold}s POLL_WARM={cfg.poll_warm}s", f"POLL_COLD={cfg.poll_cold}s POLL_WARM={cfg.poll_warm}s, {mail_desc}",
flush=True, flush=True,
) )
print( print(
f"{'t':>6} {'owned':>6} {'hot':>5} {'warm':>5} {'cold':>5} " f"{'t':>6} {'owned':>6} {'hot':>5} {'warm':>5} {'cold':>5} "
f"{'persist':>8} {'open':>5} {'lag_s':>7} {'rss_mb':>8} {'dead':>5}", f"{'persist':>8} {'open':>5} {'lag_s':>7} {'rss_mb':>8} {'dead':>5} "
f"{'msgs':>8} {'msg/s':>7}",
flush=True, flush=True,
) )
try: try:
@@ -305,12 +342,15 @@ async def _soak(cfg: Config) -> int:
rss_mb=_rss_mb(), rss_mb=_rss_mb(),
synced=len(MockStats.last_sync), synced=len(MockStats.last_sync),
dead=dead, dead=dead,
delivered=MockStats.delivered,
) )
samples.append(s) samples.append(s)
prev = samples[-2] if len(samples) > 1 else None
rate = (s.delivered - prev.delivered) / cfg.sample_s if prev else 0.0
print( print(
f"{s.t:6.0f} {s.owned:6d} {s.hot:5d} {s.warm:5d} {s.cold:5d} " f"{s.t:6.0f} {s.owned:6d} {s.hot:5d} {s.warm:5d} {s.cold:5d} "
f"{s.persistent:8d} {s.total_open:5d} {s.max_lag:7.0f} {s.rss_mb:8.1f} " f"{s.persistent:8d} {s.total_open:5d} {s.max_lag:7.0f} {s.rss_mb:8.1f} "
f"{s.dead:5d}", f"{s.dead:5d} {s.delivered:8d} {rate:7.1f}",
flush=True, flush=True,
) )
finally: finally:
@@ -343,9 +383,14 @@ def _report(cfg: Config, samples: list[Sample]) -> int:
persistent_p95 = _percentile([s.persistent for s in steady], 95) persistent_p95 = _percentile([s.persistent for s in steady], 95)
open_p95 = _percentile([float(s.total_open) for s in steady], 95) open_p95 = _percentile([float(s.total_open) for s in steady], 95)
lag_max = max(s.max_lag for s in steady) lag_max = max(s.max_lag for s in steady)
rss_base = steady[0].rss_mb # RSS trend: compare the mean of the first third of steady samples to the last
# third. This detects a genuine upward drift (leak) while ignoring transient
# parse spikes from large messages (min-to-max would flake in mail mode).
third = max(1, len(steady) // 3)
rss_early = sum(s.rss_mb for s in steady[:third]) / third
rss_late = sum(s.rss_mb for s in steady[-third:]) / third
rss_max = max(s.rss_mb for s in steady) rss_max = max(s.rss_mb for s in steady)
rss_growth = (rss_max - rss_base) / rss_base * 100 if rss_base else 0.0 rss_growth = (rss_late - rss_early) / rss_early * 100 if rss_early else 0.0
socket_budget = hot * 1.05 # §10.7: persistent sockets <= hot count + 5% socket_budget = hot * 1.05 # §10.7: persistent sockets <= hot count + 5%
lag_budget = cfg.poll_cold * 1.15 lag_budget = cfg.poll_cold * 1.15
@@ -369,9 +414,10 @@ def _report(cfg: Config, samples: list[Sample]) -> int:
f"lag_max={lag_max:.0f}s budget={lag_budget:.0f}s", f"lag_max={lag_max:.0f}s budget={lag_budget:.0f}s",
), ),
( (
"RSS stable (<15% growth)", "RSS trend stable (<15%)",
rss_growth <= rss_budget_pct, rss_growth <= rss_budget_pct,
f"rss {rss_base:.0f}->{rss_max:.0f}MB (+{rss_growth:.1f}%)", f"rss early {rss_early:.0f}MB -> late {rss_late:.0f}MB "
f"(+{rss_growth:.1f}%), peak {rss_max:.0f}MB",
), ),
] ]
@@ -385,6 +431,15 @@ def _report(cfg: Config, samples: list[Sample]) -> int:
f" [info] total open incl. transient open_p95={open_p95:.0f}", f" [info] total open incl. transient open_p95={open_p95:.0f}",
flush=True, flush=True,
) )
if cfg.mail:
delivered = samples[-1].delivered
span = samples[-1].t - samples[0].t or 1.0
rss_peak = max(s.rss_mb for s in samples)
print(
f" [info] mail delivered={delivered} (~{delivered / span:.1f} msg/s), "
f"peak RSS={rss_peak:.0f}MB",
flush=True,
)
print(f"\nsteady samples: {len(steady)} hot~{hot} owned~{owned}", flush=True) print(f"\nsteady samples: {len(steady)} hot~{hot} owned~{owned}", flush=True)
print("RESULT:", "PASS" if ok else "FAIL", flush=True) print("RESULT:", "PASS" if ok else "FAIL", flush=True)
return 0 if ok else 1 return 0 if ok else 1
@@ -407,7 +462,8 @@ async def _main() -> int:
await dispose_engine() await dispose_engine()
await _drop_database() await _drop_database()
print(f"torn down soak DB '{_SOAK_DB_NAME}'", flush=True) shutil.rmtree(_SOAK_BLOBS, ignore_errors=True)
print(f"torn down soak DB '{_SOAK_DB_NAME}' + blob dir", flush=True)
return rc return rc