← Abdul Basit Sajid
pgoverlay · case study

A copy-on-write system that copied everything

The first honest benchmark said branching a 5 GiB database took 61.9 seconds and left a 5.05 GiB writable layer behind. That is not a slow path. That is the feature not working.

Go · Postgres · OverlayFS · github.com/abd-ulbasit/pgoverlay

The problem

pgoverlay gives you a Postgres database per pull request. You seed one dataset once, and every branch after that is supposed to be an OverlayFS copy-on-write view of it: one read-only base shared by every branch, a writable layer holding only what that branch changes, thrown away without ceremony. The entire value proposition is the copying that does not happen.

So the number that mattered was not the 61.9 seconds. It was the writable layer. After creating a branch of a 5.00 GiB database (no queries run, no rows written, nothing but a boot), the branch's writable layer held 5.05 GiB. The 1 GiB dataset behaved the same way: 1.00 GiB of database, 1.05 GiB of writable layer. Whatever else was happening, the system had made a full physical copy of the data on a code path whose whole purpose is not to.

The explanation I expected, and why it was wrong

pgoverlay seeds with pg_basebackup, so a branch's first boot is a crash recovery: Postgres replays WAL to bring the data directory to a consistent state. That is a real, known cost, and it was the obvious suspect: a slow, I/O-heavy recovery pass that touches everything.

The Postgres log refused to support it. The recovery it was blamed on reported:

redo done ... elapsed: 0.00 s

Zero. The replay was not slow; the replay was nothing. Whatever was burning a minute and materialising five gigabytes happened before the work I had assumed was responsible, and I had been reading the wrong side of the log.

The diagnosis

Before replaying any WAL, Postgres runs a pass called SyncDataDirectory. Its job is to guarantee that nothing left over from before the crash is sitting dirty in the page cache unflushed. Under the default recovery_init_sync_method=fsync, it does that the literal way: it walks the entire data directory and opens every single file read-write in order to fsync it.

On an ordinary filesystem that is cheap (an open, an fsync, a close, mostly against already-clean files). On OverlayFS it is not, because of one rule: opening a file that lives in the read-only lower layer with O_RDWR forces a copy-up. Not of the pages you touch. Of the whole file, immediately, on open, before you have written a byte.

Those two behaviours compose into the exact opposite of the product. The sync pass opens every file in the dataset read-write; OverlayFS faithfully copies each one into the branch's writable layer; and by the time Postgres is ready to accept its first connection it has duplicated the entire database in the name of preparing for a WAL replay that had nothing to replay.

Proving it rather than believing it

That story is plausible, which is the problem with it. Two measurements were cheap enough that there was no excuse for shipping the explanation without them.

  • The size is not approximately the database; it is the database. 5.05 GiB of writable layer against 5.00 GiB of data, and 1.05 against 1.00. A partial or incidental copy does not land on the dataset size twice at two different scales. A full copy-up of every file does.
  • A single-variable control run. Same image, same dataset, same command, one flag changed: recovery_init_sync_method=syncfs. The branch came up with 16 KiB in its writable layer. If the mechanism were anything other than the sync method's file-open behaviour, changing only the sync method could not have moved the number by five orders of magnitude.

The fix

One flag, in the container entrypoint that starts a branch's Postgres:

exec docker-entrypoint.sh postgres -c recovery_init_sync_method=syncfs

syncfs() flushes the entire filesystem containing the data directory in one call, without opening individual files. The durability argument is that this is a superset of what the per-file pass covers. The property the pass exists to establish still holds, so crash-recovery semantics are unchanged. What it costs is specificity: syncfs can surface I/O errors belonging to unrelated files on the same filesystem. In this design a branch's filesystem is its own disposable overlay mount holding nothing else, so that class of noise has nothing to be noisy about.

It is not free. syncfs as a recovery init method is Linux-only and Postgres 14 or newer, which puts a hard floor under the project. Postgres 13 is therefore rejected when you register a source, loudly, rather than silently accepting it and quietly performing badly.

What changed, and how it was measured

p50 of five runs per configuration on identical hardware, one variable changed between the two rows. All five raw runs for both tables are published in the repository rather than summarised away.
5 GiB database Create Writable layer
Before (fsync, the default) 61.9 s 5.05 GiB
After (syncfs) 1.89 s 33.1 MiB

Roughly 33× faster to create, and the storage a branch costs at rest drops by more than two orders of magnitude. That storage number is the one that decides whether you can afford a database per pull request.

What is still wrong with this

The cost moved. It did not disappear. OverlayFS copies up whole files, and Postgres heap and index segments run to 1 GiB each. Before the fix, creation had already materialised every file, so a later write only paid for the pages it dirtied: a 1% UPDATE grew the layer by about 179 MiB on the 5 GiB dataset. After the fix the layer starts at 33 MiB, so that same UPDATE plus a checkpoint pays first-write copy-up across every segment it touches and lands at 5.20 GiB.

That number is worse and it is published anyway, next to the one it replaced. The two are not measuring the same thing, and the honest framing is that pgoverlay moved the cost from pay-at-creation to pay-per-file-written. For branches that read a lot and write a little, which is what a pull-request database does, that is strictly better. For a branch that rewrites its entire dataset, it is a wash.

Two other things I would rather say than have someone find. The safety argument for syncfs is an argument: it reasons from what the two sync methods guarantee, and it has not been demonstrated with fault injection. And nothing in CI would catch this regression coming back. The fix is protected by a benchmark script that a human has to remember to run, which is exactly the kind of protection that fails silently a year later.

The repository states outright how much of its commit history was agent-assisted, and points at the two documents (the benchmark record and the deep dives) that required diagnosis rather than generation. The benchmark file still contains the pre-fix numbers that contradicted the project's own thesis, including the one above that looks better before the fix than after it.