pneumatube/scripts/stress_test.py

532 lines
23 KiB
Python

#!/usr/bin/env python3
"""Concurrent load test for pneumatube.
Two targets:
direct Talks to a `pneumatube` subprocess directly over LMTP/SMTP,
bypassing Postfix. Isolates pneumatube's own concurrency
behaviour -- in particular, SmolWorker (worker.py) serializes
every smolmail operation onto one thread, so this is where that
design's throughput ceiling actually shows up.
postfix Drives the same load through the real docker-compose.yml rig
(Postfix -> LMTP -> pneumatube), to confirm nothing changes with
a real MTA in front. Needs podman (or docker) + compose.
Both targets spin up a throwaway smolmaild, register a gateway account plus
N recipient/sender accounts against it, generate concurrent load, and verify
every expected message actually arrived exactly once -- throughput numbers
are meaningless if messages were silently dropped or duplicated.
Usage:
uv run python scripts/stress_test.py --target direct
uv run python scripts/stress_test.py --target postfix
uv run python scripts/stress_test.py --target both --concurrency 40
Needs a `smolmail` checkout next to this repo (same default pneumatube
itself uses: PNEUMATUBE_SMOLMAIL_REPO, default "../smolmail").
"""
from __future__ import annotations
import argparse
import contextlib
import os
import shutil
import smtplib
import socket
import subprocess
import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from aiosmtpd.controller import Controller
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from pneumatube import vendor # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def wait_for_port(host: str, port: int, timeout: float = 15.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=0.5):
return
except OSError:
time.sleep(0.1)
raise TimeoutError(f"nothing listening on {host}:{port} after {timeout}s")
# --- reporting ----------------------------------------------------------------
@dataclass
class Report:
label: str
attempted: int = 0
succeeded: int = 0
rejected: int = 0
errors: int = 0
latencies: list[float] = field(default_factory=list)
def record(self, outcome: str, latency: float) -> None:
self.attempted += 1
self.latencies.append(latency)
if outcome == "ok":
self.succeeded += 1
elif outcome == "rejected":
self.rejected += 1
else:
self.errors += 1
def print(self) -> None:
lat = sorted(self.latencies)
p50 = lat[len(lat) // 2] if lat else 0.0
p99 = lat[int(len(lat) * 0.99)] if lat else 0.0
print(f"-- {self.label} --")
print(f" attempted={self.attempted} succeeded={self.succeeded} "
f"rejected={self.rejected} errors={self.errors}")
print(f" latency p50={p50 * 1000:.1f}ms p99={p99 * 1000:.1f}ms "
f"max={max(lat, default=0) * 1000:.1f}ms")
# --- smolmail test fixtures ----------------------------------------------------
@dataclass
class SmolServer:
host: str
port: int
pubkey_b32: str
process: subprocess.Popen | None = None
def parse_pubkey(text: str) -> str:
for line in text.splitlines():
if "public key" in line:
return line.strip().rsplit(" ", 1)[-1]
raise RuntimeError(f"no public key line in output:\n{text}")
def start_smolmaild(smolmail_repo: Path, tmp_dir: Path, rate_connections: int,
rate_sends: int) -> SmolServer:
port = free_port()
key_path = tmp_dir / "server.key"
subprocess.run(
["uv", "run", str(smolmail_repo / "smolmaild.py"), "keygen", "--key", str(key_path)],
cwd=smolmail_repo, check=True, capture_output=True, text=True)
process = subprocess.Popen(
["uv", "run", str(smolmail_repo / "smolmaild.py"), "serve",
"--key", str(key_path), "--db", str(tmp_dir / "server.db"),
"--host", "127.0.0.1", "--port", str(port),
# smolmaild's connection/send rate limits are keyed by source IP
# (SPEC.md S10); every sender in this script shares one IP, so the
# defaults (120 conn/min, 60 sends/min) would measure the rate
# limiter, not pneumatube, unless raised for a many-sender test.
"--rate-connections", str(rate_connections), "--rate-sends", str(rate_sends)],
cwd=smolmail_repo, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
pubkey = None
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
line = process.stdout.readline()
if not line:
break
if "server public key" in line:
pubkey = line.strip().rsplit(" ", 1)[-1]
break
if pubkey is None:
process.kill()
raise RuntimeError("smolmaild never printed its public key")
wait_for_port("127.0.0.1", port)
return SmolServer("127.0.0.1", port, pubkey, process)
@dataclass
class SmolAccount:
username: str
key_path: Path
db_path: Path
address: str # "user@host:port"
def make_account(smol, tmp_dir: Path, name: str, server: SmolServer) -> SmolAccount:
"""Keygen + trust + register, reusing smolmail.py's own CLI commands
directly (the same functions the gateway itself calls) rather than
reimplementing the protocol or shelling out per account.
A bare TCP connect (wait_for_port) can succeed slightly before a
containerized smolmaild can actually complete a Noise handshake, so
register is retried a few times rather than treating that race as fatal.
"""
key_path, db_path = tmp_dir / f"{name}.key", tmp_dir / f"{name}.db"
store = smol.Store(str(db_path))
smol.cmd_keygen(argparse.Namespace(key=str(key_path), force=True), store)
smol.cmd_trust(argparse.Namespace(host=f"{server.host}:{server.port}",
key_b32=server.pubkey_b32, force=True), store)
address = f"{name}@{server.host}:{server.port}"
register_args = argparse.Namespace(key=str(key_path), address=address, token=None)
attempts = 20
for attempt in range(attempts):
try:
smol.cmd_register(register_args, store)
break
except (smol.SmolError, ConnectionError, OSError):
if attempt == attempts - 1:
raise
time.sleep(1.0)
return SmolAccount(name, key_path, db_path, address)
def repoint_gateway_account(smol, gateway: SmolAccount, host: str, port: int,
pubkey_b32: str) -> None:
"""Point the gateway's own remembered server connection at a different
host string (e.g. a compose service name) without a real round trip --
`trust` only ever stores a pin locally (smolmail.py's own cmd_trust)."""
store = smol.Store(str(gateway.db_path))
smol.cmd_trust(argparse.Namespace(host=host, key_b32=pubkey_b32, force=True), store)
store.set_account(smol.Address(gateway.username, host, port))
# --- outbound SMTP sink (stands in for the real relay) -------------------------
class CountingSink:
def __init__(self) -> None:
self.lock = threading.Lock()
self.received: list[tuple[str, tuple[str, ...]]] = []
async def handle_DATA(self, server, session, envelope):
with self.lock:
self.received.append((envelope.mail_from, tuple(envelope.rcpt_tos)))
return "250 OK"
def count(self) -> int:
with self.lock:
return len(self.received)
# --- load generators (each measures its own latency) ---------------------------
def send_one_lmtp(host: str, port: int, mail_from: str, rcpt: str, body: bytes) -> tuple[str, float]:
t0 = time.monotonic()
try:
with smtplib.LMTP(host, port, timeout=10) as client:
client.ehlo("stress-test")
client.mail(mail_from)
code, _ = client.rcpt(rcpt)
if code >= 500:
return "rejected", time.monotonic() - t0
if code >= 400:
return "error", time.monotonic() - t0
client.data(body)
return "ok", time.monotonic() - t0
except Exception:
return "error", time.monotonic() - t0
def send_one_smtp(host: str, port: int, mail_from: str, rcpt: str, body: bytes) -> tuple[str, float]:
t0 = time.monotonic()
try:
with smtplib.SMTP(host, port, timeout=10) as client:
client.ehlo("stress-test")
client.mail(mail_from)
code, _ = client.rcpt(rcpt)
if code >= 500:
return "rejected", time.monotonic() - t0
if code >= 400:
return "error", time.monotonic() - t0
client.data(body)
return "ok", time.monotonic() - t0
except Exception:
return "error", time.monotonic() - t0
def send_one_smol(smol, account: SmolAccount, to_address: str, email_to: str,
subject: str) -> tuple[str, float]:
t0 = time.monotonic()
store = smol.Store(str(account.db_path))
send_args = argparse.Namespace(
key=str(account.key_path), address=to_address, body=f"load from {account.username}",
subject=subject, reply_to=None, header=[f"Email-To: {email_to}"],
anonymous=True, no_pad=False)
try:
smol.cmd_send(send_args, store)
return "ok", time.monotonic() - t0
except Exception:
return "error", time.monotonic() - t0
def run_concurrent(report: Report, concurrency: int, jobs: list) -> list[bool]:
"""jobs: list of (fn, args, expected_valid). Returns per-job valid flags
in submission order so the caller can cross-check expected vs actual."""
valid_flags = [valid for _, _, valid in jobs]
with ThreadPoolExecutor(max_workers=concurrency) as pool:
start = time.monotonic()
futures = [pool.submit(fn, *fn_args) for fn, fn_args, _ in jobs]
for future in futures:
outcome, latency = future.result()
report.record(outcome, latency)
elapsed = time.monotonic() - start
report.print()
print(f" throughput={len(jobs) / elapsed:.1f} msg/s over {elapsed:.1f}s")
return valid_flags
# --- direct target ---------------------------------------------------------------
def run_direct(args: argparse.Namespace) -> None:
smolmail_repo = Path(args.smolmail_repo).resolve()
with tempfile.TemporaryDirectory(prefix="pneumatube-stress-") as tmp:
tmp_dir = Path(tmp)
print(f"[direct] starting smolmaild in {tmp_dir}")
server = start_smolmaild(smolmail_repo, tmp_dir, args.server_rate_connections,
args.server_rate_sends)
smol = vendor.load(smolmail_repo)
gateway = make_account(smol, tmp_dir, "gatewayuser", server)
recipients = [make_account(smol, tmp_dir, f"recipient{i}", server)
for i in range(args.recipients)]
senders = [make_account(smol, tmp_dir, f"sender{i}", server)
for i in range(args.concurrency)]
print(f"[direct] {len(recipients)} recipient account(s), "
f"{len(senders)} sender account(s) ready")
sink = CountingSink()
sink_port = free_port()
sink_controller = Controller(sink, hostname="127.0.0.1", port=sink_port)
sink_controller.start()
listen_port, lookup_port = free_port(), free_port()
env = {**os.environ,
"PNEUMATUBE_SMOLMAIL_REPO": str(smolmail_repo),
"PNEUMATUBE_SMOLMAIL_KEY": str(gateway.key_path),
"PNEUMATUBE_SMOLMAIL_DB": str(gateway.db_path),
"PNEUMATUBE_SMOLMAIL_DOMAIN": f"{server.host}:{server.port}",
"PNEUMATUBE_EMAIL_DOMAIN": "gateway.example",
"PNEUMATUBE_FETCH_INTERVAL": "2",
# Throughput, not the accept-gate features, is this script's
# concern -- the throwaway recipient/sender accounts here
# never go through the accept dance with the gateway.
"PNEUMATUBE_REQUIRE_ACCEPTED_SENDER": "0",
"PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT": "0",
"PNEUMATUBE_LISTEN_PROTOCOL": "lmtp",
"PNEUMATUBE_LISTEN_HOST": "127.0.0.1",
"PNEUMATUBE_LISTEN_PORT": str(listen_port),
"PNEUMATUBE_LOOKUP_HOST": "127.0.0.1",
"PNEUMATUBE_LOOKUP_PORT": str(lookup_port),
"PNEUMATUBE_SMTP_RELAY_HOST": "127.0.0.1",
"PNEUMATUBE_SMTP_RELAY_PORT": str(sink_port),
"PNEUMATUBE_SMTP_RELAY_STARTTLS": "0",
"PNEUMATUBE_GATEWAY_FROM_ADDRESS": "gateway@gateway.example"}
print("[direct] starting pneumatube")
proc = subprocess.Popen(["uv", "run", "pneumatube"], cwd=REPO_ROOT, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
try:
wait_for_port("127.0.0.1", listen_port)
wait_for_port("127.0.0.1", lookup_port)
# Phase A: email -> smolmail, concurrent LMTP, mixed valid/invalid.
total = args.concurrency * args.messages
jobs = []
for i in range(total):
valid = i % 3 != 0 # 2/3 valid, 1/3 unknown-user
rcpt = (f"{recipients[i % len(recipients)].username}@gateway.example"
if valid else "nobody-at-all@gateway.example")
body = f"Subject: load {i}\r\n\r\nmessage {i}\r\n".encode()
jobs.append((send_one_lmtp, ("127.0.0.1", listen_port,
"stress@sender.example", rcpt, body), valid))
report_a = Report("email -> smolmail (LMTP, direct)")
valid_flags = run_concurrent(report_a, args.concurrency, jobs)
expected_ok, expected_rejected = sum(valid_flags), valid_flags.count(False)
ok = report_a.succeeded == expected_ok and report_a.rejected == expected_rejected
print(f" correctness: {'PASS' if ok else 'FAIL'} "
f"(expected ok={expected_ok} rejected={expected_rejected})")
# Phase B: smolmail -> email, concurrent SEND, then drain via the
# gateway's own poll loop (already running in the subprocess).
jobs = [(send_one_smol, (smol, senders[i % len(senders)], gateway.address,
f"dest{i}@example.org", f"load {i}"), True)
for i in range(total)]
report_b = Report("smolmail -> email (SEND + poll drain)")
run_concurrent(report_b, args.concurrency, jobs)
drain_deadline = time.monotonic() + max(30.0, total * 0.05)
while sink.count() < report_b.succeeded and time.monotonic() < drain_deadline:
time.sleep(0.5)
drained = sink.count()
print(f" drained {drained}/{report_b.succeeded} into the outbound sink "
f"({'PASS' if drained == report_b.succeeded else 'FAIL: lost or stuck messages'})")
finally:
proc.terminate()
with contextlib.suppress(Exception):
proc.wait(timeout=5)
sink_controller.stop()
server.process.terminate()
with contextlib.suppress(Exception):
server.process.wait(timeout=5)
# --- postfix target ---------------------------------------------------------------
def podman(*cmd: str, **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(["podman", *cmd], text=True, capture_output=True, **kwargs)
def run_postfix(args: argparse.Namespace) -> None:
if shutil.which("podman") is None:
print("[postfix] podman not found; skipping", file=sys.stderr)
return
smolmail_repo = Path(args.smolmail_repo).resolve()
smol = vendor.load(smolmail_repo)
network = "pneumatube-stress-net"
env = {**os.environ,
"PNEUMATUBE_SMOLMAIL_DOMAIN": "smolmail-dev:1961",
"PNEUMATUBE_EMAIL_DOMAIN": "gateway.example",
# Throughput, not the accept-gate features, is this script's
# concern -- the throwaway recipient accounts here never go
# through the accept dance with the gateway.
"PNEUMATUBE_REQUIRE_ACCEPTED_SENDER": "0",
"PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT": "0",
"PNEUMATUBE_SMTP_RELAY_HOST": "smolmail-dev",
"PNEUMATUBE_SMTP_RELAY_PORT": "2626",
"PNEUMATUBE_SMTP_RELAY_STARTTLS": "0",
"PNEUMATUBE_GATEWAY_FROM_ADDRESS": "gateway@gateway.example"}
with tempfile.TemporaryDirectory(prefix="pneumatube-stress-pf-") as tmp:
tmp_dir = Path(tmp)
data_dir = REPO_ROOT / "data"
data_dir.mkdir(exist_ok=True)
try:
print("[postfix] building images")
subprocess.run(["podman", "compose", "build"], cwd=REPO_ROOT, env=env, check=True)
print("[postfix] starting throwaway smolmaild-dev container")
podman("network", "create", network)
server_dir = tmp_dir / "server"
server_dir.mkdir()
keygen = podman("run", "--rm", "-v", f"{smolmail_repo}:/smolmail:ro",
"-v", f"{server_dir}:/data",
"localhost/pneumatube_pneumatube:latest",
"uv", "run", "/smolmail/smolmaild.py", "keygen", "--key", "/data/server.key")
keygen.check_returncode()
pubkey = parse_pubkey(keygen.stdout)
host_port = free_port()
podman("run", "-d", "--name", "pneumatube-stress-smolmaild",
"--network", network, "--network-alias", "smolmail-dev",
"-p", f"{host_port}:1961",
"-v", f"{smolmail_repo}:/smolmail:ro", "-v", f"{server_dir}:/data",
"localhost/pneumatube_pneumatube:latest",
"uv", "run", "/smolmail/smolmaild.py", "serve",
"--key", "/data/server.key", "--db", "/data/server.db",
"--host", "0.0.0.0", "--port", "1961",
"--rate-connections", str(args.server_rate_connections),
"--rate-sends", str(args.server_rate_sends)).check_returncode()
wait_for_port("127.0.0.1", host_port)
server = SmolServer("127.0.0.1", host_port, pubkey)
gateway = make_account(smol, tmp_dir, "gatewayuser", server)
recipients = [make_account(smol, tmp_dir, f"recipient{i}", server)
for i in range(args.recipients)]
repoint_gateway_account(smol, gateway, "smolmail-dev", 1961, pubkey)
(data_dir / "gateway.key").write_bytes(gateway.key_path.read_bytes())
(data_dir / "gateway.db").write_bytes(gateway.db_path.read_bytes())
print("[postfix] starting pneumatube + postfix")
subprocess.run(["podman", "compose", "up", "-d"], cwd=REPO_ROOT, env=env, check=True)
# Join the compose-started containers to the network carrying
# smolmail-dev (they start on compose's own default network,
# which smolmail-dev was never part of) -- not the other way
# around, which would just reconnect smolmail-dev to a network
# it's already on and leave pneumatube unable to resolve it.
podman("network", "connect", network, "pneumatube_pneumatube_1")
podman("network", "connect", network, "pneumatube_postfix_1")
wait_for_port("127.0.0.1", 2525, timeout=60.0)
# Port 25 accepting doesn't mean postconf's chroot/transport_maps
# setup (entrypoint.sh) has finished; give it a moment to settle.
time.sleep(2)
total = args.concurrency * args.messages
jobs = []
for i in range(total):
valid = i % 3 != 0
rcpt = (f"{recipients[i % len(recipients)].username}@gateway.example"
if valid else "nobody-at-all@gateway.example")
body = f"Subject: pf-load {i}\r\n\r\nmessage {i}\r\n".encode()
jobs.append((send_one_smtp, ("127.0.0.1", 2525,
"stress@sender.example", rcpt, body), valid))
report = Report("email -> smolmail (through Postfix)")
valid_flags = run_concurrent(report, args.concurrency, jobs)
expected_ok, expected_rejected = sum(valid_flags), valid_flags.count(False)
print(f" correctness (live reject, not bounce): "
f"{'PASS' if report.rejected == expected_rejected else 'FAIL'} "
f"(expected ok={expected_ok} rejected={expected_rejected})")
deadline = time.monotonic() + max(30.0, total * 0.1)
delivered = 0
while time.monotonic() < deadline:
delivered = 0
for r in recipients:
store = smol.Store(str(r.db_path))
smol.cmd_fetch(argparse.Namespace(key=str(r.key_path), db=str(r.db_path),
reset=False, keep=False), store)
delivered += len(store.mail("all"))
if delivered >= expected_ok:
break
time.sleep(1)
print(f" delivered {delivered}/{expected_ok} across recipient inboxes "
f"({'PASS' if delivered == expected_ok else 'FAIL: lost or stuck messages'})")
finally:
print("[postfix] tearing down")
subprocess.run(["podman", "compose", "down"], cwd=REPO_ROOT, env=env)
podman("rm", "-f", "pneumatube-stress-smolmaild")
podman("network", "rm", network)
with contextlib.suppress(Exception):
(data_dir / "gateway.key").unlink()
(data_dir / "gateway.db").unlink()
data_dir.rmdir()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--target", choices=["direct", "postfix", "both"], default="direct")
parser.add_argument("--concurrency", type=int, default=20,
help="concurrent sender workers per phase")
parser.add_argument("--messages", type=int, default=10,
help="messages sent by each worker")
parser.add_argument("--recipients", type=int, default=10,
help="number of pre-registered valid recipient accounts")
parser.add_argument("--smolmail-repo", default="../smolmail")
parser.add_argument("--server-rate-connections", type=int, default=6000,
help="smolmaild --rate-connections for the throwaway test server "
"(default 120/min would rate-limit this whole test, since every "
"sender shares one source IP; pass 120 to test against that "
"realistic default instead)")
parser.add_argument("--server-rate-sends", type=int, default=6000,
help="smolmaild --rate-sends for the throwaway test server "
"(see --server-rate-connections; realistic default is 60)")
args = parser.parse_args()
if args.target in ("direct", "both"):
run_direct(args)
if args.target in ("postfix", "both"):
run_postfix(args)
if __name__ == "__main__":
main()