Engineering story · PostgreSQL Fabric

A write path that sizes itself

Batches sized from the payload's own width under PostgreSQL's parameter limit, writer workers that follow queue demand, and the batch-size knobs deliberately taken away from operators

Rust writer servicePostgreSQL Fabric24-core benchmark host
Same parameter limit, three payload widths Every batch fills the same budget, sized from the width each producer stamps on its message.
65,535bind parameters per statement, the budget every batch is sized to
  • ~240updates per second each writer worker sustained in the benchmark
  • ~1,929updates per second held at eight workers
  • 99.25%buffer hit rate on that run, with I/O wait under 0.2 percent

Every function in my estate reads PostgreSQL directly and writes it through one service. Producers serialise each write into a stream message and move on; a writer service consumes the streams, groups messages by destination table and operation, and writes them in batched transactions at the pace the database can absorb. The interesting part is that nobody tells it how big a batch should be or how many workers to run.

Batches sized from the payload

PostgreSQL caps the bind parameters in one statement. A batch that exceeds the limit fails outright, and a batch far below it wastes round trips. The right batch size therefore depends on how many parameters each row binds, which depends on the destination and the operation. So every producer stamps its message with the width of its own payload, the writer aggregates per destination, and the batch size for that destination is derived from the limit and that width. The library underneath re-chunks by the same rule as a second line of defence.

The first version got this wrong in an instructive way. It sized batches from the destination table's column count, read from the catalog, when what the limit counts is placeholders in the statement. A table with eighteen columns updated through three array parameters was being batched far below what it could take.

One table, eighteen columns, updated through three array parameters · rows per batch
Sized from the catalog's columns3,640 Sized from the statement's placeholders10,000
About 63 percent of the throughput lost to counting the wrong thing.

The fix moved the width to where it is known, the producer.

Workers that follow the queue

The writer runs a pool of workers sized from demand. A coordinator measures the queue depth, estimates how many full batches it represents, and scales the pool toward that number: up quickly when the backlog grows, down slowly after it has drained, stopping the newest workers first. Dispatch is held back while more batches are outstanding than there are workers, which is the backpressure that keeps the pool from outrunning the database. Each worker takes one full batch for one destination, claimed atomically so that two workers never compete for the same messages, and partial batches are released by priority and age so a small table is not starved behind a large one.

The pool's bounds came from a benchmark rather than a guess, on a 24-core host writing tier-two health metrics across about four million rows.

Writer workers on a 24-core host
481624
useful range
CPU saturated
the measured run: fifteen to thirty-five of two hundred connections in use
Above sixteen workers, more workers only bought a saturated CPU.

Knobs removed on purpose

The batch-size settings are not on the operator's tool surface. A self-tuning system that can be hand-tuned is a system that will be mistuned, so the operator tool exposes status, scaling bounds, queue metrics, rule presets for normal, high-scale and emergency flush, and configuration persistence with atomic writes, backup, rollback and an audit trail, and nothing that sets a batch size directly.

The operator tool
Scaling boundsmin · max
Flush presetnormal · high-scale · emergency
Batch sizeno control: derived

Two lanes

Row writes and lattice-wide operations do not share a queue. Inserts, updates, upserts and deletes travel one lane; bulk updates, multi-row inserts and calls to SQL functions that run inside the database travel another, so a long-running redistribution pass, told in Redistribution inside the database, can never starve the row writes behind it. Results that span parent and child tables are written as one ordered message and committed in one transaction, so they never land half-written.

Row writes
Bulk writes and in-database functions
a redistribution pass
Illustrative timeline.

What this demonstrates

A write path engineered around the database's real limits and its own measurements: batch width derived from the protocol limit and the payload, worker count derived from the queue and bounded by a benchmark, and configuration designed so the system cannot be mis-set by hand.

The sizing rules and the decision to remove the knobs were mine; AI coding agents, a mix of frontier and open-source models, implemented the service under my governance system.