A small script that hits a curated set of company_dns endpoints - derived
from the live deployment’s own OpenAPI spec, not hardcoded guesses - across
ten well-known companies, both sequentially (clean per-call latency) and at
increasing concurrency levels (does the server actually serve requests in
parallel, or serialize them?).
While investigating why EDGAR/Wikipedia lookups were slow, we fixed two
concrete bugs (see lib/edgar.py/lib/wikipedia.py git history and
docs/plans/onprem-k8s-migration.md). This suite exists to measure what’s
left, and specifically to test the hypothesis that concurrency - not
per-call latency - is the more tractable lever now. See
docs/plans/performance-improvements.md for the full plan, including item
2 (company_dns.py now constructs a fresh handler per request and runs
blocking query methods via run_in_threadpool, instead of the old shared-
singleton pattern that ran everything inline on the event loop).
The concurrency experiment’s per-company batches (see
CONCURRENCY_ENDPOINT_KEYS) don’t just measure latency - they also assert
correctness: each concurrent response is checked to make sure it actually
belongs to the company that was queried, not a neighbor’s. This checks
against the company’s CIK, not the raw query string - EDGAR-touching
endpoints (merged_firmographics especially) legitimately replace the
query with the resolved formal filer name once matched (querying IBM
correctly returns INTERNATIONAL BUSINESS MACHINES CORP), so a literal
query-string match would false-positive on entirely correct responses. CIK
is the one identifier that’s actually stable across every endpoint’s
response format (edgar_ciks returns it unpadded, wikipedia_firmographics/
merged_firmographics return it zero-padded, but the unpadded digits are
always a substring of the padded form). This is a direct regression guard
against the specific bug item 2 fixed (a shared mutable .query attribute
on module-level singletons, which could previously have let two concurrent
requests to the same endpoint race and swap results). A failed check raises
immediately with the offending endpoint, concurrency level, and response
body, rather than silently reporting a fast but wrong
answer as a latency win.
# Full run against the live deployment (default)
python3 perf_tests/baseline.py
# Against a local/port-forwarded instance instead
python3 perf_tests/baseline.py --base-url http://localhost:8000
# Just see the call matrix, make no requests
python3 perf_tests/baseline.py --list
# Sequential baseline only, skip the concurrency experiment (faster, gentler on prod)
python3 perf_tests/baseline.py --skip-concurrency
# Widen/narrow the concurrency experiment
python3 perf_tests/baseline.py --concurrency 1 2 4 8 16 --repeat 3
Full results (every call: endpoint, company, URL, status, latency) are
written as JSON to perf_tests/results/<timestamp>.json by default, or
wherever --out points. Console output is a human-readable summary. Each
report also stamps git_commit (best-effort, from whatever checkout the
script is run from) and deployed_image (best-effort, shelled out to
kubectl -n company-dns get deployment company-dns -o jsonpath=... when
kubectl is available) — so a report can be matched back to exactly what
code was live when it was taken, without relying on memory or reconstructing
it from PR merge times later.
python3 perf_tests/compare.py perf_tests/results/before.json perf_tests/results/after.json
# Write the diff out as JSON too, and/or tune the flagging thresholds
python3 perf_tests/compare.py before.json after.json --out diff.json
python3 perf_tests/compare.py before.json after.json --regression-pct 15 --improvement-pct 25
Prints both reports’ provenance, then a per-endpoint sequential latency
delta (median/p95, before vs. after) and a per-(endpoint, concurrency
level) concurrency delta (wall time, speedup-vs-serial, before vs. after),
flagging anything that got meaningfully worse as REGRESSION and
meaningfully better as IMPROVED (thresholds configurable, default 10%
worse / 20% better) so the interesting rows don’t require reading every
line.
Caveat, worth internalizing before trusting a flag: edgar_ciks,
edgar_detail, edgar_firmographics_by_cik, health, and sic_lookup are
either local-only or a single direct SEC call — clean signal for whatever
code change you’re testing. wikipedia_firmographics and
merged_firmographics carry real variance from Wikipedia/Wikidata’s own
response times and upstream caching that has nothing to do with
company_dns code — a flag on those two when your change didn’t touch the
Wikipedia path at all is noise, not signal. compare.py’s docstring repeats
this; it’s easy to forget mid-investigation.
Requires only requests (already a company_dns dependency) - no new
project dependencies.
| endpoint key | category | what it measures |
|---|---|---|
health |
control | liveness endpoint, no DB/network calls |
sic_lookup |
control | SQLite-only SIC search, no external calls |
edgar_ciks |
control (see caveat below) | SQLite-only fuzzy CIK search by name |
edgar_detail |
external-io | fuzzy EDGAR search + one live SEC call per unique matched company |
edgar_firmographics_by_cik |
external-io | one direct live SEC call by CIK |
wikipedia_firmographics |
external-io | Wikipedia/Wikidata lookup via wptools |
merged_firmographics |
external-io | the heaviest real-world path: Wikipedia + conditionally EDGAR + ArcGIS geocoding |
The endpoint list is verified against {base_url}/openapi.json at startup;
the suite refuses to run if any of these paths have disappeared or been
renamed on the deployment, rather than silently reporting 404s as fast
responses.
https://company-dns.mediumroast.io)Full raw data: perf_tests/results/baseline-20260926.json.
| endpoint | n | ok% | median | p95 | max |
|---|---|---|---|---|---|
health |
1 | 100% | 53ms | 53ms | 53ms |
sic_lookup |
1 | 100% | 56ms | 56ms | 56ms |
edgar_ciks |
10 | 100% | 2738ms | 3301ms | 3469ms |
edgar_detail |
10 | 100% | 2804ms | 4181ms | 4676ms |
edgar_firmographics_by_cik |
10 | 100% | 178ms | 369ms | 512ms |
wikipedia_firmographics |
10 | 100% | 3851ms | 5652ms | 6120ms (one earlier run: a 30s timeout) |
merged_firmographics |
10 | 100% | 6095ms | 7840ms | 8053ms |
edgar_ciks - a SQLite-only
endpoint with no external network call at all - has a ~2.7s median
latency, in the same ballpark as edgar_detail (which does make a live
SEC call). This means a meaningful chunk of “EDGAR is slow” isn’t
external-API latency at all - it’s almost certainly the companies
table’s WHERE name LIKE '%...%' query doing a full table scan with no
index (lib/prepare_db.py’s CREATE TABLE companies (...) has none).
This is independent of, and likely a bigger lever than, the caching
question we were already weighing - an index (or FTS) on companies is
a small, local, no-architecture-change fix.edgar_firmographics_by_cik is fast (178ms median) - confirms the
earlier fix (calling get_firmographics once per unique company instead
of once per filing row) is working as intended: a single direct EDGAR
call really is quick.wptools still makes several real external calls per lookup (parse,
query, wikidata) and Wikipedia/Wikidata’s own response times dominate.
This is the part that likely does need caching to improve further, as
originally guessed.edgar_ciks at concurrency 8 showed ~2.1x
speedup vs. fully-serial, health showed ~4.6x. Since the deployment
currently runs 2 replica pods, this is consistent with each pod’s single
uvicorn process serializing its own in-flight blocking I/O calls (no
run_in_threadpool), with the ~2x baseline coming from having 2 pods to
spread load across. This supports the original hypothesis directly:
increasing replica count (or running multiple uvicorn workers per pod)
should increase real concurrent throughput roughly proportionally, since
the ceiling is pod/worker count, not the network.merged_firmographics concurrency numbers are confounded:
they came out suspiciously fast at concurrency 1 and 4 (140ms, 218ms),
almost certainly because the concurrency experiment ran immediately
after the sequential pass already fetched the exact same companies -
likely a warm cache somewhere upstream (Wikipedia’s own CDN, or the
ArcGIS geocoder), not anything in company_dns. Concurrency 8 (which
still hits mostly the same recently-fetched companies) jumped back up to
8s, so this isn’t a clean signal either way - don’t read anything into
the low-N merged_firmographics numbers. A follow-up run with
--repeat 3+ and enough of a gap between the sequential and concurrency
passes (or a larger/rotating company set) would give a cleaner read.wikipedia_firmographics call
(JPMorgan Chase, first run only - didn’t recur), and one 502 on
merged_firmographics (Meta Platforms, 13ms - a fast gateway-level
failure, not an app timeout). Both are in the JSON for the record.load.py: a closed-loop load generator (throughput and latency percentiles; --rps caps the rate).run_cluster.py: compares replica configurations on the cluster (docs/plans/v4-sizing-test.md).soak.py: a ramp-and-hold soak test for a remote tester, with periodic known-answer checks and per-window output (docs/soak-test.md).limit_probe.py: what one address gets through the per-IP limiter (burst, sustained rate, the trusted-Origin bypass). ceiling.py: ramps the load, from one or several machines, until the service buckles, merges the machines’ results and joins them with the pods’ side (docs/soak-scenarios.md, scenario 5).report_runs.py: pictures that explain the runs (pods coming and going under a steady load, the ceiling, the per-address limit) with the finding in each title, from the result files; unit-tested numbers (test_report_runs.py).origin_check.py: does a client-set Origin or Referer skip the per-address limit? A scripted yes or no (docs/origin-bypass-check.md).pod_watch.py: reads the pods from the cluster side while a soak runs (readiness, restarts, memory, CPU throttling), read-only. soak_analyze.py: merges several sites’ soak results and judges each marked event (docs/soak-scenarios.md).report_cluster.py: draws the sizing and soak figures of docs/performance-report.md from the committed result files.