ADR-010 (new): the Backend trait's I/O seams pinned before any code — 'get' returns a crate-internal read cursor (kv engines materialize; 'local' pread loop; pg-lo lo_get windows), 'put' has two named forms (whole-value kv / staged-put large); GC state is store-core-owned via a crate-internal contract-tested engine-state seam on the SQL-backed engines (backends never learn liveness — ADR-005 verbatim); the store core holds the joint entry+pin transaction (corrects ADR-009 §2's ownership statement); kv-tier fleet-validity rule added (mirror of ADR-008 §3); ops pin-token TTL race resolved (the token IS the pin; renewal rides have/re-put; pause-past-TTL falls to delete-then-recover); feature graph pinned (pg-lo does not imply postgres). ADR-011 (new): vocabulary pinned — tier (contract, exactly two) vs engine (concrete impl, exactly five); store instance / node / fleet split (fleet = pool-sharing, not node count — fixes requirements.md's self-contradiction with REQ-2); mem demoted from 'backend'/'testing tier' to the kv tier's third engine (contract-reference engine); fs tier renamed 'large' including the feature name (last free moment before code exists); constructor modes pinned (dual-tier default, kv-only, mem-only) plus the composite fleet-validity predicate. Consistency round across all specs and ADR-003/004/007/008/009: stale pg-lo passages resolved per ADR-009's supersession; ADR-008 owner 'node id' → store-instance id; threshold wording corrected (conservative edge, not midpoint, of the 128-256 KiB crossover zone); SweepReport shape specced; mem classification unified; 008/009 ADR files renamed to match the tier rename; README deferral-policy recap aligned (third category = decided-but-sequenced work, not a parking kind); POC crate list completed. Verification: two independent architecture review rounds (the first found 4 criticals — unpinned trait I/O shapes, missing fleet-state seam, node/fleet self-contradiction, pin-token/TTL conflict — all resolved; final round: zero criticals); all markdown links resolve; ADR tables complete (11 ADRs).
9.4 KiB
ADR-007: Two kv engines — sqlite and postgres — one trait, constructor-selected
Status
Accepted (the engine story's I/O seams and GC-state home are ruled by ADR-010; the vocabulary — tier/engine, and mem joining the kv tier as its third engine — is pinned by ADR-011)
Context
ADR-003/004 settled the crate's physical shape: two backends (kv for small blobs, fs for large), size-threshold dispatch between them, and the "two backends" count as a tier count. Within the kv tier, sqlite was pinned as the shipped engine, on single-connection economics (POC #3 A4). That pin left a real downstream shape unserved: the classical stack for the consumers this crate exists for — git servers, vfs/ appfile nodes, replicators — is three storage systems (a kv for small content, fs for large content, a relational database for refs, manifests, ACLs, queues). Forcing such a node to run sqlite beside its relational engine multiplies engines, WAL files, backup stories, and ops postures for no benefit.
POC #5 (poc-postgres-kv-findings.md, findings B1-B6) measured
postgres holding the kv tier's full contract and POC #6
(poc-redb-kv-findings.md, findings C1-C6) ruled out redb: its
durability API cannot express the shipped tier (crash-consistent
without per-commit fsync). Together with POC #3 A4 this is four-way
triangulated engine evidence, complete.
The deciding facts are all in hand and none is a fact only a future deployment of this crate could create (the deferral rule — see the Schrödinger's-code corollary in README.md, and the OQ-09 precedent):
- Topology, not throughput, is the trigger — but topology is a property of the node the operator builds today, not a future event: a private single-machine node has one writer and sqlite wins by measurement (44k solo puts/s at 1-16 KiB, POC #3 A4, reproduced in POC #5); a multi-machine node writing one pool needs postgres structurally, not merely faster (sqlite's WAL single-writer ceiling ~1.2k objects/s degrades under contention and flatlines — latency climbs without bound — while postgres scales near-linearly to ~37k puts/s across workers, POC #5 B3/B4).
- The engine cost delta is measured and bounded: ~180-line impl on the same table shape (B5), with named posture deltas (see Decision).
- The sweep-safety gate is testable in CI, not in production: list
correctness is observable only through GC (POC #1 finding 2), and
the exact-count sweep-outcome test shape runs against any engine the
contract covers — including dockerized postgres under
--all-features.
Recording this as an open question gated on "a replicator-shaped deployment materializes" would be circular hedging: such a deployment runs alkblobs, so it cannot cross the trigger until the pg engine ships, and the engine would not ship until the deployment exists. That deferral shape was already rejected once, on the same reasoning, in OQ-09.
Decision
The kv tier ships two engines — sqlite (default) and postgres —
behind one Backend trait; engine selection is a constructor
parameter on the backend's configuration. A node provisions one
relational engine plus the large tier; the kv tier rides whichever
engine the operator chose. This collapses the classical three-engine
downstream stack (kv + large content + relational) to two, and makes which
relational engine a per-node choice rather than a crate fork.
- sqlite stays the default engine: zero-ops, no daemon, solo
economics (44k solo puts/s at 1-16 KiB), and consumer co-tenancy
rides the same file (honker — the alk* family's SQLite
notify/queue extension,
/workspace/honker— ships WAL + the NORMAL-synchronous posture, so its defaults are literally the shipped tier). Single-machine nodes are sqlite by measurement, and remain so even at sustained rates well above observed push traffic (~1.2k/s WAL ceiling vs. single-node push rates an order below it). - postgres ships as an engine, feature-gated (
postgres, default-off):tokio-postgres+deadpool-postgres, the POC #5 arm's stack. It exists for the topology cases measured in POC #5 B3/B4: cross-machine write access to one pool, many-client replicator/hub nodes, and consolidated-ops deployments already running postgres. A postgres node's consumer tables (refs, manifests, queues) co-tenant the same instance by choice — the deployment's own consolidation, beside the pool per ADR-004. - The
Backendtrait does not change. Both engines implement the identical contract: opaque byte keys/values, CAS semantics (INSERT ... ON CONFLICT DO NOTHINGmaps sqlite'sINSERT OR IGNORE), completelist(), GC-participatingdelete, virgin-store no-op reads, namespace-blindness. The dispatch layer (size threshold, kv↔large routing, fall-through) is engine-blind and unchanged. - The pg engine's impl contract carries POC #5 finding B5's deltas
as requirements (they are the difference between a port and an
engine posture):
synchronous_commitposture per pool/session, configurable — the parity knob with sqlite'ssynchronous=NORMAL-WAL shipped tier (latency moves ~2-3× across settings on the POC disk);- pooled connections with per-connection prepared-statement discipline (fresh sessions cost ~20 ms, B2; prepared statements die with sessions — deadpool's statement cache encodes the pattern);
- autovacuum tuning stated for the churning CAS table, plus
fillfactor(90 measured) — a real maintenance surface sqlite does not have; - ~40× storage overhead at the 1-4 KiB tier accepted as the small-tier cost of a page-based server engine (B6) — documented, not hidden.
- Sweep-safety proof per ADR-003's admission gate: for pg,
list()is a stableSELECT keycursor over a vacuuming table (MVCC-ghost stability across a long sweep), batch-delete viakey = ANY($1); the exact-count sweep-outcome tests (store-api.md invariants 4/5) run in CI against dockerized postgres under--all-features, same gate shape as every backend. - Non-production posture defaults are named, deferred costs (the ADR-006 pattern, not hedges): the B5 tuning numbers are POC-derived from one dev-box disk; the first real pg deployment verifies them against its media and records deltas. No decision here waits on that verification.
- redb stays ruled out (POC #6) — recorded here so the question does not reopen implicitly: its durability API cannot express the shipped crash-consistent-without-per-commit-fsync tier, and its ~40/s write scale-out is below even sqlite's WAL ceiling.
The ADR-003 substitution door ("a downstream wanting a different engine there would be new ADRs") is now exercised in-repo, ahead of any downstream needing it: the downstream's third-engine question collapses to its own deployment choice among shipped engines.
Consequences
Positive
- The classical downstream stack lands at two engines per node (one relational engine + large tier), eliminating the "sqlite beside the real database" duplication this crate's consumers would otherwise carry forever.
- Per-node engine choice (constructor parameter, per ADR-003/004) makes a heterogeneous fleet — personal nodes on sqlite, multi-tenant hubs on postgres — a deployment topology, not a fork; everything above the trait is written once and is engine-blind.
- The evidence for both engines is first-party measured (POC #3 A4, POC #5 B1-B6, POC #6 C1-C6) and complete; this ADR stands on evidence in hand, no deferred decision remains in the engine story.
- CI covers the pg backend's contract via the same sweep-outcome test
gate as sqlite, behind
--all-features.
Negative
- The pg engine adds a dependency cluster (tokio-postgres, deadpool-postgres) behind a default-off feature — the base crate stays lean per convention 8, but the crate now carries a second engine impl and its posture surface to maintain.
- Postgres storage overhead at the small tier (~40×) is real (B6) and accepted; deployments dominated by tiny blobs on constrained disks stay better served by sqlite.
- Two engines means two durability/ops postures to keep honest across upstream changes (sqlite version/file-compat upstream guarantees; pg autovacuum behavior is config-dependent).
Neutral
- Implementation sequencing: sqlite is Phase 1's mainline; the pg impl is parallelizable against the fixed contract (POC #5 scaffold exists). No consumer wait gates it; it lands as normal Phase 1 implementation work.
- A third engine (S3-like, etc.) remains the ADR-003 path: a new ADR with its own sweep-safety proof. Nothing in this decision closes or opens that door further.
References
docs/research/poc-postgres-kv-findings.md(POC #5, findings B1-B6)docs/research/poc-redb-kv-findings.md(POC #6, findings C1-C6)docs/research/poc-largeblob-findings.md(POC #3, finding A4)docs/research/poc-trait-dispatch-findings.md(POC #1, finding 2)- ADR-003 (the trait contract and dispatch this decision rides), ADR-004 (tier count ≠ engine count; the beside-the-pool boundary), ADR-006 (the deferred-cost pattern this decision's posture defaults follow)
- open-questions.md — OQ-10, resolved by this ADR; OQ-09 (the anti-circularity precedent)
- backends-and-dispatch.md — the spec surface this ADR amends