sync-v2 §10.7: mock-provider soak/load harness + poll jitter
- 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>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""In-process mock ``MailProvider`` for the sync soak (acceptance §10.7).
|
||||
|
||||
No sockets, no network: ``idle`` sleeps and returns "no change", ``poll`` returns
|
||||
an empty delta. Two process-global counters make the soak's assertions meaningful
|
||||
without real file descriptors:
|
||||
|
||||
* ``persistent_open`` — providers currently open that have issued an IDLE, i.e. the
|
||||
hot-tier persistent connections. Warm/cold runners never call ``idle`` so they
|
||||
must not contribute here.
|
||||
* ``open`` — every currently-open provider (persistent + transient poll passes).
|
||||
|
||||
``last_sync[account_id]`` records the wall-clock of the account's most recent poll,
|
||||
so the harness can compute the oldest sync lag across all accounts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from app.providers.base import MailboxDelta, RemoteMailbox, RemoteMessage
|
||||
|
||||
|
||||
class MockStats:
|
||||
open = 0
|
||||
persistent_open = 0
|
||||
last_sync: dict[uuid.UUID, float] = {}
|
||||
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
cls.open = 0
|
||||
cls.persistent_open = 0
|
||||
cls.last_sync = {}
|
||||
|
||||
|
||||
class MockProvider:
|
||||
"""A MailProvider that simulates a quiet mailbox with no real I/O."""
|
||||
|
||||
def __init__(self, account_id: uuid.UUID, idle_s: float) -> None:
|
||||
self.account_id = account_id
|
||||
self.idle_s = idle_s
|
||||
self._open = False
|
||||
self._persistent = False
|
||||
|
||||
def _open_socket(self) -> None:
|
||||
if not self._open:
|
||||
self._open = True
|
||||
MockStats.open += 1
|
||||
|
||||
async def idle(self, mailbox: str) -> None:
|
||||
self._open_socket()
|
||||
if not self._persistent: # first IDLE marks this a persistent (hot) connection
|
||||
self._persistent = True
|
||||
MockStats.persistent_open += 1
|
||||
await asyncio.sleep(self.idle_s) # simulate the IDLE wait / 25-min re-issue
|
||||
|
||||
async def poll(
|
||||
self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None
|
||||
) -> MailboxDelta:
|
||||
self._open_socket()
|
||||
MockStats.last_sync[self.account_id] = time.time()
|
||||
return MailboxDelta(
|
||||
new=[],
|
||||
flag_changes=[],
|
||||
expunged_uids=[],
|
||||
new_uidvalidity=None,
|
||||
new_highestmodseq=highestmodseq,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._open:
|
||||
self._open = False
|
||||
MockStats.open -= 1
|
||||
if self._persistent:
|
||||
self._persistent = False
|
||||
MockStats.persistent_open -= 1
|
||||
|
||||
# --- unused in the steady-state soak, defined for interface completeness ----
|
||||
|
||||
async def verify(self) -> None:
|
||||
return None
|
||||
|
||||
async def list_mailboxes(self) -> list[RemoteMailbox]:
|
||||
return []
|
||||
|
||||
async def backfill(
|
||||
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
|
||||
) -> AsyncIterator[list[RemoteMessage]]:
|
||||
return
|
||||
yield # pragma: no cover - makes this an async generator
|
||||
|
||||
async def push_flags(
|
||||
self, mailbox: str, uid: int, add: frozenset[str], remove: frozenset[str]
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
async def move(self, uid: int, src: str, dst: str) -> int:
|
||||
return uid
|
||||
|
||||
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
|
||||
return None
|
||||
|
||||
async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int:
|
||||
return 0
|
||||
|
||||
async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None:
|
||||
return None
|
||||
Reference in New Issue
Block a user