- ~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.
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.
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.
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.
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.