Skip to content

UPDATE Write Benchmark

ReferencePerformanceBenchmarks

The 10M-Row Query Benchmark never writes. This page covers the other half: what an UPDATE costs, measured across four table sizes, together with the correctness and metadata invariants that must hold after one.

The headline is a property of the current storage engine that anyone sizing a write workload needs to know before they design around it:

BuildAetheriusDB 0.1.105 — release profile, dedicated daemon on a fresh data directory
HostApple M4 (10 cores: 4 performance + 6 efficiency), 16 GB unified memory
OSmacOS 26.6, arm64
Clientaesql 0.1.105, native binary protocol, loopback, single client
Date2026-08-26
LoadIdle host, no concurrent queries
Harnessblack-box-testing/benchmarks/update_bench.py

The daemon is a dedicated one on a fresh data directory, not the installed service, so no co-tenant background work contends with the measurement.

Four questions the query suite cannot answer, because it only reads:

  1. How long does an UPDATE take — for one row, for a group, for the whole table?
  2. Are the updated values sustained — does reading them back agree with what was written?
  3. Does the table survive — row count, and the values of rows the predicate did not match?
  4. Does each cube’s header metadata stay in sync — does _sys_cube_residency still describe the table after a rewrite, with no cube leaked?

A four-column table is created and seeded from scratch at each size:

CREATE TABLE t (id BIGINT, grp BIGINT, v BIGINT, tag TEXT);
-- seeded with id = 1..N, grp = id % 100, v = id, tag = 'x<id>'

Then three statements run in order, each timed as a full client round trip, with every invariant re-checked between them:

-- U1 one row
UPDATE t SET v = v + 1 WHERE id = 1;
-- U2 one group, ~1% of the table
UPDATE t SET v = v + 1000 WHERE grp = 5;
-- U3 every row
UPDATE t SET v = v + 7;

Every invariant passed at every size — matched count, SUM(v) after each mutation, the updated value read back, an untouched neighbour unchanged, row count preserved, no cube leaked.

Table rowsU1 — 1 rowU2 — one groupU3 — all rowsCube chain
100,000112 ms115 ms (1,000)129 ms (100,000)10 → 1
500,000217 ms241 ms (5,000)258 ms (500,000)50 → 1
1,000,000349 ms368 ms (10,000)412 ms (1,000,000)100 → 1
5,000,0001,508 ms1,594 ms (50,000)2,719 ms (5,000,000)500 → 1

Against the previous published run (0.1.86, 2026-08-03)

Section titled “Against the previous published run (0.1.86, 2026-08-03)”

Same harness, same host, same four sizes. Every size is faster on 0.1.105, and the shape of the finding is unchanged.

Table rows0.1.86 U10.1.105 U10.1.86 U30.1.105 U3
100,000177 ms112 ms135 ms129 ms
500,000349 ms217 ms245 ms258 ms
1,000,000457 ms349 ms425 ms412 ms
5,000,0002,376 ms1,508 ms3,122 ms2,719 ms

The single-row case improved most — 37 % faster at 5 M rows — but it is still an O(table) rewrite, so the caution at the top of this page stands unchanged.

Read the table across, then down.

Across a row, the matched-row count varies by up to five orders of magnitude and the time barely moves. At 1,000,000 rows, matching one row costs 349 ms and matching all one million costs 412 ms — a 1,000,000× change in matched rows buys an 18 % change in time.

Down the table, holding the matched set fixed at a single row, U1 goes from 112 ms to 1,508 ms — 13× slower for a 50× larger table, while the work the statement was asked to do never changed.

That is the signature of a whole-table rewrite, and it is exactly what the code does. AcubeStorageAdapter::update_where is scan-and-rebuild: it materialises every row, builds a second complete row set, replaces the table’s rows wholesale, and then durably flushes the table. The matched-set size never enters into it.

The raw sweep has a confound worth removing, because it makes the result look stranger than it is. U1 runs first, against a table that still has its full cube chain (10, 50, 100 or 500 cubes), and the rewrite collapses that chain to one. U2 and U3 then run against an already-collapsed table. So U1 pays a one-time cost the other two do not, which is why it appears to be the most expensive statement at the smaller sizes.

Re-running on the surviving tables, with every statement starting from identical single-cube geometry (these figures are from the 0.1.86 run and were not re-measured on 0.1.105; the main table above was):

Table rows1 row matchedAll rows matched
1,000,000330 / 279 / 279 ms329 ms
5,000,0002,013 / 1,512 / 1,458 ms2,795 ms

At 1,000,000 rows the matched set varies by six orders of magnitude and the time does not move at all. That is the clean form of the claim, and it rules out any reading in which matched-row count is the dominant term.

The three effects separate cleanly:

Componentat 1,000,000 rowsat 5,000,000 rows
Whole-table rewrite floor~280 ms~1,460–1,510 ms
Cube-chain collapse (first UPDATE only)~180 ms~900 ms
Per-matched-row modification~50 ms for 1 M rows~1,300 ms for 5 M rows

The floor dominates at every size measured. The per-matched-row term is real but so small relative to the floor that it is invisible below roughly a million rows — which is why U3 is cheaper than U1 at 100 K–1 M (U1 is paying the collapse, U3 is not), and only at 5 M does modifying five million rows finally clear the floor and make U3 the most expensive statement.

So the precise statement is O(table) with a weak O(matched) term, not pure O(table).

Cube-chain collapse and what it costs you later

Section titled “Cube-chain collapse and what it costs you later”

Every UPDATE rebuilds the surviving rows into a single fresh cube. A table loaded as 500 cubes comes back as one.

The metadata stays fully coherent. Across all four sizes, _sys_cube_residency reported resident_bytes identical before and after all three rewrites (3,438,895 / 17,638,895 / 35,388,896 / 181,388,896) and cubes_spilled stayed at 0. Nothing leaked and nothing was lost — the metadata simply describes a coarser table.

The consequence is on the read side. Zonemap, chrono-stripe and PK-bloom skipping all work per cube. Once a table is a single cube it is a single skip unit, so range pruning cannot eliminate anything on it until it is recompacted:

Size write cost by the table, not by the statement. A single-row UPDATE against a large table is not a cheap operation, and batching a thousand single-row updates into a thousand statements costs a thousand full table rewrites. Where the workload allows it, prefer one UPDATE with a predicate matching all the affected rows — the cost is essentially identical to updating one of them.

Extrapolating to a real table: by row count alone, one UPDATE … WHERE order_item_id = ? on the 10M benchmark dataset’s order_items (6,468,831 rows) would rewrite the whole table for roughly 2–3 s. Treat that as a lower bound: the benchmark table has four narrow columns and a 181 MB resident footprint, where order_items has eleven columns and 420 MB, and the rewrite cost tracks bytes as well as rows.

Expect pruning to degrade after writes. A table that is loaded, then queried with range predicates, will prune well. The same table after an UPDATE will not, until it is recompacted.

  1. Restart survival is not measured here. Every invariant above is checked against a running daemon. Verifying that updated values outlive a process restart requires operator privileges the harness deliberately does not take; the harness has a --verify-only mode intended to be run after an operator restart, and that check has not been recorded on this build.
  2. Single client, no concurrency. No concurrent readers or writers, so nothing here characterises contention or isolation behaviour under load.
  3. Warm cache, data fits in memory. As with the query suite, the working set fits comfortably on this host. Behaviour when the table exceeds RAM is not characterised.
  4. One table shape. Four columns, three of them BIGINT. Wider tables and different type mixes are not covered, and the rewrite cost tracks bytes, so they will differ.
  5. UPDATE only. DELETE and mixed read/write workloads are not measured.