Lepis puts one Postgres database on many Postgres servers, behind one connection string. The servers stay stock: no extension, no fork, two settings changed. How it works, what we measured, what broke.
The short version
- It scales. Eight 4-vCPU servers behind one router: 76,799 writes a second against 12,169 on one, 6.3 times.
- It matches Postgres. 3,500 generated queries against plain Postgres: 0 wrong.
- It survives broken servers. A live cluster broken 21 ways under load: 0 acknowledged writes lost wherever it heals itself.
- It moves data cheaply. A quarter of a node moved on 107 MB of source WAL, against 1,584 MB row by row.
- It refuses before it guesses. The error names the fix.
When each result was measured
Lepis changes every week, so we do not rerun everything. Each number is what one build did on one day. How it works describes router 0.1.25.
| Result | Measured | On |
|---|---|---|
| Hash against Postgres | Oct 5 | Postgres 13, 17 and 18 |
| Cost of routing | Oct 6 | One 8-vCPU host, shared |
| Failure drills | Oct 11 to 18 | Live SnoutData Cloud fleet |
| Generated queries, moves under load | Oct 17 | Router 0.1.11, one table per node |
| Scale-out to 8 nodes | Oct 18 | Eleven hosts, one table per node |
| Chunks, joins, growth | Nov 1 | Chunked build, one 4-vCPU box in Docker |
Chunks became the default on November 1, after the scale-out. The raw output is public.
How it works
The pieces
- The router speaks the Postgres protocol and holds only connections. Run as many as you like.
- A distribution key, say
customer_id, picks each row's node. Tables split by row, never by column. Tables sharing a keyspace join on one node. - Ranges cut the hash space. Each has one owner, and they split, merge and move.
- Chunks make up a range: plain tables with their own indexes, 64 per keyspace by default.
- Reference tables are small and copied whole to every node.
- The home node holds everything else and the catalog. No etcd, no extra service.
Three things it does not trust
- Its reading of SQL. Postgres's parser (libpg_query) reads every statement. What it cannot parse is refused.
- Its hash. Postgres's extended hash, ported to Rust: 0 differences on 101 million values.
- Its routing. Each chunk has a CHECK on that hash, so a stale router, a bug or psql cannot put a row on the wrong node.
Moving data
- Copy. The target subscribes to the moving chunks by logical replication.
- Pause. Only those chunks lock, 2 seconds at most by default. A job that would run over retries.
- Switch. The source's copies retire, the target's attach, the catalog moves. Waiting statements get a
40001to retry. - Clean up.
DROP TABLE. NoDELETE, noVACUUM, and the disk comes back.
In the Cloud, a new node can start from the source's S3 backup, then drop the chunks it does not own.
Queries across nodes
| The statement | What Lepis does |
|---|---|
| Names the key | One node |
| Names no key | Every node, merged: ORDER BY, LIMIT, aggregates, GROUP BY, DISTINCT |
| Joins a small table off the key | Copies it to each node (100,000 rows at most) |
| Joins two big tables off the key | Re-hashes both, node to node (about 1 billion rows at most) |
| Window function, DISTINCT ON, UNION | Gathers on the home node (1 million rows or 1 GiB at most) |
| Writes to two nodes | Two-phase commit, decided once on the home node |
| Goes past a limit | Refused, naming the limit and the fix |
Join paths apply to a single SELECT outside a transaction, and every limit is a setting. If a router dies mid-commit, another finishes: a logged decision completes, and no decision after 30 seconds rolls back.
What we broke on purpose
A client wrote known keys, read fixed answers and moved money across nodes through the public address. A checker then compared every table with the client's log. Lost means told committed, and not there. Keys out runs from first failure to first success.
| Scenario | Writes lost | Keys out | Heals alone |
|---|---|---|---|
| Postgres crashes in a node | 0 | 1 s | yes |
| Node killed | 0 | 5 s | yes |
| Node killed, again 20 s later | 0 | 6 s, 4 s | yes |
| Both data nodes killed | 0 | 8 s, 6 s | yes |
| Range move, source or target killed | 0 | 5 s | yes |
| Node killed mid-drain | 0 | 8 s | yes |
| Node killed while joining | 0 | none | yes |
| Replica killed or broken | 0 | none (commits stall 9 s) | yes |
| Router killed | 0 | 11 s, 2.5 s, every key | yes |
| Home killed | 0 | 9 to 10 s, every key | yes |
| Paused, woken by clients | 0 | none (7 s wait) | yes |
| Evicted to S3, woken cold | 0 | 30 s, every key | yes |
| Node frozen 60 s | 0 | 60 s | yes |
| Node cut off 60 s | 0 | 61 s | yes |
| Disk full | 0 | writes fail until freed | yes |
| Node broken, replica in sync | 0 | 111 s | yes |
| Node broken, replica behind | 0 | 82 s | yes |
| Node broken, replica behind, WAL gone | 0 held; 251, and 85 transfers, if accepted | 200 s | waits for a person |
| Home broken, with replica | 0 | 130 s, every key | yes |
| Home broken, no replica | none | until repaired | no |
| Two-phase: node killed before COMMIT | rolled back on both | yes | |
| Two-phase: node killed after PREPARE | committed on both | yes | |
| Two-phase: router killed after PREPARE | committed on both | yes |
Everywhere else, nothing was duplicated or half applied. In the first run, keys on the other data nodes never failed.
The other drills
| What we did | Result |
|---|---|
| A database's disk destroyed under load | Back in 89 s, 0 of 4,231 lost (a plain database) |
| One 8 KB page overwritten on disk | Caught by its checksum, every row matched after failover |
| Coordinator killed at 9 commit points | All 9 recovered; 240 transfers, no money made or lost |
| 8 writers, 19 cutovers per run | About a million writes each on 18 and 17, 0 lost |
| Fleet acceptance run, first node to cluster off | All 49 checks passed |
What those drills caught
- The last 44 seconds. A node died with its replica behind, and promotion lost 630 writes. Failover now saves the node's WAL first, or waits for a person. Rerun: 0 lost.
- The backup that kept the damage. pgBackRest copies a bad page with a warning and a clean exit, so a restored replica carried it. Replicas now use the newest clean backup.
- 797 writes. The first disk drills lost 797 and 301 to two bugs of ours. Fixed; the rerun lost none.
- The 37-second stall. Growing a table to 40 million rows froze a node's clients: an
ALTER TABLEqueued behind aCOPY. Fixed, the slowest transaction took 1,059 ms. - One wrong answer. The generated corpus caught a
NOT EXISTSgoing wrong on the new join path, before it was committed.
On the chunked build, 500 generated queries: 493 equal, 7 refused, 0 wrong. All 40 million rows came back exact.
How fast
Spreading the data
Eleven EC2 hosts: eight 4-vCPU nodes, two 8-vCPU routers, one client. 10 million accounts, every transaction on one node. Peak transactions a second, 32 to 512 clients.
| Nodes | Read, router | Read, no router | Write, router | Write, no router | TPC-B, router | TPC-B, no router |
|---|---|---|---|---|---|---|
| 1 | 68,296 | 73,191 | 12,169 | 11,608 | 7,611 | 7,644 |
| 2 | 129,753 | 123,468 | 25,734 | 26,028 | 14,124 | 14,619 |
| 4 | 223,356 | 249,216 | 45,923 | 51,411 | 25,134 | 28,483 |
| 8 | 341,126 | 504,827 | 76,799 | 103,722 | 44,238 | 58,558 |
| 8, two routers | 409,449 | 85,626 | 45,952 |
- To 4 nodes, the nodes are the limit, at 94 to 100% CPU.
- At 8, one router is the limit: 68 to 76% of the nodes alone, 78 to 83% with two routers.
- The catch. The 8-node write and TPC-B peaks had the home at 1,200 connections. At 600: 62,551 and 39,322.
The price of the extra hop
One node behind the router, all on one shared 8-vCPU host. Cells: tps / p50 ms / p99 ms.
| Workload | Direct | Router, transaction mode | Session pooler |
|---|---|---|---|
| select, 16 clients | 123k / .123 / .31 | 65k / .238 / .51 | 58k / .271 / .45 |
| select, 64 clients | 113k / .471 / 1.73 | 80k / .704 / 2.17 | 57k / 1.126 / 1.40 |
| TPC-B | 5.0k / 2.36 / 9.9 | 4.85k / 2.87 / 9.6 | 4.85k / 2.80 / 10.9 |
- Costs: simple reads, 53% of direct at 16 clients, about 71% at 64.
- Costs little: TPC-B keeps 97% of direct.
- Loses: p99 at 64 clients, 2.17 ms against the pooler's 1.40, unexplained. Session mode's TPC-B p99 is 12.5 ms.
Moving data and running joins
One 4-vCPU box in Docker, 10 million rows per table. Compare the columns; they are not capacity numbers.
| Moving 25% of a node | One table per node | Chunks |
|---|---|---|
| Time | 130.6 s | 38.9 s |
| WAL on the source | 1,584 MB | 107 MB |
| Disk given back | none | 379 MB |
| Write pause | 48 ms | 144 ms |
| p99 read during the move | 975 ms | 368 ms |
| p99 write during the move | 38 ms | 51 ms |
- Joins: a broadcast took 956 ms against 528 ms on one Postgres. Two 10 million row tables off the key: 22.2 s against 2.4 s.
- Other pauses: logical moves, one table per node, p99 0.21 s on Postgres 18. A move by promotion on the fleet, no writers: 736 to 875 ms.
What it does not do
- The home node is the single point. In the Cloud a broken home fails over in about two minutes, every key out. A home that hangs or is cut off is not covered there.
- Losing a whole host loses no acknowledged write on Pro and above while the second-zone copy is up, but stops the cluster until its nodes are restored.
- Not distributed SQL, not multi-master. Each node is a whole Postgres, and every row has one owner.
- No global snapshot across nodes.
- A router behind on the catalog can serve stale reads for at most 10 seconds, then stops. The node refuses its writes.
- Refused across nodes:
INSERT ... SELECT, SERIALIZABLE, savepoints. - Table rules: unique keys include the distribution key, plain
serialis refused, partitioned tables stay on the home node. - Not measured: scale-out on chunks, transactions across nodes, skewed keys, a fixed-rate run.
- Open defect: a session's first prepared transaction commits in two phases on one node. Past about 300 clients the home ran out of connections.
- Unexplained: a node spends 17% more CPU per transaction behind the router.
- Advisor thresholds are chosen, not measured. The autopilot is off until you turn it on.
Where it sits
Postgres spreads three ways: a new storage engine, an extension in every server, or a router in front of plain servers. Lepis is the third.
- Nothing to install. Any Postgres 17 or 18, with
max_prepared_transactionsabove zero andwal_level = logical. - The node enforces ownership, not only the router.
- Moves end in
DROP TABLE, not a delete and a vacuum. - In the Cloud, a new node can be an S3 restore, not a copy off the live primary.
Run it yourself
From the public snout-lepis repository. It needs Docker; run benchmarks on a throwaway host.
bash scripts/dev.sh cargo test -p snout-lepis --lib # unit tests
bash scripts/it.sh 18 # every cluster test and the oracle
bash scripts/hash-check.sh # the hash against a real server
bash scripts/it-physical.sh 18 # a move by promotion
LEPIS_BENCH_ROUTED=1 bash bench/pgbench.sh 60 16 <label>
bash bench/scaleout.sh --routers <a>[,<b>] --client <c> --nodes <1>,...,<8> # eleven hosts
bash bench/chunks.sh # moves and joins, both layouts