Files
alkblobs/docs/architecture/decisions/007-two-kv-engines-sqlite-and-postgres.md
T
glm-5.3-flash abf8c5ae9c docs(architecture): ADR-010/011 — I/O seams, GC-state home, vocabulary; pre-decomposition consistency round
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).
2026-10-03 05:55:20 +00:00

181 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
1. **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).
2. **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.
3. **The `Backend` trait does not change.** Both engines implement the
identical contract: opaque byte keys/values, CAS semantics
(`INSERT ... ON CONFLICT DO NOTHING` maps sqlite's
`INSERT OR IGNORE`), complete `list()`, GC-participating `delete`,
virgin-store no-op reads, namespace-blindness. The dispatch layer
(size threshold, kv↔large routing, fall-through) is engine-blind and
unchanged.
4. **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_commit` posture per pool/session, configurable —
the parity knob with sqlite's `synchronous=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.
5. **Sweep-safety proof per ADR-003's admission gate**: for pg, `list()`
is a stable `SELECT key` cursor over a vacuuming table (MVCC-ghost
stability across a long sweep), batch-delete via `key = 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.
6. **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.
7. **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](../open-questions.md) — OQ-10, resolved by this
ADR; OQ-09 (the anti-circularity precedent)
- [backends-and-dispatch.md](../backends-and-dispatch.md) — the spec
surface this ADR amends