deer-flow/backend/scripts/benchmark/concurrency/run_concurrency_bench.py
Otavio Rodrigues Santana 27cb73659d
fix(auth): correct OAuth conflict error message + validate multi-worker Postgres claim with real concurrency benchmark (#5026)
* fix(auth): correct OAuth uniqueness error and index parity on Postgres

create_user() caught any IntegrityError on commit and always reported it
as a duplicate email. The email pre-check already rules out a real email
collision in the common case, so any IntegrityError reaching that handler
is actually idx_users_oauth_identity firing instead -- confirmed against
both backends: SQLite reports "UNIQUE constraint failed:
users.oauth_provider, users.oauth_id", Postgres reports a
UniqueViolationError naming the same index. The caller saw "Email already
registered" for an OAuth account conflict, which is wrong and would send
API consumers debugging the wrong field.

Distinguish the two cases via a substring check on the driver error text
(both backends name the oauth columns) and raise an accurate message for
each.

Also add postgresql_where to the same index, alongside the existing
sqlite_where. This is not a correctness fix -- verified empirically that
Postgres already enforces the same practical uniqueness without it
(NULL is never equal to NULL in either backends unique index, so real
duplicate (provider, id) pairs are already rejected and NULL/NULL rows
are already unconstrained). postgresql_where makes the index genuinely
partial on Postgres too, matching the stated intent in the surrounding
comment and keeping the index smaller as the common case (plain-password
accounts, both columns NULL) accumulates.

* test(bench): add multi-process SQLite vs Postgres concurrency benchmark

CONFIGURATION.md documents that multi-worker deployments must use Postgres
because "SQLite silently ignores row-level locks", but nothing in the repo
exercised that claim against real separate worker processes -- the existing
checkpoint benchmarks (scripts/benchmark/checkpoint/) measure single-process
read/write latency, and the existing Postgres tests
(test_pg_schema_integration.py, test_multi_worker_postgres_gate.py) cover
schema placement and config validation, not throughput or lock behavior
under concurrent load.

run_concurrency_bench.py spawns N real OS processes (subprocess.Popen, not
asyncio tasks or threads within one process) against the shared users
table, mixing reads (get_user_by_email) and writes (create_user) at a
configurable ratio, and reports throughput, error counts by exception
type, and p50/p95/p99/max latency per run.

Measured locally (2/4/8/16 workers, 100 ops/worker, 70/30 read/write):
SQLite completed all operations with zero errors at every worker count
(busy_timeout absorbs contention rather than raising), but total
throughput stayed flat around 28-34 ops/s regardless of worker count, and
p99 latency grew from ~400ms at 2 workers to ~5.9s at 16, with a 22s max.
Postgres throughput scaled with worker count (41 to 66 ops/s) and p99
stayed under 500ms at every worker count tested. Raw JSON output from
both runs is available on request; exact numbers will vary by machine and
are not asserted in the test suite.

test_bench_concurrency.py unit-tests the pure aggregation logic
(percentile math, error grouping, crashed-worker handling) the same way
test_bench_checkpoint_channels.py does for the existing benchmarks --
fast, no DB required, not the full multi-process sweep in CI.

* fix(auth): inspect the driver exception for OAuth conflict detection

str(exc) embeds the full failed INSERT statement, whose column list
always names oauth_provider/oauth_id, so a substring check on it
misclassified every commit-time IntegrityError on the users table as
an OAuth conflict (reproduced on SQLite: a duplicate primary key with
a different email raised "OAuth account already linked: None/None").

_is_oauth_identity_violation now inspects exc.orig instead: constraint_name
on Postgres, both violated column names present (not a bare "oauth"
substring) on SQLite.

Also ships the alembic revision idx_users_oauth_identity's postgresql_where
predicate needed: 0001_baseline created it as a full index on Postgres,
and ORM metadata changes only affect fresh create_all databases, never an
already-versioned deployment.

Addresses review feedback from willem-bd.

* fix(bench): run the concurrency benchmark in an isolated schema and derive paths from the checkout

--pg-url accepted an arbitrary database URL while the code pinned
postgres_schema="public" and unconditionally ran DELETE FROM users --
against any non-disposable database that permanently destroyed every
auth account. Each run now generates a unique throwaway schema
(bench_<uuid>), points both the seeder and every worker subprocess at
it via postgres_schema, and drops only that schema (DROP SCHEMA ...
CASCADE) once the full worker-count sweep finishes.

Also stopped hard-coding /opt/deer-flow/backend as the checkout path
and .venv/bin/python3 as the interpreter: BACKEND_DIR is now derived
from Path(__file__), and workers are spawned with sys.executable (the
orchestrator's own interpreter) instead, so the documented
uv run python scripts/benchmark/concurrency/run_concurrency_bench.py
command works from any checkout.

Addresses review feedback from willem-bd.

* fix: shorten oauth-index revision id, repin migration-head assertions, fix bench read/write mix

- 0017_users_oauth_identity_partial_pg (36 chars) exceeded
  alembic_version.version_num's VARCHAR(32) limit, which would fail
  stamping/upgrading on both fresh and existing Postgres deployments.
  Renamed to 0017_oauth_identity_pg_partial (30 chars).
- Repinned every test asserting 0016_subagent_batches as the migration
  head (test_persistence_bootstrap[.py|_concurrency.py|_regression.py],
  test_migration_0004/0007/0015) to the new 0017 revision id.
- worker.py's `(i % 100) < int(read_ratio * 100)` assumed n_ops >= 100;
  at the documented default (50 ops/worker, 0.7 read ratio) it produced
  either all-reads or all-writes, never the claimed mixed workload.
  Replaced with read_count()/is_read_op(), which distribute an exact
  round(n_ops * read_ratio) reads evenly across the sequence via modular
  spacing, and added test_bench_worker.py covering the default values
  plus small op counts.

* fix(bench): establish a real physical connection before timing ops

async with sf(): pass entered an empty AsyncSession without checking out
a physical connection -- SQLAlchemy stays lazy until the first statement
executes. That pushed connection-establishment cost onto each worker's
first timed operation instead of conn_time_s, and at 16 workers those 16
cold first-ops (1% of a 1600-op sample) could skew the reported p99.
Execute a real `SELECT 1` before starting the timer instead.

Verified with a real end-to-end run (uv sync + sqlite backend, 2
workers/10 ops, 0 errors) plus the full auth/bench/migration-bootstrap
suites (135 tests) and ruff check/format, all clean.

* fix(bench): synchronize workers before timing, fix percentile off-by-one

Two remaining measurement issues from review:

- run_workers() started the wall clock before spawning any worker, so
  throughput/wall_time absorbed N processes' staggered Python-startup and
  connection-establishment cost, and early workers could run ahead of ones
  still starting. Workers now print READY right before their timed loop
  and block on stdin for a GO signal; the orchestrator waits for every
  READY, then starts the timer and releases all workers together.

- summarize()'s pct() used int(len(latencies) * p) directly as a
  zero-based index -- a one-based-rank-as-index bug that put p95 and p99
  at the same slot (the max) for any 20-or-fewer-sample run, and for the
  documented 100-sample default. Now delegates to
  checkpoint_bench_common.percentile(), the already-correct nearest-rank
  implementation used elsewhere in the same benchmark family, instead of
  a second, broken one.

Verified: 14/14 unit tests pass (2 new pinned-value regression tests for
the percentile bug, using the reviewer's own 20-sample repro), ruff
clean, and a real 2/4-worker SQLite multi-process smoke run completes
with distinct p95/p99/max latencies and no hang.

* fix(bench): absolute SQLite bench path, surface crash diagnostics, exit nonzero on failure; share OAuth index constant + cover Postgres branch

Three more findings from review at 5fd25a7:

- seed_baseline() cleaned an absolute .deer-flow/bench_data path but
  handed DatabaseConfig a relative one, which resolves against the
  CALLER's CWD -- not BACKEND_DIR. Invoking the documented command from
  anywhere other than backend/ silently pointed the seeder and the
  (cwd=BACKEND_DIR) workers at two different directories: workers crashed
  with 'unable to open database file' while the run still printed a
  well-formed summary and exited 0. Both seed_baseline() and worker.py's
  make_session_factory() now use the same absolute path.

- Crashed workers' stderr was captured then discarded, and main() always
  exited 0 -- an all-crashed sweep was indistinguishable from a real
  (uneventful) measurement to anything checking the exit code or
  --out. run_workers() now prints each crash immediately and tags it with
  the real worker_id (previously always None); summarize() exposes
  crashed_worker_errors alongside the existing crashed_workers count;
  main() exits 1 via the new summary_indicates_failure() whenever any
  sweep crashed or fell short of expected_total_ops.

- idx_users_oauth_identity was hardcoded separately in the ORM Index and
  in _is_oauth_identity_violation's Postgres branch, with no test to
  catch drift, and that branch had zero non-skipped coverage (its only
  guard needs a live Postgres CI never configures). Exported
  OAUTH_IDENTITY_INDEX_NAME from user/model.py as the shared source of
  truth (migrations intentionally keep their own frozen literal, matching
  every other revision in that package) and added stub-exception unit
  tests pinning both the asyncpg constraint_name path and the sqlite
  message-substring path, positive and negative.

Verified: 107 passed locally (auth + bench-unit suites), ruff clean, and
two real reproductions -- invoking run_concurrency_bench.py from a
scratch directory outside backend/ (the reviewer's exact repro) now
completes 8/8 ops with crashed_workers: 0 instead of crashing, and the
new crashed_worker_errors/exit-code logic is exercised directly by the
new unit tests against the real summarize()/summary_indicates_failure().

* fix(auth): attribute create_user IntegrityErrors to the right constraint

Two coupled review findings on the classification helpers:

P3 (fall-through) -- after ruling out the OAuth-identity index, create_user
raised "Email already registered: {email}" for every remaining
IntegrityError, including the duplicate-primary-key case the new
regression test exercises, whose address is not registered. Added
_is_email_violation() so the email message is used only for an actual
users.email collision that raced past the pre-check; anything else (in
practice a duplicate id) now raises a neutral
"User already exists (constraint: <name>)".

P2 (unreachable asyncpg branch) -- exc.orig is not the asyncpg error.
SQLAlchemy's asyncpg dialect re-raises its own DBAPI IntegrityError
(pgcode/sqlstate only) 'from' the real asyncpg error, so constraint_name
lives on exc.orig.__cause__. getattr(exc.orig, "constraint_name", None)
was always None on Postgres; the helpers only worked there by accident,
matching asyncpg's DETAIL line in the message fallback. Added
_driver_constraint_name() which walks orig then orig.__cause__, and the
stub tests now model that real shape (orig wrapper + __cause__) instead of
a constraint_name that no driver puts on orig directly.

Tests: 76 passed. New coverage for the email-race path, both new helpers
on each backend, the neutral fallback message, and the cause-chain walk.

* fix(bench): match app SQLite PRAGMAs in workers; fail a sweep on any op error

Two review follow-ups:

- worker.py opened its SQLite engine with only connect_args timeout=30.
  synchronous and foreign_keys are per-connection PRAGMAs, so workers ran
  at SQLite's synchronous=FULL / foreign_keys=OFF while a real Gateway
  worker runs synchronous=NORMAL (persistence/engine.py::_enable_sqlite_wal)
  -- an extra fsync per commit on the measured 30%-write path, overstating
  SQLite's cost in the direction that flatters the "use Postgres"
  conclusion. Added a connect listener applying the same four PRAGMAs, with
  a test asserting synchronous/foreign_keys/journal_mode on a real worker
  connection.

- summary_indicates_failure() only looked at crashes and the completed vs
  expected op counts, so a sweep where every op completed but raised
  (e.g. writes hitting OperationalError) passed as a clean measurement:
  completed_ops == expected, 0 crashes. Added an "errors > 0" clause; the
  error breakdown stays in the JSON, only the exit code changes. Test added.

test_bench_concurrency.py + test_bench_worker.py green (20), plus a real
2-worker sqlite smoke run (6/6 ops, 0 errors, exit 0).

* fix(auth): match the real email index name; only claim "exists" for uniqueness

Review follow-ups on the classification helpers:

- email is mapped_column(unique=True, index=True), which SQLAlchemy and
  0001_baseline realise as a single UNIQUE INDEX (ix_users_email), not a
  named UNIQUE constraint. _is_email_violation compared the driver
  constraint name against "users_email_key", which Postgres never emits,
  so that arm was dead on Postgres (SQLite matched via the message). Fixed
  to ix_users_email.

- the residual IntegrityError fallback raised "User already exists" for
  every remaining IntegrityError -- a NOT NULL / CHECK / foreign-key
  violation is not a "user already exists" condition and is not part of
  create_user's ValueError contract. Added _is_uniqueness_violation
  (sqlstate 23505, or the SQLite "UNIQUE/PRIMARY KEY constraint failed"
  message); only that raises the "already exists" ValueError, everything
  else propagates unchanged.

- documented scripts/benchmark/concurrency/ in backend/AGENTS.md alongside
  the other benchmark family.

Tests: 78 auth + 20 bench-unit pass, ruff clean. New coverage for
_is_uniqueness_violation on both backends and for a non-uniqueness
IntegrityError propagating out of create_user.

* fix(bench): don't pre-close worker stdin (breaks communicate); require --pg-url for postgres

* fix(bench): ruff format; time throughput on the op phase, not teardown

- lint-backend: ruff format the files touched in this PR.
- Throughput window (P2): the orchestrator sampled its wall clock after
  every worker's communicate() returned, so it also covered each worker's
  engine.dispose(), result serialization and stdout transfer. Each worker
  now times just its operation phase (GO -> last op) and reports
  ops_elapsed_s; summarize() uses max(ops_elapsed_s) over the workers -- all
  released by the same GO -- as the throughput window (ops_window_s).
- Exercise migration 0018 (P2): test_user_oauth_partial_index.py goes
  through bootstrap create_all(), which builds the partial index from ORM
  metadata and never runs 0018.upgrade(). New Postgres-gated
  test_migration_0018_oauth_identity_pg_partial.py alembic-upgrades to 0017
  (full index), then 0018 (asserts the predicate appears), then downgrades
  (asserts the full index is restored) and re-upgrades.

* docs(middlewares): tighten SandboxAudit and Clarification entries in AGENTS.md

PR #5134 grew agents/middlewares/AGENTS.md ~1.8 KB, pushing the effective
AGENTS.md chain for that directory over the 96 KiB hard limit once this
branch also documents scripts/benchmark/concurrency/ in backend/AGENTS.md.
Condense the two longest middleware entries (SandboxAuditMiddleware,
ClarificationMiddleware) without dropping any identifier, example, issue
reference, ordering constraint, or documented gap; chain back to ~96.8 KiB.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-03 08:17:26 +08:00

331 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Real concurrency benchmark: N SEPARATE OS processes (subprocess.Popen,
not asyncio.gather, not threading) hitting the SAME users table at the same
time, comparing SQLite vs Postgres at 2/4/8/16 workers.
This tests exactly the scenario DeerFlow's own docs describe
(CONFIGURATION.md line 325): "Multi-worker deployments (GATEWAY_WORKERS > 1)
must use the Postgres database backend... SQLite silently ignores row-level
locks" -- multiple Gateway PROCESSES, each with its own connection, not
multiple async tasks inside ONE process (which a prior single-process
benchmark already showed has no problem).
Usage:
uv run python scripts/benchmark/concurrency/run_concurrency_bench.py \
--backend sqlite --workers 2,4,8,16 --ops-per-worker 50 --read-ratio 0.7
uv run python scripts/benchmark/concurrency/run_concurrency_bench.py \
--backend postgres --workers 2,4,8,16 --ops-per-worker 50 --read-ratio 0.7 \
--pg-url postgresql+asyncpg://deerflow_test:deerflow_test_pw@localhost/deerflow_test
"""
from __future__ import annotations
import argparse
import asyncio
import json
import shutil
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
# scripts/benchmark/concurrency/run_concurrency_bench.py -> backend/ is 3
# levels up (concurrency -> benchmark -> scripts -> backend). Derived from
# this file's own location, not hard-coded, so the documented
# `uv run python scripts/benchmark/concurrency/run_concurrency_bench.py`
# command works from any checkout, not just one at a specific fixed path.
BACKEND_DIR = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(BACKEND_DIR))
# checkpoint_bench_common.py is a sibling script folder, not a package (see
# its own docstring) -- same sys.path-insert-then-import pattern
# bench_channels.py/bench_production.py already use for it, reused here so
# percentile() has one correct implementation instead of two.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "checkpoint"))
from checkpoint_bench_common import percentile # noqa: E402
WORKER_SCRIPT = Path(__file__).parent / "worker.py"
# One absolute path, shared by the seeder (this file) and every worker
# process (worker.py's make_session_factory). DatabaseConfig.sqlite_dir
# resolves relative strings against the CALLER's CWD, not this file's
# location -- passing the literal ".deer-flow/bench_data" meant the
# orchestrator (running from wherever it was invoked) and the workers
# (spawned with cwd=BACKEND_DIR) could silently resolve to two different
# directories whenever this script is invoked from outside backend/,
# leaving workers pointed at a DB the seeder never created (or already
# removed).
SQLITE_BENCH_DIR = str(BACKEND_DIR / ".deer-flow" / "bench_data")
# The orchestrator itself is already running under the correct interpreter
# (`uv run python ...`, per this file's own usage docstring above) -- reuse
# it for workers instead of a second hard-coded venv path that silently
# assumes deer-flow is checked out at /opt/deer-flow.
PYTHON = [sys.executable]
async def seed_baseline(backend: str, pg_url: str, pg_schema: str, n_users: int = 100) -> list[str]:
"""Populate a known baseline BEFORE the concurrent run starts -- worker
reads target these known emails (not ones the workers themselves are
creating), so read and write paths don't depend on each other within
the same run."""
from app.gateway.auth.models import User
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from deerflow.config.database_config import DatabaseConfig
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
if backend == "sqlite":
sqlite_dir = Path(SQLITE_BENCH_DIR)
if sqlite_dir.exists():
shutil.rmtree(sqlite_dir)
cfg = DatabaseConfig(backend="sqlite", sqlite_dir=SQLITE_BENCH_DIR)
else:
# pg_schema is a unique, disposable schema for this benchmark run
# (see main()) -- never "public" or any schema a real deployment
# might already be using. init_engine_from_config creates it
# automatically and pins search_path to it, so every statement
# below (including the DELETE re-seed on repeat worker-count
# sweeps) is scoped to this run's own throwaway namespace.
cfg = DatabaseConfig(backend="postgres", postgres_url=pg_url, postgres_schema=pg_schema)
await init_engine_from_config(cfg)
sf = get_session_factory()
repo = SQLiteUserRepository(sf)
if backend == "postgres":
# clean prior worker-count sweep's rows so they don't accumulate
# across iterations WITHIN this run -- safe here specifically
# because it's scoped (via search_path) to this run's own isolated
# schema, never a shared/production one.
from sqlalchemy import text
from deerflow.persistence.engine import get_engine
engine = get_engine()
async with engine.begin() as conn:
await conn.execute(text("DELETE FROM users"))
emails = []
for i in range(n_users):
email = f"baseline_{i}@conc-bench-teste.com"
u = User(id=uuid4(), email=email, password_hash="h", system_role="user", created_at=datetime.now(UTC), oauth_provider=None, oauth_id=None, needs_setup=False, token_version=0)
await repo.create_user(u)
emails.append(email)
await close_engine()
return emails
async def drop_isolated_schema(pg_url: str, pg_schema: str) -> None:
"""Drop this run's disposable Postgres schema (and everything in it) once
every worker-count sweep has finished. Only ever targets the unique
per-run schema main() generated -- never "public" or a caller-supplied
name, so there's nothing here that can reach into a real deployment's
namespace even if --pg-url points at one."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from deerflow.config.database_config import DatabaseConfig
cfg = DatabaseConfig(backend="postgres", postgres_url=pg_url, postgres_schema=pg_schema)
engine = create_async_engine(cfg.app_sqlalchemy_url)
try:
async with engine.begin() as conn:
await conn.execute(text(f'DROP SCHEMA IF EXISTS "{pg_schema}" CASCADE'))
finally:
await engine.dispose()
def run_workers(backend: str, n_workers: int, ops_per_worker: int, read_ratio: float, known_emails: list[str], pg_url: str, pg_schema: str):
emails_arg = ",".join(known_emails)
procs = [] # list of (worker_id, Popen) -- worker_id kept alongside so a
# crashed worker's diagnostics can be attributed to the right id below,
# instead of the placeholder "worker_id": None every crash used to get.
for wid in range(n_workers):
cmd = PYTHON + [str(WORKER_SCRIPT), backend, str(wid), str(ops_per_worker), str(read_ratio), emails_arg, pg_url, pg_schema]
p = subprocess.Popen(cmd, cwd=str(BACKEND_DIR), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
procs.append((wid, p))
# Start barrier: wait for every worker to print READY (connection
# established, right before its own timed loop -- see worker.py) before
# releasing any of them. Otherwise early workers run ahead of ones still
# starting -- not a controlled N-worker contention measurement. Each
# worker times its own operation phase (from GO to its last op) and the
# throughput window is the max of those (see summarize); the staggered
# Python-startup + connection cost stays out of it (conn_time_s measures
# that per-worker). A worker that crashes before printing READY closes
# its stdout, so readline() returns "" rather than hanging; the loop
# below reports that same worker as crashed via its nonzero returncode.
for _wid, p in procs:
p.stdout.readline()
for _wid, p in procs:
try:
p.stdin.write("GO\n")
p.stdin.flush()
except (BrokenPipeError, ValueError):
pass # worker already exited -- nothing to release
# Do NOT close p.stdin here: p.communicate() below flushes and closes
# it, and a second close turns that flush into an uncaught ValueError
# ("I/O operation on closed file"). The worker reads exactly one line
# (the GO above), so the flush is all the release it needs.
worker_outputs = []
for wid, p in procs:
stdout, stderr = p.communicate()
if p.returncode != 0:
# Surfaced immediately (not just embedded in the summary JSON)
# so a crash is visible in real time, not just discoverable by
# someone reading crashed_workers back out of the final report.
print(f"--- worker {wid} crashed (exit {p.returncode}): {stderr[-2000:]} ---", file=sys.stderr)
worker_outputs.append({"worker_id": wid, "crashed": True, "stderr": stderr[-2000:], "results": []})
continue
try:
worker_outputs.append(json.loads(stdout.strip().splitlines()[-1]))
except Exception as e:
msg = f"parse error: {e}; stdout={stdout[-500:]}; stderr={stderr[-500:]}"
print(f"--- worker {wid} produced unparseable output: {msg} ---", file=sys.stderr)
worker_outputs.append({"worker_id": wid, "crashed": True, "stderr": msg, "results": []})
return worker_outputs
def summarize(worker_outputs, n_workers: int, ops_per_worker: int) -> dict:
all_results = []
crashed = 0
crashed_worker_errors = []
ops_windows = []
for w in worker_outputs:
if w.get("crashed"):
crashed += 1
crashed_worker_errors.append({"worker_id": w.get("worker_id"), "stderr": w.get("stderr")})
continue
all_results.extend(w["results"])
if "ops_elapsed_s" in w:
ops_windows.append(w["ops_elapsed_s"])
# All non-crashed workers are released by the same GO, so the slowest
# worker's operation-phase elapsed is the window during which every
# worker was contending. Using it (not the orchestrator's post-
# communicate() wall clock) keeps per-worker engine.dispose() +
# result serialization + stdout transfer out of the throughput figure.
ops_window = max(ops_windows) if ops_windows else 0.0
total_ops = len(all_results)
errors = [r for r in all_results if not r["ok"]]
latencies = sorted(r["latency_s"] for r in all_results)
err_types = {}
for r in errors:
key = r["err"].split(":")[0] if r["err"] else "unknown"
err_types[key] = err_types.get(key, 0) + 1
def pct(p):
# p is a 0..1 fraction here (0.50/0.95/0.99); checkpoint_bench_common's
# percentile() takes 0..100 and already does correct nearest-rank
# interpolation ((n-1)*percentile/100, not int(n*p) used directly as
# an index -- that off-by-one made p95/p99 both resolve to the max
# for any sample of 20 or fewer values, and for the documented
# 100-sample default).
if not latencies:
return None
return percentile(latencies, p * 100)
return {
"n_workers": n_workers,
"ops_per_worker": ops_per_worker,
"expected_total_ops": n_workers * ops_per_worker,
"completed_ops": total_ops,
"crashed_workers": crashed,
"crashed_worker_errors": crashed_worker_errors,
"errors": len(errors),
"error_types": err_types,
"ops_window_s": round(ops_window, 3),
"throughput_ops_per_s": round(total_ops / ops_window, 2) if ops_window > 0 else None,
"latency_p50_ms": round(pct(0.50) * 1000, 3) if pct(0.50) is not None else None,
"latency_p95_ms": round(pct(0.95) * 1000, 3) if pct(0.95) is not None else None,
"latency_p99_ms": round(pct(0.99) * 1000, 3) if pct(0.99) is not None else None,
"latency_max_ms": round(latencies[-1] * 1000, 3) if latencies else None,
}
def summary_indicates_failure(summary: dict) -> bool:
"""True if this worker-count sweep's summary represents a broken run,
not a real measurement:
- a crashed worker, or fewer completed results than expected without a
crash -- an all-crashed sweep still produces a well-formed-looking
summary (crashed_workers: N, completed_ops: 0,
throughput_ops_per_s: 0.0);
- any failed op (errors > 0). This benchmark's conclusions rest on
"0 errors on both backends"; a sweep where every op completed but
raised (e.g. writes hitting OperationalError) has completed_ops ==
expected and 0 crashes, so it would otherwise pass as a clean
measurement. The error breakdown stays in the printed JSON either
way -- this only stops the exit code from calling it clean."""
return summary["crashed_workers"] > 0 or summary["completed_ops"] != summary["expected_total_ops"] or summary.get("errors", 0) > 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--backend", required=True, choices=["sqlite", "postgres"])
ap.add_argument("--workers", required=True, help="comma-separated list, e.g. 2,4,8,16")
ap.add_argument("--ops-per-worker", type=int, default=50)
ap.add_argument("--read-ratio", type=float, default=0.7)
ap.add_argument("--pg-url", default="")
ap.add_argument("--baseline-users", type=int, default=100)
ap.add_argument("--out", default=None)
args = ap.parse_args()
if args.backend == "postgres" and not args.pg_url:
ap.error("--pg-url is required for --backend postgres")
worker_counts = [int(x) for x in args.workers.split(",")]
all_summaries = []
# A sweep where every worker crashed still produces a well-formed
# summary (crashed_workers, completed_ops: 0, throughput_ops_per_s:
# 0.0) -- exiting 0 for that made a broken run indistinguishable from
# a real (if uneventful) measurement to anything checking the exit
# code, and let a garbage --out file sit next to a real one the same
# way. Tracked across the whole worker-count sweep, not just the last
# iteration, so one bad n_workers value in the middle doesn't get
# masked by later ones succeeding.
had_failure = False
# One disposable schema for this ENTIRE invocation (reused across the
# worker-count sweep below, dropped once at the very end) -- --pg-url
# accepts an arbitrary database URL, so this must never touch "public"
# or any namespace a real deployment might be using. Unset for sqlite;
# seed_baseline/run_workers/worker.py ignore it on that backend.
pg_schema = f"bench_{uuid4().hex[:12]}" if args.backend == "postgres" else ""
try:
for n_workers in worker_counts:
print(f"--- seeding baseline ({args.backend}, {args.baseline_users} users{f', schema={pg_schema}' if pg_schema else ''}) ---", file=sys.stderr)
emails = asyncio.run(seed_baseline(args.backend, args.pg_url, pg_schema, args.baseline_users))
print(f"--- running {n_workers} workers ({args.backend}, {args.ops_per_worker} ops/worker) ---", file=sys.stderr)
worker_outputs = run_workers(args.backend, n_workers, args.ops_per_worker, args.read_ratio, emails, args.pg_url, pg_schema)
summary = summarize(worker_outputs, n_workers, args.ops_per_worker)
summary["backend"] = args.backend
all_summaries.append(summary)
print(json.dumps(summary, indent=2), file=sys.stderr)
if summary_indicates_failure(summary):
had_failure = True
finally:
if pg_schema:
print(f"--- dropping isolated schema {pg_schema} ---", file=sys.stderr)
asyncio.run(drop_isolated_schema(args.pg_url, pg_schema))
result = {"backend": args.backend, "read_ratio": args.read_ratio, "runs": all_summaries}
output = json.dumps(result, indent=2)
if args.out:
Path(args.out).write_text(output)
# Printed unconditionally, failure or not -- a broken run's diagnostics
# (crashed_worker_errors, the mismatched op counts) are exactly what's
# needed to debug it, so the JSON goes out before the exit code below
# can make anything piping/discarding stdout on a nonzero exit lose it.
print(output)
if had_failure:
sys.exit(1)
if __name__ == "__main__":
main()