{{ lede }}
Read the story behind these numbers → MySQL, a database per microservice → Benchmarks by shape, from one worker to the run store →0.43.0, one gunicorn worker as in the Docker image, a saved query by primary key on 10 million rows, 32 callers at once.
| Call | 0.42 | 0.43 | 0.43, cached |
|---|---|---|---|
| Lookup, plain key | 786 (751-829) | 852 (827-892) | 1,012 (991-1,043) |
| Lookup, masking and table allow-list | 738 (713-771) | 809 (803-856) | 964 (956-992) |
| 100-row page with a join | 682 (661-694) | 725 (710-758) | 835 (833-873) |
| Write | 793 (764-836) | 850 (832-887) | 1,002 (999-1,046) |
PostgreSQL answers this lookup in 0.03 ms. Everything else is the gateway.
Per-user queries joining across six microservice databases on one MySQL server, one gunicorn worker, 32 callers at once.
| Query (databases it joins) | MySQL directly | 0.42 | 0.43 | 0.43, metadata cache |
|---|---|---|---|---|
| One customer by id (crm) | 75,420 | 647 | 743 | 875 |
| Order-Status (crm, orders) | 25,063 | 573 | 662 | 727 |
| Stock-Level (org, orders, inventory) | 5,343 | 630 | 751 | 833 |
One MySQL server, a database per microservice, one read-only connection. Joins across databases cost no more than joins within one.
Closed in 0.43. Table allow-lists on MySQL match database.table: a key granted crm's customer can't read billing.customer. When upgrading, a bare grant now means the connection's own database; keys that read another database's tables need those grants written out. 0.43's breaking changes →
Latency under load, scale-out and where the time goes on MySQL: see the full MySQL page →
p50 stays flat right up to capacity. p99 holds at about 4-5 ms to 75% load, then climbs: about 16 ms at 90%. At capacity a queue builds.
At 100%, a queue builds: p99 reaches 108 ms (141 ms cached), off this scale.
Measured open-loop, at fixed request rates, with latency corrected for coordinated omission - so saturation isn't hidden by slow answers delaying the next request.
Measured on 0.42; 0.43 didn't change how it scales. On MySQL, 0.43's scale-out on a shared PostgreSQL store is on the MySQL page.
QUERYAPIGATE_THREADS.Counted by timing calls at increasing network delay. With the cache, a call makes one round trip: the query, sent as one message that also sets its access mode and time limit. Across a 0.5 ms network, a call took 6.58 ms in 0.42, 4.39 ms in 0.43, and 1.60 ms with the cache.
A small Rust process in front of the gateway serves saved-query reads itself, from plans the Python server computes, and forwards everything else unchanged. Every security decision stays in Python; every run it serves is still recorded there.
Experimental: in the image and the Helm chart since 0.47.0, off unless started (ADR 0030). Measured, and decided go (ADR 0028).
/metrics, as if it had served them.| Python + cache, 8 workers | Data plane + cache, 2 Python workers behind it | |
|---|---|---|
| MySQL customer lookup | 2,976 at p99 93 ms | 23,619 at p99 3.8 ms |
| MySQL Order-Status, governed key | 2,252 at p99 116 ms | 13,863 at p99 6.4 ms |
| Gateway cores used | 7.3 | 7.3 and 5.8 |
Every run recorded in both. Python's eight workers can't use their cores: 405 calls a second per core, against 769 for one worker on its own.
QUERYAPIGATE_EVENTS_RETENTION_HOURS.An optional ClickHouse store for run history, the event log and usage. The data plane writes every run it serves straight to it, in batches, with an insert-only user. Keys, queries and the audit log stay where they are.
| Read | p95 | ADR 0029's bar |
|---|---|---|
GET /api/v1/history, the newest 100 | 12.5 ms | 100 ms |
| One key's newest 100 | 73.6 ms | 100 ms |
| The Console's versions and run counts | 37.3 ms | 100 ms |
| A 24-hour usage report, exact | 66.7 ms | 500 ms |
| One key's replay of the whole event log 34,893 events | 6.2 s | 10 s |
Usage reports count every run, whatever the history sample rate keeps: whole minutes from per-minute rollups, the edges from the event log.
events, up to 100,000 runs. ClickHouse fills history and the rollups from it through materialized views, and drops whole partitions as they age out.queryapigate events delivers an event once it's a second old, so a client resuming from any id it was given misses nothing. A rare late insert is counted in /metrics.queryapigate events process delivers about 6,500-10,000 events a second per stream: run several behind a load balancer for more.A worker runs out of CPU or of threads to wait with, whichever comes first. Put in your own load and this works out which, and how many workers to run.
{{ hint }}
{{ caveat }}
The rule: a worker serves at most 670 fast calls a second on CPU (780 with the metadata cache), which is 75% of measured capacity. Waiting allows threads ÷ (query time + round trips × round-trip time), with 4 round trips a call in 0.43, or 1 with the cache. Take the smaller, divide your load by it, and add one worker you can afford to lose.
With the Rust data plane in front, size the data plane's CPU from its section above, and keep at least two Python workers behind it.
/health check before and after every block shows whether the machine changed speed. One session, 2026-10-26 22:00 to 2026-10-27 00:28; the /health check stayed between 1,978 and 2,323 requests a second.python:3.12-slim, links against a shared libpython and is about 30% slower per CPU for every call Python handles (BACKLOG #104). The image's Rust data plane serves at 0.95-0.99× the from-source build. So data-plane rows carry over to the image, and Python-only rows overstate what the container does today. Which build each number came from →It starts its own PostgreSQL, pins each part to its own cores, compares two releases side by side, and cleans up after itself. About 2½ hours.
$ pip install -e ".[server,postgres]" py-spy $ python benchmarks/oltp.py --out /tmp/qag-oltp --base v0.42.0 setup $ python benchmarks/oltp.py --out /tmp/qag-oltp --base v0.42.0 env pgbench b1 b2 b3 b5 b7 b9 b10 b11 env $ python benchmarks/oltp_report.py /tmp/qag-oltp/results.jsonl