- loadtest/soak_sync.py + mock_provider.py: fully isolated soak for acceptance criterion 7. Creates a throwaway webmail_soak DB + uses Redis DB 14 (both torn down), simulates N accounts (default 1000, 20% active sessions) across 3 in-process coordinators with a mock MailProvider (no real IMAP). Asserts: all accounts owned, persistent sockets <= hot*1.05, oldest lag < POLL_COLD*1.15, RSS stable. Steady state = after every account has completed its first sync. Configurable via SOAK_* / POLL_* env for fast smoke runs. - engine: poll_all_loop and secondary_poll_loop now jitter their waits (first at a random point in the interval, later +/-10%) so a fleet claimed together doesn't poll in lockstep — anti-thundering-herd, a real improvement surfaced by the soak. Validated: 60-account smoke passes all four criteria; full gate green (238 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
404 lines
14 KiB
Python
404 lines
14 KiB
Python
"""Sync-engine soak / load test — acceptance criterion §10.7 (mock provider).
|
||
|
||
Simulates ``SOAK_ACCOUNTS`` accounts (default 1000), a fraction with active
|
||
sessions, sharded across ``SOAK_WORKERS`` in-process ``SyncCoordinator`` instances
|
||
using a mock ``MailProvider`` (no real IMAP). It samples, over the run:
|
||
|
||
* persistent sockets vs hot-account count (target: persistent ≤ hot, total ≤ hot×1.05)
|
||
* process RSS (target: stable — no per-account leak)
|
||
* oldest sync lag across all accounts (target: < POLL_COLD)
|
||
|
||
**Fully isolated**: it creates its own Postgres database (``webmail_soak``) and uses
|
||
a separate Redis DB, so it never touches the dev database, the dev Redis, or a
|
||
running worker. Both are torn down at the end.
|
||
|
||
Run (defaults = the §10.7 spec, ~1h):
|
||
|
||
cd backend && python -m loadtest.soak_sync
|
||
|
||
Fast smoke run (a couple of minutes, shortened cadences):
|
||
|
||
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
|
||
|
||
Exit code 0 = all targets met, 1 = a target missed (usable in CI-nightly).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
|
||
# --- isolation: point DB + Redis at throwaway locations BEFORE importing app ---
|
||
|
||
_DEV_DB = os.environ.get(
|
||
"DATABASE_URL", "postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev"
|
||
)
|
||
_SOAK_DB_NAME = os.environ.get("SOAK_DB_NAME", "webmail_soak")
|
||
_SOAK_DB_URL = re.sub(r"/[^/?]+(\?|$)", f"/{_SOAK_DB_NAME}\\1", _DEV_DB)
|
||
os.environ["DATABASE_URL"] = _SOAK_DB_URL
|
||
os.environ.setdefault("SOAK_REDIS_URL", "redis://127.0.0.1:6380/14")
|
||
os.environ["REDIS_URL"] = os.environ["SOAK_REDIS_URL"]
|
||
|
||
import asyncio # noqa: E402
|
||
import logging # noqa: E402
|
||
import time # noqa: E402
|
||
import uuid # noqa: E402
|
||
from dataclasses import dataclass, field # noqa: E402
|
||
|
||
import asyncpg # noqa: E402
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine # noqa: E402
|
||
from sqlalchemy.pool import NullPool # noqa: E402
|
||
|
||
from app.config import get_settings # noqa: E402
|
||
from app.identity.passwords import hash_password # noqa: E402
|
||
from app.models import Account, Mailbox, User # noqa: E402
|
||
from app.redis_client import close_redis, get_redis # noqa: E402
|
||
from app.sync import leases # noqa: E402
|
||
from app.sync.coordinator import SyncCoordinator # noqa: E402
|
||
from loadtest.mock_provider import MockProvider, MockStats # noqa: E402
|
||
|
||
# --- config ------------------------------------------------------------------
|
||
|
||
|
||
def _int(name: str, default: int) -> int:
|
||
return int(os.environ.get(name, default))
|
||
|
||
|
||
@dataclass
|
||
class Config:
|
||
accounts: int = field(default_factory=lambda: _int("SOAK_ACCOUNTS", 1000))
|
||
session_pct: int = field(default_factory=lambda: _int("SOAK_SESSION_PCT", 20))
|
||
duration_s: int = field(default_factory=lambda: _int("SOAK_DURATION_S", 3600))
|
||
workers: int = field(default_factory=lambda: _int("SOAK_WORKERS", 3))
|
||
sample_s: int = field(default_factory=lambda: _int("SOAK_SAMPLE_S", 30))
|
||
idle_s: int = field(default_factory=lambda: _int("SOAK_IDLE_S", 60))
|
||
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))
|
||
|
||
def __post_init__(self) -> None:
|
||
s = get_settings()
|
||
self.poll_cold = s.poll_cold
|
||
self.poll_warm = s.poll_warm
|
||
self.session_grace_s = s.tier_session_grace_min * 60
|
||
# Warm-up must outlast one cold poll so cold accounts have synced once, but
|
||
# never eat the whole run on a short smoke test.
|
||
default_warmup = min(self.poll_cold + 90, int(self.duration_s * 0.4))
|
||
self.warmup_s = _int("SOAK_WARMUP_S", default_warmup)
|
||
|
||
|
||
# --- database lifecycle ------------------------------------------------------
|
||
|
||
|
||
def _admin_dsn() -> str:
|
||
# asyncpg DSN for the maintenance DB (strip the +asyncpg driver + swap db name).
|
||
dsn = _DEV_DB.replace("postgresql+asyncpg://", "postgresql://")
|
||
return re.sub(r"/[^/?]+(\?|$)", "/postgres\\1", dsn)
|
||
|
||
|
||
async def _create_database() -> None:
|
||
conn = await asyncpg.connect(_admin_dsn())
|
||
try:
|
||
await conn.execute(f'DROP DATABASE IF EXISTS "{_SOAK_DB_NAME}" WITH (FORCE)')
|
||
await conn.execute(f'CREATE DATABASE "{_SOAK_DB_NAME}"')
|
||
finally:
|
||
await conn.close()
|
||
|
||
|
||
async def _drop_database() -> None:
|
||
conn = await asyncpg.connect(_admin_dsn())
|
||
try:
|
||
await conn.execute(f'DROP DATABASE IF EXISTS "{_SOAK_DB_NAME}" WITH (FORCE)')
|
||
finally:
|
||
await conn.close()
|
||
|
||
|
||
def _migrate() -> None:
|
||
env = {**os.environ, "DATABASE_URL": _SOAK_DB_URL}
|
||
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
subprocess.run(
|
||
[sys.executable, "-c", "from alembic.config import main; main(argv=['upgrade', 'head'])"],
|
||
cwd=backend_dir,
|
||
env=env,
|
||
check=True,
|
||
)
|
||
|
||
|
||
_MAILBOXES = [
|
||
("INBOX", "inbox"),
|
||
("Sent", "sent"),
|
||
("Drafts", "drafts"),
|
||
("Archive", "archive"),
|
||
("Trash", "trash"),
|
||
]
|
||
|
||
|
||
async def _seed(cfg: Config) -> list[uuid.UUID]:
|
||
"""Create N users, each with one live account and a handful of mailboxes."""
|
||
engine = create_async_engine(_SOAK_DB_URL, poolclass=NullPool)
|
||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||
user_ids: list[uuid.UUID] = []
|
||
pw = hash_password("soak")
|
||
async with sm() as session:
|
||
for i in range(cfg.accounts):
|
||
user = User(handle=f"soak-{i:05d}@load.local", password_hash=pw)
|
||
session.add(user)
|
||
await session.flush()
|
||
user_ids.append(user.id)
|
||
account = Account(
|
||
user_id=user.id,
|
||
email=f"soak-{i:05d}@load.local",
|
||
display_name="Soak",
|
||
imap_config={"host": "mock", "port": 993, "tls": "ssl"},
|
||
smtp_config={"host": "mock", "port": 465, "tls": "ssl"},
|
||
secret_ct=b"",
|
||
discovery_src="manual",
|
||
state="live",
|
||
)
|
||
session.add(account)
|
||
await session.flush()
|
||
for path, special in _MAILBOXES:
|
||
session.add(
|
||
Mailbox(account_id=account.id, user_id=user.id, path=path, special_use=special)
|
||
)
|
||
if i % 200 == 0:
|
||
await session.flush()
|
||
await session.commit()
|
||
await engine.dispose()
|
||
return user_ids
|
||
|
||
|
||
# --- metrics -----------------------------------------------------------------
|
||
|
||
|
||
def _rss_mb() -> float:
|
||
with open("/proc/self/status") as fh:
|
||
for line in fh:
|
||
if line.startswith("VmRSS:"):
|
||
return int(line.split()[1]) / 1024.0
|
||
return 0.0
|
||
|
||
|
||
@dataclass
|
||
class Sample:
|
||
t: float
|
||
owned: int
|
||
hot: int
|
||
warm: int
|
||
cold: int
|
||
persistent: int
|
||
total_open: int
|
||
max_lag: float
|
||
rss_mb: float
|
||
synced: int # accounts that have completed >=1 poll (steady-state gate)
|
||
|
||
|
||
async def _tier_counts(coords: list[SyncCoordinator]) -> tuple[int, int, int, int]:
|
||
owned = hot = warm = cold = 0
|
||
for c in coords:
|
||
for runner in list(c._runners.values()):
|
||
owned += 1
|
||
if runner.tier == "hot":
|
||
hot += 1
|
||
elif runner.tier == "warm":
|
||
warm += 1
|
||
else:
|
||
cold += 1
|
||
return owned, hot, warm, cold
|
||
|
||
|
||
def _max_lag(now: float, seeded: int, start: float) -> float:
|
||
"""Oldest sync lag across accounts. Never-synced accounts count from run start."""
|
||
worst = 0.0
|
||
synced = MockStats.last_sync
|
||
for ts in synced.values():
|
||
worst = max(worst, now - ts)
|
||
if len(synced) < seeded: # some accounts have not completed a first poll yet
|
||
worst = max(worst, now - start)
|
||
return worst
|
||
|
||
|
||
# --- run ---------------------------------------------------------------------
|
||
|
||
|
||
def _percentile(values: list[float], pct: float) -> float:
|
||
if not values:
|
||
return 0.0
|
||
ordered = sorted(values)
|
||
idx = min(len(ordered) - 1, int(pct / 100.0 * len(ordered)))
|
||
return ordered[idx]
|
||
|
||
|
||
async def _soak(cfg: Config) -> int:
|
||
import app.sync.engine as engine_mod
|
||
|
||
# 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]
|
||
MockStats.reset()
|
||
|
||
redis = get_redis()
|
||
await redis.flushdb()
|
||
|
||
user_ids = await _seed(cfg)
|
||
active_users = user_ids[: cfg.accounts * cfg.session_pct // 100]
|
||
|
||
async def refresh_sessions() -> None:
|
||
for uid in active_users:
|
||
await leases.mark_user_active(redis, uid, cfg.session_grace_s)
|
||
|
||
await refresh_sessions()
|
||
|
||
coords = [SyncCoordinator(f"soak-w{i}") for i in range(cfg.workers)]
|
||
for c in coords:
|
||
c.claim_interval = cfg.claim_s
|
||
c.claim_jitter = 1.0
|
||
c.tier_eval_interval = cfg.tier_eval_s
|
||
c.heartbeat_refresh = 5.0
|
||
c.rebalance_interval = 20.0
|
||
c.reconcile_interval = 60.0
|
||
await asyncio.gather(*(c.start() for c in coords))
|
||
|
||
start = time.time()
|
||
deadline = start + cfg.duration_s
|
||
next_session_refresh = start + cfg.session_grace_s / 2
|
||
samples: list[Sample] = []
|
||
print(
|
||
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"POLL_COLD={cfg.poll_cold}s POLL_WARM={cfg.poll_warm}s",
|
||
flush=True,
|
||
)
|
||
print(
|
||
f"{'t':>6} {'owned':>6} {'hot':>5} {'warm':>5} {'cold':>5} "
|
||
f"{'persist':>8} {'open':>5} {'lag_s':>7} {'rss_mb':>8}",
|
||
flush=True,
|
||
)
|
||
try:
|
||
while time.time() < deadline:
|
||
await asyncio.sleep(cfg.sample_s)
|
||
now = time.time()
|
||
if now >= next_session_refresh:
|
||
await refresh_sessions()
|
||
next_session_refresh = now + cfg.session_grace_s / 2
|
||
owned, hot, warm, cold = await _tier_counts(coords)
|
||
s = Sample(
|
||
t=now - start,
|
||
owned=owned,
|
||
hot=hot,
|
||
warm=warm,
|
||
cold=cold,
|
||
persistent=MockStats.persistent_open,
|
||
total_open=MockStats.open,
|
||
max_lag=_max_lag(now, cfg.accounts, start),
|
||
rss_mb=_rss_mb(),
|
||
synced=len(MockStats.last_sync),
|
||
)
|
||
samples.append(s)
|
||
print(
|
||
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}",
|
||
flush=True,
|
||
)
|
||
finally:
|
||
await asyncio.gather(*(c.stop() for c in coords), return_exceptions=True)
|
||
|
||
return _report(cfg, samples)
|
||
|
||
|
||
def _report(cfg: Config, samples: list[Sample]) -> int:
|
||
# Steady state begins once every account has completed its first sync (and at
|
||
# least the configured warm-up has elapsed) — before that, lag legitimately
|
||
# reflects accounts still ramping up, not a starved steady state.
|
||
all_synced_t = next((s.t for s in samples if s.synced >= cfg.accounts), None)
|
||
if all_synced_t is None:
|
||
worst = max((s.synced for s in samples), default=0)
|
||
print(
|
||
f"\nFAIL: not all accounts synced (best {worst}/{cfg.accounts}); "
|
||
"increase SOAK_DURATION_S",
|
||
flush=True,
|
||
)
|
||
return 1
|
||
floor = max(cfg.warmup_s, all_synced_t)
|
||
steady = [s for s in samples if s.t >= floor]
|
||
if not steady:
|
||
print("\nFAIL: no steady-state samples (increase SOAK_DURATION_S)", flush=True)
|
||
return 1
|
||
|
||
owned = max(s.owned for s in steady)
|
||
hot = max(s.hot for s in steady)
|
||
persistent_p95 = _percentile([s.persistent 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)
|
||
rss_base = steady[0].rss_mb
|
||
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
|
||
|
||
socket_budget = hot * 1.05 # §10.7: persistent sockets <= hot count + 5%
|
||
lag_budget = cfg.poll_cold * 1.15
|
||
rss_budget_pct = 15.0
|
||
|
||
# §10.7 hard criteria.
|
||
checks = [
|
||
(
|
||
"all accounts owned",
|
||
owned >= cfg.accounts,
|
||
f"owned={owned}/{cfg.accounts}",
|
||
),
|
||
(
|
||
"persistent sockets <= hot+5%",
|
||
persistent_p95 <= socket_budget,
|
||
f"persist_p95={persistent_p95:.0f} budget={socket_budget:.0f} (hot={hot})",
|
||
),
|
||
(
|
||
"oldest lag < POLL_COLD*1.15",
|
||
lag_max < lag_budget,
|
||
f"lag_max={lag_max:.0f}s budget={lag_budget:.0f}s",
|
||
),
|
||
(
|
||
"RSS stable (<15% growth)",
|
||
rss_growth <= rss_budget_pct,
|
||
f"rss {rss_base:.0f}->{rss_max:.0f}MB (+{rss_growth:.1f}%)",
|
||
),
|
||
]
|
||
|
||
print("\n=== soak result (steady-state) ===", flush=True)
|
||
ok = True
|
||
for name, passed, detail in checks:
|
||
ok = ok and passed
|
||
print(f" [{'PASS' if passed else 'FAIL'}] {name:30} {detail}", flush=True)
|
||
# Observational (transient poll connections, not a §10.7 gate).
|
||
print(
|
||
f" [info] total open incl. transient open_p95={open_p95:.0f}",
|
||
flush=True,
|
||
)
|
||
print(f"\nsteady samples: {len(steady)} hot~{hot} owned~{owned}", flush=True)
|
||
print("RESULT:", "PASS" if ok else "FAIL", flush=True)
|
||
return 0 if ok else 1
|
||
|
||
|
||
async def _main() -> int:
|
||
if not os.environ.get("SOAK_DEBUG"):
|
||
logging.disable(logging.WARNING) # quiet the per-account loops; keep ERROR+
|
||
else:
|
||
logging.basicConfig(level=logging.INFO)
|
||
print(f"provisioning isolated soak DB '{_SOAK_DB_NAME}' + Redis {os.environ['REDIS_URL']} ...")
|
||
await _create_database()
|
||
_migrate()
|
||
try:
|
||
rc = await _soak(Config())
|
||
finally:
|
||
await get_redis().flushdb()
|
||
await close_redis()
|
||
from app.db import dispose_engine
|
||
|
||
await dispose_engine()
|
||
await _drop_database()
|
||
print(f"torn down soak DB '{_SOAK_DB_NAME}'", flush=True)
|
||
return rc
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(asyncio.run(_main()))
|