Files
alkblobs/docs/architecture/decisions/003-backend-contract-and-dispatch.md
T
glm-5.3-flash 4b5009d85a docs(architecture): Phase 1 spec tree — 7 specs, ADRs 001-006, OQ tracker
- overview/hashing/store-api/backends/gc/ops specs + README index,
  all draft status with YAML frontmatter and cross-referenced ADR/OQ
- ADR-001: substrate posture (store layer alkcall-free = family
  conformance, not deviation) + ops placement decided (feature-gated
  module, no consumer-waited deferral)
- ADR-002: canonical git-blob-sha-256 + key encoding — the declared
  wire-format ADR (one-way door)
- ADR-003: backend contract, sqlite/fs/mem tiers, pure-function
  dispatch, no migration, unknown-length buffering semantics pinned
- ADR-004: exactly two production backends; manifest/path-tree layers
  are consumer-side (the corrected two-vs-three-backends framing)
- ADR-005: pooled CAS, three liveness sources, delete windows with
  pin-before-publish + delete-time arbitration (race closed), no
  ambient scheduling
- ADR-006: whole-blob verification; chunk-tree encodings excluded by
  scoping, not deferred on a paused consumer
- open-questions.md: OQ-BL-01..06 resolved with ADR cross-refs; OQ-07
  /08 externally-owned, OQ-09 deferred(scope) with SDD tracker task
- AGENTS.md: status updated to Phase 1, gix-odb path corrected

Verification: cargo test / clippy -D warnings / fmt --check clean
2026-10-01 14:45:32 +00:00

148 lines
6.9 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-003: Backend contract, shipped backends, and dispatch policy
## Status
Accepted
## Context
OQ-BL-02 settled the *base* contract in Phase 0 (lean `Backend` trait,
opaque keys, complete `list()`, size-threshold dual dispatch) with
named residuals: migration-between-backends policy, per-namespace
backend config, and the unknown-length put asymmetry (POC #3 finding
A6: an unknown-length re-put of a small blob lands on fs, duplicating
content the same digest's known-length put would put in kv).
One framing correction also lands here: the "two backends is ugly" /
"three backends" framing that influenced early rounds — corrected to
its real content as ADR-004 (two backends are *required*, and the
manifest layer is downstream, not a backend). This ADR records the
physical layer; ADR-004 records the boundary.
Evidence in hand (no further POC or consumer-waiting needed):
- POC #1 findings 1/2/6 (trait seam, list traps, dispatch thinness),
- POC #3 findings A1/A4/A5/A6 (put shapes, benchmark, stage hygiene,
the asymmetry),
- rudolfs anti-lesson (S3 backend with no list can never GC).
## Decision
### Backend trait contract
- **Opaque byte keys (`&[u8]`), opaque byte values.** Typed `Key`
converts at the store boundary only (ADR-002). Backends must survive
digest-layer evolution — a kv row or fs filename has no migration
story, so opaque, self-describing bytes are the durable choice. (The
generic-over-`K` alternative was considered and rejected in POC #1
finding 1: with one key type, generics buy nothing and break the
persistence boundary.)
- **Methods:** `has`/`get`/`put`/`delete`/`list`/`name`. Streaming and
range shapes ride the store layer above (fs `get` yields a file
handle, not a loaded buffer).
- **`list()` complete by contract** — and *well-formed*: a malformed
list (wrong destructure, values-as-keys) is as deadly as an
incomplete one, and silent until a sweep deletes the wrong things
(POC #1 finding 2). Consequence: backend implementations are accepted
only with sweep-outcome tests proving list correctness.
- **Virgin-store reads are no-ops** (missing table = absent/empty).
- **Namespace-blind** (ADR-005): backends see hashes only.
### Shipped backends (two, default-on; plus a testing tier)
- **`kv` (default-on): sqlite** for small blobs — measured ~9-10×
faster than fs at 1-16 KiB (POC #3 A4, first-party benchmark),
the regime holding most git objects, workspace files, and manifests.
WAL + `synchronous=NORMAL` as shipped durability; prepared
statements; no-tables-yet tolerance.
- **`fs` (default-on)** for large blobs — flat
`{hex-prefix}/{hex-prefix}/{hash}` sharding (iroh's surviving
conclusion); stage-then-commit-rename; pread range reads (sound
locally per ADR-006).
- **`mem` (default-off):** ephemeral `BTreeMap` tier for tests and
in-process ephemerality. Never a production story.
Both production backends are default-on (batteries-included, the
alkgit feature-model precedent); `mem` is not.
### Dispatch policy
- **Size-threshold routing; default 128 KiB, constructor-tunable**
(midpoint of the measured ~128-256 KiB crossover zone; POC #3 A4).
- **Unknown-length puts buffer in memory up to the threshold; the
buffer's fate has three pinned semantics** (finding A6's option (d)):
1. **Threshold exceeded mid-stream** (non-restartable sources are
normal — network streams can't be replayed): the buffered prefix
flushes *into a fresh stage file*, and the stream continues
appending into it. There is no "restart the stream" requirement
anywhere.
2. **Verification runs over the eventual stored artifact as a
whole**: the memory buffer for the sub-threshold kv commit; the
complete staged bytes for the fs commit. "Two passes over staged
bytes" and "hash-check against the canonical derivation" are both
literally true under this — there is no stitched-input case left
ambiguous (the buffer is *in* the stage file by the time the
second pass runs).
3. **Stream error before commit**: no entry lands and no stage file
remains (the stage-hygiene invariant extends to the buffered
case: buffer discard on the failure path, exactly like stage
discard).
This is finding A6's option (d), decided over the alternatives:
- (a) tier-correction sweeps are moving machinery that re-creates
migration, and are unnecessary if routing never misplaces a blob;
- (b) rejecting unknown-length small puts is hostile to streaming
producers for no safety gain (dedup still holds under (d));
- (c) accepting duplicated content across tiers makes `list()` lie
about the pool (two entries for one digest) and complicates GC's
live-set diffs for no benefit.
Option (d) makes **routing a pure function of content length** — the
property that eliminates the asymmetry *and* the migration question
with it. Batch-scope pinning (POC #1 finding 3) is orthogonal — it
protects in-flight puts, it does not route them — and ships anyway
(ADR-005).
- **No migration between backends.** Content is immutable; its length
never changes; routing is pure. The migration question existed only
to patch the asymmetry and is *resolved by elimination*: there is
nothing to migrate. A future backend needing migration writes a new
ADR.
- **No per-namespace backend configuration.** It would re-weld
namespacing into the physical layer — the exact rudolfs anti-pattern
ADR-005 inverts. Tenancy differences that ever require physical
separation (a different pool per tenant) are a deployment/topology
concern (two stores), not a backend-config concern.
## Consequences
**Positive**
- The dispatch is deterministic and test-invariant: same content ⇒ same
backend, always — including across mixed put paths.
- GC's enumerate-everything substrate (`list()` unions) is contract-
enforced upfront rather than discovered against the rudolfs failure.
- The shipped default matches the measured economics on the git/
workspace regime, re-tunable per deployment without API change.
**Negative**
- Unknown-length puts of large content pay two passes (forced by the
preamble — ADR-002 §Consequences) plus staging I/O; bounded-memory
buffering adds a copy for sub-threshold unknown-length puts (cheap,
per the benchmark).
- sqlite's on-disk format enters the crate's surface durability story
(file compatibility across rusqlite/sqlite versions is upstream
sqlite's guarantee, not ours).
**Neutral**
- A network-backed backend (S3-like) remains possible via the same
trait but ships nothing here; its list/GC obligations are the
acceptance gate.
## References
- `docs/research/poc-trait-dispatch-findings.md` findings 1/2/6
- `docs/research/poc-largeblob-findings.md` findings A1/A4/A5/A6
- `docs/research/phase-0.md` OQ-BL-02
- [backends-and-dispatch.md](../backends-and-dispatch.md);
[store-api.md](../store-api.md)
- ADR-002 (key bytes), ADR-004 (boundary vs manifest layer), ADR-005
(namespace-blindness, pins)