Queries under writes (Beta)
Most benchmarks measure a database that has stopped. This one measures one that hasn't: a 1M-row linked accounting database being written to the whole time the queries run.
On that database an inference predict โ the account of a posting, inferred
from seven known fields including free text โ costs
268 ms at p50 while the writer
is paused, and 853 ms at
p50 while a writer commits a new posting every
250 ms. That is
3.2ร the idle
figure โ and the second number, not the first, is what decides whether a
database stays usable while it ingests.
And at 3 points after that write load, the answer the live process served was byte-identical to what a cold engine computes from the same data. That check is the point of the page as much as the latency is.
The measurement
One run, one build, one machine. The two rows that matter are measured together, in alternating 5-second slices across 12 rounds, with the reader threads never stopping โ only the writer pauses and resumes. So both figures see the same machine over the same minutes, and each predict is filed by whichever slice it started in.
Each p50 below is the median round, not the median of all queries pooled: rounds yield unequal sample counts, so pooling silently weights the faster ones.
| Phase | p50 | p90 | p99 | max |
|---|---|---|---|---|
| Writer paused | 268 ms | 561 ms | 1065 ms | 1630 ms |
| Immediately after a 1,000-row commit | 1008 ms | โ | โ | 2300 ms |
| Under continuous writes | 853 ms | 1061 ms | 2081 ms | 2081 ms |
The load the third row was measured under:
| Property | Value |
|---|---|
| Rows in the database | 1,000,000 |
| Run length | 180 s |
| Measured rounds | 12 (after discarding 3 warm-up rounds) |
| Reader threads | 2 |
| Writer commits | one row every 250 ms |
| Writes committed | 309 |
| Predicts served | 609 |
| Predict throughput | 3.4 /s |
| Commit latency, 1000 rows (p50) | 224 ms |
| JVM heap after the run | 1,005 MB |
Provenance. dataset accounting (1,000,000
linked postings) ยท v2 engine (rep2) ยท predict of account from seven known
fields including free text ยท from booktest WritesUnderLoadBench
writesUnderLoad, one execution producing every cell above ยท single dev
machine, hardware unspecified ยท the run certifies its own stability: the
middle half of its per-round medians spanned
1.28ร paused and
1.05ร under
writes, inside the
1.35ร the
protocol allows, or nothing would have been published. Measured at a
one-minute load average of
9.8 with
18,153 MB
free. Build-time tokens, not hand-typed.
Correct while it writes
Latency under writes is easy to buy with a stale cache. So once the measured rounds are done and the database has absorbed every write of the run, the same run checks 3 times that it hasn't:
- the writer pauses;
- the live process answers a predict, using whatever it has cached after thousands of commits;
- a copy of the store is opened in a brand-new engine with empty caches, and answers the same query from the data alone;
- the two responses must match byte for byte โ every probability, to the last digit.
These checks deliberately run outside the measured window. Each one copies the whole database and starts a second engine, and when they were interleaved with the timing rounds they visibly perturbed the latency they exist to validate.
Result: 3 of 3 checkpoints identical, with 0 failed queries and 0 failed writes.
This is a strict check on purpose. A cache that outlived the data it described, or a per-segment statistic reused after its segment was rewritten, would move a probability in the last decimal and pass every eyeball test. It fails this one.
It is a sample, not a proof: three answers out of hundreds served. What it rules out is a systematic staleness, not every conceivable one.
Why a write costs anything at all
A commit does not mutate the database in place โ it mints a new state. The new state shares the old one's segments, except the tail, which is rewritten to absorb the new rows. That is what keeps writes cheap and readers undisturbed: a query already in flight finishes against the state it started on.
The cost is on the next query. Anything the engine holds per state is new-and-empty on the state a commit just created, and gets recomputed. The engine's job is therefore to key as much work as possible to something that survives a write โ the segment's own content โ rather than to the state:
- Segment columns decode once. A column's decoded form belongs to the segment blob, which is the same object before and after the commit, so an untouched segment is not re-decoded.
- Statistics are computed per segment and composed. The class counts behind a prediction, and the rows a linked attribute projects onto the main table, are built for one segment at a time and memoised under that segment's content key. After a commit, only the rewritten tail is rebuilt; every other segment's work is reused as it is.
- Merging segments does not re-key their samples. Composing per-segment evidence into a whole-table answer resolves each contributing position through a memoised ordinal map instead of rebuilding the mapping for the new state.
- What is genuinely per-state is bounded. The caches that must be keyed to a state retain a small fixed number of recent generations, so continuous writing cannot grow them without limit โ which is what keeps the heap figure above flat across thousands of commits rather than climbing.
The second row of the table is what remains after all that: the first predict on a fresh state still re-pays the work that genuinely changed. The third row is the steady behaviour, where new commits keep arriving and readers keep hitting mostly-reusable segments.
What this does not say
One shape of query. The measured query is a seven-field inference predict โ deliberately the expensive shape. A filter or full-text query is a different cost model, measured on Query, predict & write speed.
One scale, one node. 1M rows on a single machine. Nothing here says how the curve bends at 10M, and nothing here is a distributed result.
One write pattern. A single writer appending small commits, which is the common shape for a system ingesting a live feed. Bulk backfill is a different regime and has its own number, ingest throughput. Deletes, schema changes and explicit optimize are not in this run.
One build. The figures come from the engine as it stands on master, which
may be ahead of the released build you are running. The exact commit that
produced them is recorded in the metrics file this page reads
(core/docs/metrics/writes-under-load.json), so a number here can always be
traced to the code that produced it.
Latency moves with the box. These are wall-clock numbers from one shared dev machine, and the conditions are published with them. Rather than assume a quiet machine, the bench checks whether its own rounds agreed and publishes nothing when they did not โ so a figure here is one the run demonstrated it could reproduce minute to minute, not one measurement that happened to land well. The ratio between the rows, and the correctness result, are the durable parts; the absolute milliseconds are not portable to your hardware.
A tail is a tail. The p90 and p99 columns pool individual queries rather than rounds, because a tail is a property of single requests. On a machine with neighbours, part of that tail is theirs.
Related
- Query, predict & write speed โ query latency against Elasticsearch, prediction latency, and ingest throughput
- Memory & disk footprint โ what a database of this size costs to hold
- How big can my database get? โ the accuracy and latency ladder from 1k to 10M rows
- Warming strategies โ controlling first-query cost