Writes must be bulk, not per-row — per-column insert overhead and async inserts

Every INSERT touches every column file once, so per-row inserts on a 30-column table pay 30x the per-column overhead and the engine drowns; the only sustainable write pattern is large batches.

Previously

Vectorized execution wants thousands of values per call. That same rule applies to writes — they have to arrive in batches, not one row at a time, or the per-column overhead crushes the engine.

Scene 05

Writes must be bulk, not per-row

  1. Watch
  2. Try it
  3. Predict
  4. Capture
CLIENT1000 rows/secarriving from appENGINE — PER-ROW1 row in →30 column writes outBACK-PRESSURE: too many parts (3000+)COLUMN FILES — 30c0.binc1.binc2.binc3.binc4.binc5.binc6.binc7.binc8.binc9.binc10.binc11.binc12.binc13.binc14.binc15.binc16.binc17.binc18.binc19.binc20.binc21.binc22.binc23.binc24.binc25.binc26.binc27.binc28.binc29.bin30 writes / rowPER-COLUMN OVERHEAD (writes/sec)30,000/sSUSTAINED THROUGHPUT~30 rows/secmode: per-rowPer-row INSERT into a 30-column table: each row fires 30 column writes. Engine drowns.
What to watch for

1000 rows/sec arrive at the engine. Watch the client-side buffer fill to 1000 rows, then flush as one batched INSERT — the engine pays a single 30-column fan-out for the whole batch, not 30 fan-outs per row.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Engine.insertPerRow(rows)
the pathological path — 30 fan-outs per row
def insertPerRow(rows):
for row in rows: # N iterations
for col in table.columns: # 30 columns
f = open(part_dir / f'{col}.bin')
f.write(codec.encode(row[col]))
f.sync()
f.close()
registerPart(part_dir) # 1 part per row
# cost = N rows * 30 columns = 30N file ops
Engine.insertBulk(batch)
the amortized path — 30 fan-outs per batch, not per row
def insertBulk(batch):
part_dir = newPartDir()
for col in table.columns: # 30 columns, ONCE
f = open(part_dir / f'{col}.bin')
for row in batch: # N rows, hot loop
f.write(codec.encode(row[col]))
f.sync()
f.close()
registerPart(part_dir) # 1 part per batch
# cost = 30 file ops, regardless of batch size
Server.onInsert(query, rows)
async_insert — server-side buffer flushes one slab
def onInsert(query, rows):
if not cfg.async_insert:
return insertBulk(rows) # synchronous path
buf = buffers[shape(query)] # one buf per query shape
buf.append(rows)
if (buf.bytes >= async_insert_max_data_size # ~1 MB
or buf.age_ms >= async_insert_busy_timeout_ms # ~200 ms
or buf.queries >= async_insert_max_query_number):
insertBulk(buf.drain()) # one fan-out per FLUSH
if cfg.wait_for_async_insert:
return waitForFlush(buf) # durable, back-pressures
return Ack() # fire-and-forget

Where this sits in Build a columnar OLAP store (ClickHouse / Druid style)

Scene 05 of 13, in the Write side act — Bulk inserts → immutable parts → background merge → too-many-parts cliff.. Every INSERT touches every column file; per-row inserts on a 30-column table pay 30× the per-column overhead. Batches or async_insert.

Up next. If every batch becomes its own self-contained file on disk, we need a name and a shape for that unit — because everything operational from here on out is about how many of these units a query has to touch.

All 13 scenes in Build a columnar OLAP store (ClickHouse / Druid style) · Every curriculum

Built with Arqly
Every scene in Build a columnar OLAP store (ClickHouse / Druid style) builds on the one before it.All 13 Build a columnar OLAP store (ClickHouse / Druid style) scenes