<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>stagDB Engineering</title><link>https://stagdb.com/blog/</link><description>Engineering notes from stagDB — we build production-adjacent dev tools.</description><language>en-us</language><atom:link href="https://stagdb.com/blog/index.xml" rel="self" type="application/rss+xml"/><item><title>Benchmarking FSx for OpenZFS for Postgres: throughput vs. provisioned IOPS</title><link>https://stagdb.com/blog/fsx-openzfs-postgres-throughput-iops/</link><pubDate>Fri, 07 Aug 2026 00:00:00 +0000</pubDate><guid>https://stagdb.com/blog/fsx-openzfs-postgres-throughput-iops/</guid><description>&lt;p&gt;FSx for OpenZFS is sized along two independent dimensions, and it bills them separately.
&lt;strong&gt;Provisioned throughput capacity&lt;/strong&gt; is a fixed ladder of tiers — 64, 128, 256, 512, 1024 MB/s
and up — and it is the expensive lever. &lt;strong&gt;Provisioned SSD IOPS&lt;/strong&gt; is a much cheaper add-on
above the baseline that comes with your storage. Writing a Terraform module for a Postgres
workload requires picking both, and the console does not indicate which one a given database
will be sensitive to.&lt;/p&gt;
&lt;p&gt;We run Postgres clones for development environments on FSx for OpenZFS, so we had to answer
this for our own default sizing. The question was narrow: &lt;strong&gt;at a fixed filesystem, which
knob buys Postgres TPS — throughput or IOPS — and is the relation linear?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We measured it. The answer is lopsided enough that it changed how we size filesystems.&lt;/p&gt;
&lt;h2 id="method"&gt;Method&lt;/h2&gt;
&lt;p&gt;One EKS cluster, one FSx for OpenZFS filesystem: 256 GiB, &lt;code&gt;SINGLE_AZ_1&lt;/code&gt;, in us-east-2. One
Postgres 16 database under CloudNativePG, its data directory on a volume from that
filesystem, loaded with pgbench at scale 50 — 5,000,000 accounts, about 0.7 GiB logical.&lt;/p&gt;
&lt;p&gt;The important detail is that we did not reprovision anything between tiers. FSx accepts
throughput and IOPS changes &lt;strong&gt;in place&lt;/strong&gt; via &lt;code&gt;aws fsx update-file-system&lt;/code&gt;; each step applies
non-disruptively over roughly 2–10 minutes while the filesystem stays mounted and the
database stays up. So every row below is the &lt;em&gt;same database, on the same volume, with the
same cache state and the same pod&lt;/em&gt; — only the filesystem&amp;rsquo;s provisioned numbers moved. That
removes the usual confound where each configuration gets a freshly loaded database with
different physical layout.&lt;/p&gt;
&lt;p&gt;At each tier we ran in-pod pgbench twice: select-only (&lt;code&gt;-S&lt;/code&gt;) and the default TPC-B-like
read/write script, each with 4 clients, 2 threads, 60 s.&lt;/p&gt;
&lt;p&gt;Two honesty constraints, up front: &lt;strong&gt;one run per tier, 60 s each.&lt;/strong&gt; These are not
distributions. The effects below are large enough to survive that — a 2.5× gap does not come
from run-to-run noise — but nothing here supports reasoning about a 5% difference.&lt;/p&gt;
&lt;h2 id="results"&gt;Results&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier (MB/s / IOPS)&lt;/th&gt;
&lt;th&gt;select-only TPS&lt;/th&gt;
&lt;th&gt;TPC-B TPS&lt;/th&gt;
&lt;th&gt;TPC-B avg latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;128 / 1000&lt;/td&gt;
&lt;td&gt;15,272&lt;/td&gt;
&lt;td&gt;645&lt;/td&gt;
&lt;td&gt;6.21 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 / 1000&lt;/td&gt;
&lt;td&gt;16,361&lt;/td&gt;
&lt;td&gt;699&lt;/td&gt;
&lt;td&gt;5.72 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512 / 1000&lt;/td&gt;
&lt;td&gt;15,398&lt;/td&gt;
&lt;td&gt;769&lt;/td&gt;
&lt;td&gt;5.20 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1024 / 1000&lt;/td&gt;
&lt;td&gt;15,770&lt;/td&gt;
&lt;td&gt;834&lt;/td&gt;
&lt;td&gt;4.80 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1024 / &lt;strong&gt;3000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15,865&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,089&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.92 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;128 / 3000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;14,556&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,925&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.08 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The first four rows walk throughput across three doublings at fixed IOPS. The fifth changes
only IOPS. The sixth is the interesting one: cheapest throughput tier, expensive IOPS.&lt;/p&gt;
&lt;h2 id="findings"&gt;Findings&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. Throughput scaling is not linear, and its effect is small.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;8× the provisioned throughput, 128 → 1024 MB/s, bought &lt;strong&gt;+29%&lt;/strong&gt; TPC-B TPS: 645 → 834. Three
doublings of the expensive lever produced less than a third more throughput. The curve rises
roughly logarithmically, consistent with marginally lower per-operation queueing rather than
any bandwidth constraint being lifted.&lt;/p&gt;
&lt;p&gt;That is the expected result once you account for what the workload actually moves. A pgbench
TPC-B transaction touches a handful of pages and forces a WAL flush; a 0.7 GiB working set
streams nowhere near 128 MB/s, let alone 1024. The read/write workload is bound by
&lt;strong&gt;sync-write latency per operation&lt;/strong&gt;, not by bandwidth, so additional bandwidth addresses a
constraint that is not binding.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Provisioned SSD IOPS is the knob that matters.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;3× the IOPS, 1000 → 3000, at fixed 1024 MB/s throughput took TPC-B from 834 → &lt;strong&gt;2,089 TPS —
2.5×&lt;/strong&gt; — and cut average latency from 4.80 ms to 1.92 ms. That is near-linear in IOPS, which
is what a WAL-fsync-bound workload should look like: each commit needs a durable write to
land, the rate of durable writes is what IOPS provisions, so TPS tracks it.&lt;/p&gt;
&lt;p&gt;Compare the two levers directly. 8× on throughput: +29%. 3× on IOPS: +150%.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. The cost-optimal shape is cheap throughput plus provisioned IOPS.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The last row is the most useful one for a sizing discussion. 128 MB/s — the &lt;em&gt;lowest&lt;/em&gt; tier we
tested, the default sizing — combined with 3000 IOPS delivered &lt;strong&gt;1,925 TPS: 92% of the best
result we measured&lt;/strong&gt;, which needed 8× the throughput to add the remaining 8%.&lt;/p&gt;
&lt;p&gt;On FSx for OpenZFS pricing, stepping throughput from 128 to 1024 MB/s is the expensive lever;
2,000 extra provisioned IOPS costs a small fraction of that. We are deliberately not printing
dollar figures — AWS pricing varies by region and changes — but the ratio is substantial, and
it points in the same direction as the performance data. The configuration that performs
nearly best is also the less expensive one, which makes the trade-off unusually
straightforward.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Read-heavy work is insensitive to both knobs.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Select-only sat at roughly &lt;strong&gt;15–16k TPS across every tier&lt;/strong&gt;, with average latency in the
0.24–0.28 ms range. The spread has no trend in it — the highest select-only number, 16,361
TPS, is at 256 MB/s, in the middle of the ladder, and the lowest is at the tier with the most
IOPS.&lt;/p&gt;
&lt;p&gt;The reason is that the hot set lives in Postgres shared buffers and the page cache, so the
reads mostly never reach FSx. The pod&amp;rsquo;s CPU is the ceiling. For a read-heavy workload that
fits in RAM, neither FSx knob is the bottleneck, and the choice between them has little
effect either way.&lt;/p&gt;
&lt;h2 id="what-this-means-for-sizing"&gt;What this means for sizing&lt;/h2&gt;
&lt;p&gt;For a write-oriented workload, the data supports &lt;strong&gt;scaling provisioned IOPS with expected
concurrent write load while holding throughput at a low tier.&lt;/strong&gt; That runs against the
intuition that a &amp;ldquo;bigger&amp;rdquo; filesystem means more MB/s, and against what our own default sizing
did before this run — we had been scaling throughput with the number of developers and
pinning IOPS at 1000, which is the less effective allocation for anything TPC-B-shaped.&lt;/p&gt;
&lt;p&gt;Throughput still matters, just not for OLTP. It is the right lever for streaming reads and
bulk sequential work: restoring a dump, an initial data load, a large sequential scan that
misses cache, a backup drain. Those genuinely move bytes at a rate a 128 MB/s tier will cap.
A filesystem serving both — an OLTP database plus a nightly bulk load — is best sized with
throughput chosen for the bulk window and IOPS for the transactional steady state; the two
are independent enough to reason about separately.&lt;/p&gt;
&lt;p&gt;Two practical notes. First, &lt;code&gt;SINGLE_AZ_1&lt;/code&gt; throughput is a &lt;strong&gt;fixed list of tiers&lt;/strong&gt;, not a
continuous dial: 64, 128, 256, 512, 1024, 2048, 3072, 4096 MB/s — a request for 200 is not
possible, so sizing math needs to be rounded onto that ladder before any decision is made.
Second, because updates apply in place and non-disruptively, this is an inexpensive
experiment to repeat against a specific workload: a single afternoon of
&lt;code&gt;aws fsx update-file-system&lt;/code&gt; and pgbench produces a table like the one above for the database
actually in use, which is more informative than our numbers.&lt;/p&gt;
&lt;p&gt;One operational caution for anyone repeating it: an in-place FSx update puts the filesystem in
an optimization window for several minutes, and we saw volume-creation calls against that
filesystem stall for the duration. It is non-disruptive to &lt;em&gt;running&lt;/em&gt; I/O, but resizing a
filesystem is best avoided while anything depends on provisioning new volumes from it on a
schedule.&lt;/p&gt;
&lt;h2 id="caveats"&gt;Caveats&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One 60 s run per tier.&lt;/strong&gt; No variance estimate, no repeats, no warm-up discipline beyond
the fact that the same database stayed hot across all six tiers. The 2.5× IOPS effect is
far too large to be noise; the +29% throughput effect is directionally solid but its exact
magnitude is not something we would defend to two significant figures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Small hot set.&lt;/strong&gt; At scale 50, roughly 0.7 GiB, the data fits comfortably in cache. That
is the entire explanation for the flat read-side numbers, and a working set larger than RAM
would change them substantially. It would &lt;strong&gt;not&lt;/strong&gt; change the write-side finding: WAL fsync
is a durability cost that a bigger cache does not remove, so the IOPS-over-throughput
conclusion should hold — and get stronger — as the database grows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One Postgres instance, no concurrent cluster load.&lt;/strong&gt; The filesystem&amp;rsquo;s provisioned budget
is shared across everything on it. These numbers describe one database with the whole
budget to itself; a filesystem hosting many active databases splits it, and per-database
TPS falls accordingly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Modest client concurrency.&lt;/strong&gt; 4 clients / 2 threads is a light load. Higher write
concurrency will move the absolute TPS numbers and may change where the IOPS curve stops
being near-linear; we did not find that ceiling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SINGLE_AZ_1&lt;/code&gt;, us-east-2, July 2026.&lt;/strong&gt; Deployment type, region, pricing, and FSx behavior
can all change. &lt;code&gt;MULTI_AZ_1&lt;/code&gt; has different write-path characteristics that we did not
measure at all.&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>Clone latency on FSx for OpenZFS: from 10 minutes to 16 seconds</title><link>https://stagdb.com/blog/fsx-openzfs-clone-latency/</link><pubDate>Fri, 07 Aug 2026 00:00:00 +0000</pubDate><guid>https://stagdb.com/blog/fsx-openzfs-clone-latency/</guid><description>&lt;p&gt;A ZFS clone does not copy data. &lt;code&gt;zfs clone&lt;/code&gt; writes a bit of metadata pointing a new dataset
at an existing snapshot&amp;rsquo;s blocks, marks them copy-on-write, and returns. The cost is the
same whether the snapshot is 10 MB or 10 TB — you only pay for blocks that later diverge.
That property is the reason we picked ZFS: it makes it plausible to give every developer
their own full-size copy of production data instead of a gutted 500-row fixture set.&lt;/p&gt;
&lt;p&gt;AWS offers that property as a managed service. FSx for OpenZFS runs real OpenZFS, and its CSI
driver provisions a volume from a snapshot with &lt;code&gt;CopyStrategy=CLONE&lt;/code&gt;, sharing blocks rather
than copying them. On paper: a developer asks for a clone of a production-size Postgres
database and gets one in seconds, no matter how big it is.&lt;/p&gt;
&lt;p&gt;We built that, and the block sharing behaved exactly as documented. The latency did not match
the underlying primitive, and characterizing the gap is what the rest of this post is about.&lt;/p&gt;
&lt;h2 id="where-the-latency-actually-comes-from"&gt;Where the latency actually comes from&lt;/h2&gt;
&lt;p&gt;Our first milestone spike was deliberately small: EKS 1.30, one FSx for OpenZFS
&lt;code&gt;SINGLE_AZ_1&lt;/code&gt; filesystem (256 GiB, 128 MB/s), and a ~10 GB golden snapshot — pgbench at
scale 700, 10.98 GB logical. We cloned it into three developer namespaces at once, booted a
CloudNativePG Postgres on each, and measured clone-to-Ready.&lt;/p&gt;
&lt;p&gt;Three concurrent clones came back at &lt;strong&gt;110 s, 177 s, and 238 s&lt;/strong&gt; — a p50 of 177 s against a
gate of 120 s, and a p95 of 238 s.&lt;/p&gt;
&lt;p&gt;The storage half passed convincingly. Amplification for the golden plus three clones was
&lt;strong&gt;1.107×&lt;/strong&gt; — 12.16 GB used where four independent full copies would have been about 44 GB.
The blocks were genuinely shared, the cloned data directories byte-identical to the golden,
and they replayed WAL cleanly after we killed the pods. Copy-on-write worked as advertised.&lt;/p&gt;
&lt;p&gt;So the latency was not data movement, and we confirmed it was size-independent — nothing was
being read or written. What we were waiting on was the managed control plane in front of ZFS.
FSx&amp;rsquo;s &lt;code&gt;CreateVolume&lt;/code&gt; API takes roughly &lt;strong&gt;75 s for a plain provisioned volume and about
2 minutes when creating from a snapshot&lt;/strong&gt;. That is the floor, and it is independent of the
data involved.&lt;/p&gt;
&lt;p&gt;The shape of those three numbers is the more consequential detail. 110, 177, 238 — rising
with concurrency, spaced about a minute apart. That is not three requests each taking two
minutes in parallel; it is the signature of a queue. We ran the test again at ten concurrent
clones to characterize how the queue behaves at higher fan-out:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concurrent clones&lt;/th&gt;
&lt;th&gt;Clone-to-Ready (s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;K=3&lt;/td&gt;
&lt;td&gt;110 / 177 / 238&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;K=10&lt;/td&gt;
&lt;td&gt;129 / 198 / 269 / 319 / 381 / 428 / 490 / 550 / 611, plus one that had not finished at 10 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The inter-completion gap is a near-constant &lt;strong&gt;~60 s&lt;/strong&gt;. FSx &lt;code&gt;CreateVolume&lt;/code&gt; serializes at
roughly &lt;strong&gt;one clone per minute&lt;/strong&gt;, so the Nth developer to ask for a database waits about N
minutes. At K=10 the p50 was &lt;strong&gt;381 s&lt;/strong&gt; and the p95 was &lt;strong&gt;611 s&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That characteristic is a poor match for the demand pattern a development-environment tool
sees. Developer demand for clones is not uniform; it arrives in a burst when a team starts
work in the morning, which is precisely the regime in which a serialized API performs worst.
Ten concurrent developers is a small team rather than a stress test, and under this API the
last request in line waits about ten minutes for an operation the storage layer itself
performs in milliseconds.&lt;/p&gt;
&lt;h2 id="an-unplanned-natural-control"&gt;An unplanned natural control&lt;/h2&gt;
&lt;p&gt;We built the fix (below), redeployed, and ran a time-to-first-clone benchmark on a separate
live cluster, against a smaller golden — &lt;code&gt;prod-pg16-v3&lt;/code&gt;, 86 MB and 750k rows — measuring each
Instance&amp;rsquo;s &lt;code&gt;creationTimestamp&lt;/code&gt; to its Ready condition. The size difference does not matter
here, and that is the point: the claim path moves no data at all, so its latency is
independent of golden size, which is what makes these numbers comparable to the 10 GB
measurements above.&lt;/p&gt;
&lt;p&gt;Nine of the ten claims were fast, and one was not: a single claim raced a momentarily-empty
pool, found nothing warm, and fell through to the on-demand path — a real &lt;code&gt;CreateVolume&lt;/code&gt;
against FSx. It returned in &lt;strong&gt;600.3 s&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;We could not have designed a cleaner control. Same cluster, same golden snapshot, same
operator build, same measurement — one request took the old path and every other took the
new one, roughly &lt;strong&gt;37× apart&lt;/strong&gt;, with no confounding variable except which code path it took.&lt;/p&gt;
&lt;p&gt;It also reproduced the spike&amp;rsquo;s finding on a live cluster, months later, without our
intending it: 600.3 s is what &amp;ldquo;serializes at ~1/min&amp;rdquo; predicts for a request landing behind a
queue of pool refills. We excluded it from the percentiles — it has no pool-claim
timestamp pair, so it is not a sample of what we were measuring — but it is the most
informative number in the run.&lt;/p&gt;
&lt;h2 id="the-fix-moving-the-api-call-out-of-the-hot-path"&gt;The fix: moving the API call out of the hot path&lt;/h2&gt;
&lt;p&gt;If the bottleneck is a control-plane API you do not control, you cannot make it faster. What
you can do is arrange not to be waiting on it while the user is.&lt;/p&gt;
&lt;p&gt;So we pre-pay it. A pool controller keeps N clones per golden snapshot already provisioned
and idle in a holding namespace, each created ahead of demand by the slow serialized path,
on our schedule rather than a developer&amp;rsquo;s. When a developer runs &lt;code&gt;adj clone&lt;/code&gt;, they do not
&lt;em&gt;create&lt;/em&gt; a clone — they &lt;em&gt;claim&lt;/em&gt; one.&lt;/p&gt;
&lt;p&gt;The claim path makes &lt;strong&gt;zero&lt;/strong&gt; &lt;code&gt;CreateVolume&lt;/code&gt; calls. We verified that directly in a separate
spike, counting FSx volumes and CSI calls across a claim: the FSx volume count went 3 → 3,
with 0 CSI &lt;code&gt;CreateVolume&lt;/code&gt; calls, and the resulting Postgres came up Ready with the golden&amp;rsquo;s
data intact.&lt;/p&gt;
&lt;p&gt;What a claim does is move an existing volume between namespaces and convince Postgres to
adopt it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Patch the pre-warmed PV&amp;rsquo;s reclaim policy to &lt;code&gt;Retain&lt;/code&gt;, so releasing it does not destroy
the underlying FSx volume.&lt;/li&gt;
&lt;li&gt;Delete the holding-namespace PVC. The PV goes to &lt;code&gt;Released&lt;/code&gt;; the FSx volume survives.&lt;/li&gt;
&lt;li&gt;Strip the PV&amp;rsquo;s &lt;code&gt;claimRef&lt;/code&gt;, returning it to &lt;code&gt;Available&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Create a PVC named &lt;code&gt;&amp;lt;cluster&amp;gt;-1&lt;/code&gt; in the developer&amp;rsquo;s namespace, bound by &lt;code&gt;volumeName&lt;/code&gt;
directly to that PV, carrying CloudNativePG&amp;rsquo;s data-PVC identity metadata —
&lt;code&gt;cnpg.io/pvcRole: PG_DATA&lt;/code&gt;, &lt;code&gt;cnpg.io/nodeSerial: &amp;quot;1&amp;quot;&lt;/code&gt;, and critically
&lt;code&gt;cnpg.io/pvcStatus: ready&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Create a CloudNativePG &lt;code&gt;Cluster&lt;/code&gt; with &lt;strong&gt;no &lt;code&gt;bootstrap&lt;/code&gt; stanza&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That last pair is the essential part. &lt;code&gt;cnpg.io/pvcStatus: ready&lt;/code&gt; is what distinguishes an
&amp;ldquo;adopt me&amp;rdquo; volume from one still initializing. With it present and no bootstrap stanza, CNPG
runs neither &lt;code&gt;initdb&lt;/code&gt; nor &lt;code&gt;pg_basebackup&lt;/code&gt; — it logs &lt;code&gt;Creating new Pod to reattach a PVC&lt;/code&gt; and
starts Postgres directly on the existing data directory. No initdb pod is ever created.&lt;/p&gt;
&lt;p&gt;We expected to need a specially prepared &amp;ldquo;cold&amp;rdquo; golden, since ours is taken as an online
volume-snapshot backup and could in principle carry in-progress-backup state. It did not.
&lt;code&gt;pg_controldata&lt;/code&gt; on the clone showed &lt;code&gt;Backup start location: 0/0&lt;/code&gt;, &lt;code&gt;End-of-backup record required: no&lt;/code&gt;, &lt;code&gt;Database cluster state: in production&lt;/code&gt;. The cloned directory is just a
crash-consistent copy of a live PGDATA; Postgres ran ordinary automatic recovery, hit an
end-of-recovery checkpoint, and came up. Our existing goldens were directly adoptable.&lt;/p&gt;
&lt;p&gt;Measured over the 9-sample benchmark, the claim path gives:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Operator time-to-first-clone, p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.0 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 120 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operator time-to-first-clone, p95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25.0 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 300 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operator min / max&lt;/td&gt;
&lt;td&gt;15.0 s / 25.0 s&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI wall-clock (user-visible)&lt;/td&gt;
&lt;td&gt;16.4–26.4 s&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Operator time is the &lt;code&gt;Instance&lt;/code&gt; CR&amp;rsquo;s &lt;code&gt;creationTimestamp&lt;/code&gt; to its Ready condition; the CLI
wall-clock a developer experiences tracks it about 1 s higher. The remaining ~16 s is not
FSx at all — it is the PV rebind (dominated by waiting on the PVC delete) plus CNPG starting
a pod and running crash recovery. We moved the two-minute managed-API call out of the hot
path, and what is left is Kubernetes and Postgres doing their normal work.&lt;/p&gt;
&lt;h2 id="the-constraint-we-did-not-remove"&gt;The constraint we did not remove&lt;/h2&gt;
&lt;p&gt;The pool does not make FSx faster. It moves the wait.&lt;/p&gt;
&lt;p&gt;Refill runs at the same serialized rate we measured in the first place: filling a cold pool
from 0 to 10 warm clones takes about &lt;strong&gt;580 s&lt;/strong&gt;, and refilling from 4 to 10 after a benchmark
drained it takes about &lt;strong&gt;348 s&lt;/strong&gt; — both the same ~1 clone/min cadence.&lt;/p&gt;
&lt;p&gt;So the honest description of what we built is a buffer. It absorbs a burst up to its warm
size at seconds-latency, and beyond that it is bounded by the same API as before. The 600.3 s
outlier is not an anomaly; it is what a claim looks like when demand outruns refill — the
system&amp;rsquo;s designed failure mode, observed in practice.&lt;/p&gt;
&lt;p&gt;That makes pool size the real operational knob, and it has to be set against expected
concurrent-launch rate rather than headcount. A ten-person team that all starts at 9 a.m.
needs a bigger warm pool than a thirty-person team spread across time zones. Sizing it from
usage telemetry rather than a guess is work we still owe.&lt;/p&gt;
&lt;h2 id="takeaways"&gt;Takeaways&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A managed control plane can dominate the cost of an operation that is nearly free at
the storage layer.&lt;/strong&gt; &lt;code&gt;zfs clone&lt;/code&gt; is a metadata write; wrapped in &lt;code&gt;CreateVolume&lt;/code&gt; it becomes
a two-minute operation. When evaluating a managed version of a primitive you already
understand, it is worth benchmarking the API rather than the primitive — the API is the
part being purchased.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concurrency behavior is more informative than single-shot latency.&lt;/strong&gt; One clone at 110 s
looked like a tuning problem. The same operation at K=10 was a 611 s p95 and an
architectural constraint. Only the fan-out showed whether the design worked, and the
indicator was the near-constant ~60 s gap between completions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When an API outside your control is the bottleneck, the remaining lever is removing it
from the hot path.&lt;/strong&gt; We could not make &lt;code&gt;CreateVolume&lt;/code&gt; faster, so we arranged for it to
happen before anyone was waiting: pre-provision, then claim, with the claim itself kept
genuinely cheap. Ours makes zero calls to the slow API, which is why it lands at ~16 s
rather than at ~16 s plus an occasional two-minute provisioning call.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="caveats"&gt;Caveats&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;n=9, and it is a burst benchmark, not sustained load.&lt;/strong&gt; A real p95 for a burst.
Sustained concurrency — many developers launching over a long window, contending on refill
— is future work, and we expect it to be governed by the refill rate above rather than by
the 16 s claim number.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The 600.3 s on-demand figure is a single observation.&lt;/strong&gt; It is a clean natural experiment
and it agrees with the independently measured ~1 clone/min serialization, but it is one
sample, not a distribution.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filesystem sizing:&lt;/strong&gt; all numbers come from &lt;code&gt;SINGLE_AZ_1&lt;/code&gt; at 128 MB/s and 1000 IOPS. That
matters less than it sounds for the headline result — the claim path moves no data, so the
warm-claim numbers are throughput- and IOPS-independent and should hold on larger
filesystems. It matters a great deal for what happens &lt;em&gt;after&lt;/em&gt; a clone boots: in a separate
test, one clone running pgbench alone hit 166 TPS, while ten clones writing concurrently
got ~13–19 TPS each, because that budget is a single shared pool split across every clone
on the filesystem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This is FSx behavior as we measured it, June–July 2026, in us-east-2.&lt;/strong&gt; AWS may have
changed &lt;code&gt;CreateVolume&lt;/code&gt; since. Anyone evaluating FSx for OpenZFS would be well served by
re-running the fan-out test directly — it takes an afternoon, and it is the number that
decides the design.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This pooled claim path is now the default way clones are created in
&lt;a href="https://stagdb.com/adjoint/"&gt;Adjoint&lt;/a&gt;.&lt;/p&gt;</description></item><item><title>Copy-on-write development databases: ten clones at 1.12× the storage</title><link>https://stagdb.com/blog/copy-on-write-dev-databases/</link><pubDate>Fri, 07 Aug 2026 00:00:00 +0000</pubDate><guid>https://stagdb.com/blog/copy-on-write-dev-databases/</guid><description>&lt;p&gt;To ship correct software, developers need data that behaves like production: real volumes,
real distributions, real edge cases. The reason almost nobody gets that is arithmetic. A full
copy of a multi-TB database costs real time and real money, multiplied by every developer who
needs one. The naive approach is &lt;strong&gt;O(size × developers)&lt;/strong&gt;, and both terms grow.&lt;/p&gt;
&lt;p&gt;So teams generally pick from four options, each of which is a version of the same concession:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What teams do instead&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tiny seed or fixture data&lt;/td&gt;
&lt;td&gt;The query that returns in 3 ms on 500 rows times out on 50 million. Cardinality-dependent bugs reach production because nothing before production had the cardinality.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One shared staging database&lt;/td&gt;
&lt;td&gt;Contention and pollution. No isolation. One destructive migration or one long-running &lt;code&gt;ALTER TABLE&lt;/code&gt; blocks everyone, so risky work gets scheduled instead of done.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual production dumps&lt;/td&gt;
&lt;td&gt;Hours per copy, perpetually stale by the time it lands, full storage cost per copy, and unmanaged production data sitting on laptops.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-developer cloud copies (e.g. one RDS instance each)&lt;/td&gt;
&lt;td&gt;Honest but expensive: at multi-TB × headcount the bill is the point of the exercise, and because refreshing costs the same again, nobody refreshes.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each of these is a rational response to the multiplication. If you can remove the
multiplication, all four choices stop being necessary.&lt;/p&gt;
&lt;h2 id="what-copy-on-write-actually-does"&gt;What copy-on-write actually does&lt;/h2&gt;
&lt;p&gt;ZFS stores a dataset as a tree of blocks. A snapshot does not copy anything — it pins the
current block tree so those blocks cannot be freed or overwritten in place. A clone is a new,
writable dataset whose root points at that same pinned tree.&lt;/p&gt;
&lt;p&gt;From there the accounting is simple. Reads on the clone follow shared blocks. A write on the
clone allocates a new block and repoints the clone&amp;rsquo;s tree at it; the snapshot&amp;rsquo;s block is
untouched, because the snapshot still needs it. Nobody copies the unchanged 99%.&lt;/p&gt;
&lt;p&gt;So N clones of a base dataset cost:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;base + Σ(divergence per clone)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;not &lt;code&gt;N × base&lt;/code&gt;. Two consequences fall out of that, and they are the whole argument. First,
creating a clone is a metadata operation, so it does not get slower as the dataset gets
bigger. Second, storage is charged for what each developer &lt;em&gt;changes&lt;/em&gt;, not for what they
&lt;em&gt;have&lt;/em&gt;. A developer who clones a 2 TB database and runs a migration against three tables pays
for three tables&amp;rsquo; worth of rewritten blocks.&lt;/p&gt;
&lt;p&gt;The question is whether that survives a managed cloud filesystem and a real Postgres on top of
it. We measured it on AWS FSx for OpenZFS.&lt;/p&gt;
&lt;h2 id="measured"&gt;Measured&lt;/h2&gt;
&lt;p&gt;Our first substrate spike ran on EKS 1.30 with a single FSx for OpenZFS &lt;code&gt;SINGLE_AZ_1&lt;/code&gt;
filesystem (256 GiB, 128 MB/s) in us-east-2. The base dataset was a ~10 GB golden — pgbench
at scale 700, 10.98 GB logical — snapshotted and cloned into separate developer namespaces,
with a CloudNativePG Postgres booted on each clone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Golden + 3 clones: 1.107×.&lt;/strong&gt; Total storage consumed was &lt;strong&gt;12.16 GB&lt;/strong&gt;, against roughly
&lt;strong&gt;44 GB&lt;/strong&gt; for four independent full copies of the same data. That is the golden plus three
clones, on the ~10 GB dataset described above.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Golden + 10 clones, before divergence: 1.115×.&lt;/strong&gt; A second run on the same substrate and the
same ~10 GB golden cloned ten times: &lt;strong&gt;12.24 GB total&lt;/strong&gt;, where eleven full copies would be
about &lt;strong&gt;121 GB&lt;/strong&gt;. Per-clone overhead at rest was around &lt;strong&gt;126 MB&lt;/strong&gt;. These are two separate
measurements — 1.107× is the 3-clone run, 1.115× the 10-clone run — and going from 3 clones to
10 barely moved the multiplier, because the multiplier is not a function of clone count.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;With writes, amplification tracks divergence, as it must.&lt;/strong&gt; After driving 120 s of heavy
pgbench writes into each of the ten clones, total storage rose to &lt;strong&gt;1.366×&lt;/strong&gt; — about
&lt;strong&gt;+2.76 GB&lt;/strong&gt;, or roughly &lt;strong&gt;276 MB of divergence per clone&lt;/strong&gt; for that write burst. This is not
a defect in the model; it is the model. You pay for changed blocks. Ten clones each carrying
276 MB of changes still cost about &lt;strong&gt;8× less&lt;/strong&gt; than eleven full copies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The clones are real copies, not approximations.&lt;/strong&gt; Data digests on the cloned Postgres data
directories matched the golden&amp;rsquo;s byte for byte (&lt;code&gt;be80ca2f…&lt;/code&gt;), and after killing the pods the
clones replayed WAL cleanly and came back up. There is no consistency tax being hidden inside
the storage number.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Amplification is divergence-driven and base-size-independent.&lt;/strong&gt; Nothing in the mechanism
reads the size of the base dataset. A 1 TB golden shares blocks exactly the way a 10 GB one
does; what changes is the absolute size of the base you pay for once, not the multiplier.&lt;/p&gt;
&lt;h2 id="what-that-buys-as-a-workflow"&gt;What that buys as a workflow&lt;/h2&gt;
&lt;p&gt;Cheap clones only help if the lifecycle around them is cheap too, and the lifecycle is where a
storage property turns into a way of working.&lt;/p&gt;
&lt;p&gt;An admin curates a &lt;strong&gt;golden snapshot&lt;/strong&gt; from a real source of truth — an RDS Postgres, say —
and versions it. That golden is immutable, so every clone taken from &lt;code&gt;v3&lt;/code&gt; is the same &lt;code&gt;v3&lt;/code&gt;,
which is what makes a test run reproducible weeks later. Then each developer works against
their own clone of it, in their own namespace, with four operations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;clone&lt;/strong&gt; — take a private, full-size database from a golden version.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;reset&lt;/strong&gt; — throw the current one away and re-clone the same version. This is what makes
destructive work safe: run the migration, drop the table, rewrite the index, and if it goes
badly, discard the evidence in seconds instead of asking who else is on staging.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;refresh&lt;/strong&gt; — re-clone from a newer golden version when the base data has moved on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;destroy&lt;/strong&gt; — reclaim it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What makes this different from a dump is that reset and destroy are cheap enough to be
reflexes. A copy that took two hours to produce becomes something you protect; a copy you can
replace in seconds becomes something you use hard and throw away. Disposability is a behavior
change, and it only exists if both the storage cost and the time cost are near zero.&lt;/p&gt;
&lt;p&gt;The time cost is a separate engineering problem, and on FSx it was the harder one — the managed
&lt;code&gt;CreateVolume&lt;/code&gt; API serializes at roughly one clone per minute, which put the tenth concurrent
clone about ten minutes out. With a pre-warmed pool in front of it, the measured p50 for a
developer claiming a clone is &lt;strong&gt;16.0 s&lt;/strong&gt;. That work is written up separately in
&lt;a href="https://stagdb.com/blog/fsx-openzfs-clone-latency/"&gt;Clone latency on FSx for OpenZFS: from 10 minutes to 16 seconds&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="the-honest-bill"&gt;The honest bill&lt;/h2&gt;
&lt;p&gt;Copy-on-write removes one specific cost — the per-copy multiplication of storage. It does not
make infrastructure free, and it is worth being precise about what is left.&lt;/p&gt;
&lt;p&gt;You still pay for &lt;strong&gt;the AWS infrastructure you run&lt;/strong&gt;: the EKS cluster and the FSx for OpenZFS
filesystem, including its provisioned throughput and IOPS. That is a real, recurring bill, and
it has to be sized for the &lt;em&gt;aggregate&lt;/em&gt; concurrent activity of your developers rather than
per-developer — an FSx filesystem&amp;rsquo;s throughput and IOPS budget is a single shared pool.&lt;/p&gt;
&lt;p&gt;On top of that you pay for &lt;strong&gt;one base dataset per golden version you keep&lt;/strong&gt;, plus &lt;strong&gt;each
developer&amp;rsquo;s divergence&lt;/strong&gt;. Ten developers on one golden version is one base plus ten small
deltas. Keeping four golden versions live is four bases — versions are not free, and retention
policy is a real decision.&lt;/p&gt;
&lt;p&gt;What you do not pay is a full copy per developer, and you do not ship production data to a
third party to get any of it: the clones live on infrastructure in your own account.&lt;/p&gt;
&lt;h2 id="who-this-is-not-for"&gt;Who this is not for&lt;/h2&gt;
&lt;p&gt;Two cases where this is the wrong tool, stated plainly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sub-GB databases.&lt;/strong&gt; If a production database is a few hundred megabytes, &lt;code&gt;pg_dump&lt;/code&gt; and
restore takes a minute and costs nothing worth measuring. The argument above is about
collapsing a multiplication; when the quantity being multiplied is small, there is nothing to
collapse, and a plain copy is the better answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Regulated PII or PHI, until masking exists.&lt;/strong&gt; Cloning gives every developer a byte-identical
copy of production — which is the point, and also the problem if that data is regulated.
Masking is not built yet, so clones today are raw. Where a compliance posture requires that
developers never touch unmasked production data, namespace isolation does not address that
requirement. This approach currently suits data that is acceptable to clone in full.&lt;/p&gt;
&lt;h2 id="caveats"&gt;Caveats&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The amplification numbers come from a ~10 GB golden.&lt;/strong&gt; 1.107× is the golden + 3 clones
run; 1.115× is the separate golden + 10 clones run. We have not measured the multiplier
directly at 1 TB. The mechanism is size-independent and we expect it to hold, but that is an
argument from how ZFS works, not a measurement at that scale.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The 1.12× in this post&amp;rsquo;s title is an illustrative model, not a measurement.&lt;/strong&gt; It is a
500 GB golden plus ten developers each changing 6 GB — 560 GB, or about 1.12× — and it
assumes pessimistically that every changed byte costs its full size. The measured anchors are
the two above: 1.107× at 3 clones and 1.115× at 10, both on a ~10 GB golden. Your ratio
depends entirely on how much your developers actually change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Divergence dominates over time, and our divergence figures are short-window.&lt;/strong&gt; The 276 MB
per clone came from a 120 s pgbench write burst — a deliberately heavy synthetic write load
over two minutes, not a measurement of what a developer accumulates over a working week. We
do not have a &lt;em&gt;measured&lt;/em&gt; sustained-use divergence number yet. A clone that lives for months
under heavy writes converges toward the cost of a full copy. Copy-on-write &lt;strong&gt;defers&lt;/strong&gt; storage
cost; it is disposability — reset and refresh being cheap enough to use — that keeps the
deferred cost from accumulating. TTLs and per-namespace quotas are load-bearing, not polish.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storage efficiency is not performance.&lt;/strong&gt; These clones share a filesystem&amp;rsquo;s provisioned
throughput and IOPS. In a separate test, one clone running pgbench alone reached 166 TPS
while ten writing concurrently got roughly 13–19 TPS each. Cheap storage does not mean free
I/O, and the sizing question is independent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FSx for OpenZFS, us-east-2, June–July 2026.&lt;/strong&gt; Managed-service behavior changes. The ZFS
property underneath is stable; the numbers around it are a snapshot of one provider at one
point in time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Postgres only, at the time of writing.&lt;/strong&gt; The mechanic is substrate-agnostic in principle —
it is a filesystem property — but everything above was measured with CloudNativePG.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a href="https://stagdb.com/adjoint/"&gt;Adjoint&lt;/a&gt; packages exactly this: clone, reset, refresh, and destroy
across versioned golden snapshots.&lt;/p&gt;</description></item></channel></rss>