- requirements.md (new): the three use cases pinned as REQ-1..4 with the node/pool/fleet/engine vocabulary defined once — ends per-session re-derivation of consumer facts. REQ-2 (replicator fleet over one shared pg pool, incl. large-blob serving) is recorded as a planning fact predating all POCs, on the operator's authority. - ADR-008: Backend trait gains size(key) length probe (before any backend ships data — ADR-002 one-way-door discipline); fleet GC mechanism (DB-backed pin rows committed atomically with entries, TTL+renewal semantics, liveness = embedder-owned table, protect callback is single-node-only, advisory-locked single sweeper, staged re-arbitrated delete window on both engines); fs tier becomes engine-selectable (local default; pg-lo named candidate) with the fleet locality contract (shared media or re-routing; mixed tiers are a documented deployment invariant, not a constructor-provable one). - poc-pglo-spec.md (new): POC #7 spec — pg Large Objects as the fs tier's pg-lo engine; instruments, decision gate, registered in phase-0.md OQ-BL-06. - ADR-003/005 status amendments point to ADR-008's extensions; specs ripple (backends/store-api/gc/ops/overview/README). - open-questions.md: deferral-policy header gains the decisions-vs- sequenced-work distinction; pg-lo's why-not-parked audit trail recorded. - research fixes: postgres POC renumbered #4->#5 to the canonical register (phase-0 OQ-BL-06), redb cross-refs fixed, thinking- artifact sentence in B1 replaced with the honest reading. Verification: docs-only change; reference-integrity sweep across the tree (ADR/REQ/POC refs resolve); architecture-reviewer pass on the delta — original 3 criticals addressed, its follow-up (fleet liveness form, pin TTL, staged-delete semantics, enforceability, shared-media caveats) fixed in this commit.
240 lines
14 KiB
Markdown
240 lines
14 KiB
Markdown
---
|
||
status: passed
|
||
title: "POC #6 — redb as the kv engine: the 2-7×-over-sqlite claim does not survive crash-consistent measurement"
|
||
last_updated: 2026-10-02
|
||
---
|
||
|
||
# POC: redb as the kv engine — findings
|
||
|
||
> **POC register #6**, added post-convergence (same standing as #5: an
|
||
> ADR-003 substitution-seam input, not a Phase 0 gate). Code: standalone
|
||
> crate `/workspace/alkblobs-redb-poc` — a copy of the POC crate with the
|
||
> pg arm replaced by a `RedbKv` backend (`redb` 4.3, the engine iroh-blobs'
|
||
> fs store uses for its metadata tables). The inherited sqlite/fs arms and
|
||
> the 14-test suite are byte-identical to POC #3/#5's methodology.
|
||
> Date: 2026-10-02. Status: **passed** (14 tests, clippy `-D warnings`
|
||
> clean, fmt clean).
|
||
|
||
## What this POC set out to decide
|
||
|
||
The "opposite route" from POC #5: postgres is complicated, we only need a
|
||
kv — what about a high-performance *embedded* kv like redb, which the
|
||
DESIGN.md lineage (iroh-blobs' "faster than the filesystem for small
|
||
items") and downstream folklore put anywhere from **2-7× over sqlite**,
|
||
which was itself 7-9× over fs (POC #3 A4)? If redb beats sqlite by that
|
||
margin at equal semantics, the kv tier's engine pin (ADR-003) is again
|
||
informed rather than hypothetical — and so is the anti-recommendation if
|
||
it doesn't.
|
||
|
||
Same 2D instrument as POC #5 (the shape of the question, learned there):
|
||
|
||
1.Solo sweep (inherited `bench_small` harness, redb/sqlite/fs arms at
|
||
1-256 KiB, identical working set) — where the 2-7× claim should show up.
|
||
2. **Worker scale-out probe** (`redb_conc`, plus the POC #5 `sqlite_conc`
|
||
head-to-head) — redb is a single-file single-writer engine; does it
|
||
degrade like sqlite's WAL or survive like postgres?
|
||
|
||
## Result summary
|
||
|
||
| Question | Verdict |
|
||
|----------|---------|
|
||
| redb 2-7× over sqlite for small blobs? | **No, inverted: sqlite ~430× over redb on crash-consistent puts** (finding C1) |
|
||
| redb single-put floor | **~8-20 ms** at `Durability::Immediate` — one fdatasync per commit whose latency *is* the commit (finding C2, strace-verified) |
|
||
| redb at `Durability::None` (crash-inconsistent) | ~25-28k puts/s solo — but **degrades under threads** (24.7k → 12k at 8, finding C4) and can leave the file unreopenable after a crash |
|
||
| redb reads | **Catastrophically fast and ~free**: ~530-575k gets/s (p50 1-4 µs) — mmap/page-cache reads, 1000× sqlite (finding C3) |
|
||
| redb write scale-out | **Flat ~40-44 ops/s at 1, 2, 4, 8 threads** — pure serialization, worse than sqlite's WAL (which at least reached ~1.1k), nowhere near pg's ~28k (finding C5) |
|
||
| Is the swap small like pg's? | **Yes** — ~230 lines, same table shape, but the durability API is coarser (finding C6) |
|
||
|
||
**The headline conclusion (C1+C2): the 2-7× claim is a *durability-tier
|
||
confusion*, not a measured property.** It implicitly compares workloads
|
||
where fsync costs are hidden (in-memory, or crash-inconsistent commits).
|
||
Under the shipped tier this crate would actually tolerate — redb
|
||
`Immediate` vs sqlite `synchronous=NORMAL` WAL, both crash-consistent —
|
||
sqlite's WAL commits are fsync-free (checkpoint-time only) while redb's
|
||
Immediate commits are one-fdatasync-each: the 430× inversion. redb's
|
||
genuine weapon is reads (C3), and reads are the thing the fs backend and
|
||
page cache were never losing at.
|
||
|
||
## Findings
|
||
|
||
### Finding C1 (benchmark, solo): sqlite ~430× faster than redb on crash-consistent puts at 1 KiB; the 2-7× claim inverted
|
||
|
||
Inherited harness, 1 s/arms, representative run (see the harness for a
|
||
re-run; all sizes measured, table shows key ones):
|
||
|
||
```
|
||
backend size puts/s gets/s put p50µs put p99µs get p50µs get p99µs
|
||
redb 1024 ~100 ~540000 8334 ~45000 1.0 5
|
||
sqlite 1024 ~44000 ~51000 21 64 19.0 24
|
||
fs 1024 ~5000 ~3500 200 285 282.0 365
|
||
redb 16384 ~105 ~530000 8333 ~42000 1.0 5
|
||
sqlite 16384 ~13000 ~14800 73 205 66.0 79
|
||
fs 16384 ~4700 ~2870 210 350 410.0 580
|
||
redb 65536 ~100 ~530000 8334 ~70000 1.0 5
|
||
sqlite 65536 ~3700 ~4080 270 495 243.0 310
|
||
fs 65536 ~2550 ~1840 410 550 543.0 665
|
||
redb 131072 ~103 ~527000 8330 ~73000 1.0 5
|
||
sqlite 131072 ~1900 ~1930 530 795 507.0 768
|
||
fs 131072 ~1930 ~1540 527 795 629.0 840
|
||
redb 262144 ~86 ~516000 8333 ~70000 1.0 5
|
||
sqlite 262144 ~900 ~990 1080 2825 1008.0 1194
|
||
fs 262144 ~1530 ~2470 588 1375 381.0 580
|
||
```
|
||
|
||
Readings:
|
||
|
||
- **redb puts are flat ~100/s regardless of size** (1 KiB through
|
||
256 KiB — the put cost is the commit, not the value). 8.3 ms p50,
|
||
40-85 ms p99. sqlite remains 440×/1300×/4×/2×/0.6× ahead at
|
||
1/16/64/128/256 KiB respectively.
|
||
- **The fs crossover survives** (fs wins 256 KiB puts, and puts get
|
||
close at 128 KiB) — A4's threshold story is unchanged by a fourth arm.
|
||
- sqlite's numbers reproduce A4 within noise again (44k at 1 KiB):
|
||
methodology parity across all four POCs holds.
|
||
|
||
### Finding C2: redb's Immediate commit is *exactly one fdatasync* — and on a loaded mdraid that sync costs 8-30 ms; the p50 tracks it 1:1
|
||
|
||
Decomposition probes (`redbdiag*`):
|
||
|
||
- **Warm Immediate puts: p50 8.3-19.7 ms depending on device pressure,
|
||
p99 40-135 ms.** `strace -c` on a full bench run: 5784 fdatasync,
|
||
~300 µs mean each; `strace -tt -T` on a warm solo run: 363 fdatasync
|
||
accounting for **95% of wall time** (10.2 s of 10.7 s), per-fdatasync
|
||
19-30 ms p50-p90 on the loaded disk. The commit latency *is* the
|
||
fdatasync latency.
|
||
- The fdatasync count per put is ~1 (~300 Immediate commits + ~13
|
||
startup/savepoint commits vs 313 calls in the isolated probe) — no
|
||
2-phase double-sync; it's a single sync, just an expensive one on a
|
||
device with dirty-page pressure (the pg container's WAL churn was
|
||
co-resident through part of this POC — noted in the caveats; the
|
||
*relative* redb-vs-sqlite conclusion is unaffected, and redbdiag was
|
||
re-run clean at the end: p50 18.5 ms).
|
||
- **Durability::None puts: 27-33 µs p50** — the fsync removal alone
|
||
accounts for the 300× spread. Confirms the mechanism precisely.
|
||
|
||
### Finding C3: redb reads are ~530-575k gets/s (p50 1-4 µs) — the page cache through an mmap-like path
|
||
|
||
The has/get probes return in 1-4 µs p50 (5 µs p99) across every size —
|
||
that's not a B-tree probe, that's the OS page cache served from
|
||
process-mapped memory. sqlite's 19-23 µs p50 is a syscall-per-probe; fs's
|
||
282 µs is open+read syscalls. **This is redb's real 10-1000× win — on the
|
||
operation that matters least**, because (a) the fs tier already serves
|
||
large blobs from the page cache the same way, and (b) small-tier gets
|
||
were never the bottleneck axis (sqlite was already the fastest get at
|
||
19 µs, amortized by the store's digest checks anyway).
|
||
|
||
### Finding C4: redb's Durability::None concurrency *degrades* — 24.7k solo → 20k at 4 threads → 12k at 8
|
||
|
||
`redb_conc` in none-mode: the None arm has no fdatasync to amortize, so
|
||
adding threads only adds internal transaction-lock contention plus the
|
||
None-durability freelist growth penalty (pages freed between durable
|
||
commits accumulate until an Immediate commit collects them — redb's
|
||
documented "rapid growth" behavior). Contrast POC #5: sqlite's
|
||
contentioned WAL plateaued ~1.2k, pg *scaled* to 28k+. redb's None mode
|
||
is the only arm in three POCs where throughput goes **down** as workers
|
||
go up.
|
||
|
||
### Finding C5 (the decisive one): redb write scale-out is perfectly flat — ~40-44 ops/s at 1, 2, 4, and 8 threads
|
||
|
||
`redb_conc` Immediate mode (same fresh-key discipline as POC #5's
|
||
`pgdiag6`/`sqlite_conc`):
|
||
|
||
| workers | redb Immediate | sqlite (same probe) | pg (from POC #5) |
|
||
|---------|---------------|---------------------|------------------|
|
||
| 1 | ~42-44 ops/s | ~700-800/s | ~2.5-3.2k/s |
|
||
| 2 | ~40 ops/s | ~1.0-1.1k/s | ~4.6-5.5k/s |
|
||
| 4 | ~40-43 ops/s | ~0.6-0.9k/s | ~20-21k/s |
|
||
| 8 | ~43 ops/s | ~1.1-1.2k/s | ~28k/s |
|
||
|
||
- redb's single-writer file lock + internal transaction serialization
|
||
admits **zero concurrency**: flat at the solo commit cost (~24 ms/put
|
||
fresh-key; ~8-19 ms re-put). sqlite's WAL at least *queues* writers
|
||
behind the one flush lock and gets modest throughput from contention
|
||
efficiency; redb just serializes full fdatasync latencies.
|
||
- Against pg's curve this is the whole argument in one row set: the
|
||
embedded engines' curves are flat/degrading; the client-server curve
|
||
is the only increasing one.
|
||
|
||
### Finding C6: the swap is small but the durability API is coarser than sqlite's
|
||
|
||
`kv_redb.rs` (~230 lines): same row shape (`TableDefinition<&[u8],
|
||
(&[u8], i64)>` — the value/size tuple matches sqlite's row exactly;
|
||
`WITHOUT ROWID`-equivalent PK ordering is native B-tree). Differences:
|
||
|
||
- **redb 4.x offers only `Durability::None` and `Durability::Immediate`**
|
||
— no middle tier. sqlite's `synchronous=NORMAL` (consistent-on-crash,
|
||
not-fsynced-per-commit — the shipped tier this crate benchmarks and
|
||
documents) has **no redb equivalent**: `None` is not
|
||
crash-consistent (unpersisted commits can leave the file failing to
|
||
open until repaired — the freelist bookkeeping itself is lost on
|
||
crash), `Immediate` pays a full fdatasync per commit. The
|
||
durability-posture comparison that POC #5 could tune continuously
|
||
(`synchronous_commit=off` on pg) doesn't exist here.
|
||
- redb's `Database` is process-lifetime; concurrency modes
|
||
(`ExclusiveWriter` default, `SingleWriter`, `MultiWriter`) only matter
|
||
multi-process — in-process it's one writer transaction at a time,
|
||
structurally.
|
||
- `create`-then-repair behavior on a `None`-durability crash and the
|
||
`has+insert` CAS dance inside one write tx are correct but slower
|
||
(the bench's re-put path costs a table probe per commit).
|
||
|
||
## Consequences for the phase-0/1 register
|
||
|
||
- **redb is ruled out as the shipped kv engine at a measured tier
|
||
mismatch, not a benchmark quibble**: the only durability postures it
|
||
offers are "lose acknowledged data on crash" or "pay one fdatasync
|
||
per commit" — the crate's shipped durability tier
|
||
(`synchronous=NORMAL` WAL, crash-consistent, no per-commit fsync)
|
||
is not expressible. sqlite keeps ADR-003's pin on both economics
|
||
(44k vs 100 puts/s at 1 KiB) and posture.
|
||
- **The iroh-blobs DESIGN.md lineage ("faster than the filesystem for
|
||
small items") is now fully triangulated**: its kv-tier claim is
|
||
true for its *inline small items with batched/durable-None commits*
|
||
(iroh batches writes through a serialized actor with batch windows —
|
||
`BatchOptions.max_write_batch`, the same amortization pg gets from
|
||
WAL group-commit *for free across connections*). Our harnesses
|
||
measure per-commit put discipline; iroh's actor batching is the
|
||
reconciliation, and it is also why our POC #3 sqlite numbers (44k)
|
||
are the honest ceiling for a per-op CAS store.
|
||
- **Batched commits are a real Phase 1 lever, proven by three
|
||
independent engines**: pg's group-commit (B3), iroh's write actor
|
||
windows, redb's None+flush emulation (arm C4's structure). The
|
||
store's put seam could take a `WriteBatch`-shaped commit later — an
|
||
API question for Phase 1 (pinned batch puts exist in ADR-005's
|
||
surface already; the *durability-window* question is adjacent).
|
||
- **Reads: no case for redb on merit** — page-cache reads are what any
|
||
of these give the large tier for free; sqlite's 19 µs probe was
|
||
already fine.
|
||
- **Anti-recommendation adopted (ADR-007, 2026-10-02): redb stays
|
||
ruled out** — recorded in the decision so the question does not
|
||
reopen implicitly. SQLite's engine pin now has four-way triangulated
|
||
evidence (POC #3 fs, POC #5 pg, POC #6 redb, plus the lineage
|
||
citation), and ADR-007 ships the kv tier as sqlite + postgres. If a
|
||
future consumer wanted redb-shaped durability trade-offs (ephemeral
|
||
cache tier), that's a *different* tier than the kv engine slot.
|
||
|
||
## POC quality notes
|
||
|
||
- The inherited 14-test suite passes unchanged (the redb backend is
|
||
additive; sqlite/fs arms untouched). The CAS semantics of
|
||
put-if-absent are exercised through the smoke path; redb's
|
||
insert-returns-prior-guard shape documented in `kv_redb.rs` docs.
|
||
- clippy `-D warnings` clean, fmt clean.
|
||
- **Honest caveats**: mdraid single dev box whose fdatasync latency
|
||
ranged 0.24-30 ms during the session under co-resident load (pg
|
||
container churn from POC #5 ran through the first bench; stopped
|
||
before the final redbdiag re-run — relative conclusions unaffected,
|
||
absolute redb-Immediate numbers are *device-pressure-dependent* and
|
||
the honest statement is "1 fdatasync per commit, cost = device
|
||
fdatasync latency, observed 8-30 ms here"); the 2s full bench was
|
||
dropped from the record (its 128/256 KiB redb arms hit shell
|
||
timeouts; 1s arms with explicit size lists used instead — the harness
|
||
gained a size-list argument for this); the 1g redb working-set files
|
||
(~270 MB at 128 KiB arm) cleaned from /tmp after diagnosis.
|
||
- Instruments: `src/kv_redb.rs` (the backend: Immediate + None + flush
|
||
emulation methods), `src/bin/bench_small.rs` (inherited + redb arm +
|
||
size-list arg), `examples/redbdiag.rs` (durability decomposition),
|
||
`redbdiag2.rs` (None + periodic-flush group-commit emulation),
|
||
`redbdiag3.rs` (commit-vs-insert split, per-put file growth),
|
||
`redbdiag4.rs` (warm distribution), `redb_conc.rs` (scale-out, both
|
||
durability modes), `sqlite_conc.rs` (copied head-to-head from #5). |