mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-13 07:28:44 +00:00
* 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>
331 lines
16 KiB
Python
Executable File
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()
|