POC code: /workspace/alkblobs-trait-poc (17 tests passing, clippy -D warnings + fmt clean; digests validated byte-exact against the real git hash-object CLI, both object formats) Findings (poc-trait-dispatch-findings.md, 8): - lean Backend trait (opaque byte keys, complete list(), put-path pinning) is sufficient; Key typed wrapper lives at the boundary - redb traps: list() key-vs-value destructure (silent until GC catches it — malformed list is as deadly as incomplete), virgin-db TableDoesNotExist on first read, no in-memory db (temp file used) - pinning: Pinned RAII guard + refcount map works; batch-scope pins and the sweep-vs-put race are named Phase 1 requirements (iroh DeleteSet = prior art) - git-blob preamble abstraction validated; external-git oids address pool entries with zero indirection (the OQ-BL-03 resolution, implementation-confirmed) - tagged key format ([algo byte][digest]) makes git-sha256 + git-sha1 coexist; sort-stable for sweep diffs - dual dispatch works as simply as hoped; routing deterministic - namespace tables: protect callback MUST share live state (the snapshot-install bug proves the API lesson: register_liveness_source shape, not install_into) - GC semantics verified: exact deletion counts, abort-callback, delete-then-recover byte-identical re-put phase-0.md: POC #1 marked passed, #2 absorbed (validated), #4 covered in miniature (concurrency half deferred to Phase 1 impl work); OQ-BL-02 base settled; OQ-BL-03 implementation-validated; OQ-BL-05 mechanism settled with findings folded in; plan updated (POC #3 is the last open POC)
13 KiB
POC: trait contract + digest abstraction + dispatch — findings
POC register #1 (absorbing #2), per
docs/research/phase-0.mdOQ-BL-06. Code: standalone crate/workspace/alkblobs-trait-poc(findings land here regardless of where the code lives, per the established convention). Date: 2026-10-02. Status: passed, with findings that reshape OQ-BL-02, OQ-BL-03 (key mechanics), and OQ-BL-05 (GC seam).
What this POC set out to decide
From the phase-0 register (as updated by the GC and hash rounds):
- Backend trait's minimum contract — can a lean
Backendtrait (has/get/put/delete/list) carry everything the store layer needs, with:list()complete by contract (the rudolfs anti-lesson — incomplete listing is why its S3 backend can never GC);- not-yet-referenced pinning on the put path (the TempTag/RAII shape)?
- The digest abstraction's hardest member — git's oid derivation
(
"blob <len>\0" + content, per OQ-BL-03's canonical resolution): does "algorithm as domain-separated derivation" work, cangit-sha256(canonical) andgit-sha1(tolerated) coexist in one flat pool, and does the pinned key encoding hold up? - Dual-backend dispatch — does the kv-small/fs-large appfile dispatch hold together empirically, including the get-fall-through case?
- GC mechanism — namespace reference tables + mark-and-sweep with a protect callback + RAII pinning, exercised to the delete- then-recover semantics (POC #4's core, pulled forward because it needed only pinning, which the trait work required anyway).
Result summary
| Question | Verdict |
|---|---|
Lean Backend trait sufficient? |
Yes — with Key→bytes at the boundary (finding 1) |
list() complete by contract |
Yes — dual-backend list() union passed; redb key-vs-value trap found (finding 2) |
| RAII pinning shape | Yes — Pinned guard with refcount-on-pins; sweep honors + callback can abort (finding 3) |
| git-blob derivation as digest | Validated end to end — byte-exact vs git hash-object (finding 4) |
| sha256 + sha1 coexisting via tagged keys | Yes (finding 5) |
| Size-threshold dual dispatch | Yes — routing, fall-through get, miss path (finding 6) |
| Namespace tables + sweep liveness | Yes with a callback-share lesson (finding 7) |
| Delete-then-recover (re-put) | Yes — re-put lands byte-identical under the same oid |
All 17 tests pass (cargo test), clippy -D warnings and rustfmt
clean. Digest validations run against the real git hash-object CLI
(git 2.43), not just hardcoded vectors.
Findings
Finding 1: the Backend trait should speak opaque bytes, not typed keys — Key→bytes at the boundary
The Backend trait uses &[u8] keys everywhere; Key (the typed
digest wrapper) converts at the store-layer boundary via
Key::as_bytes(). This kept backends dumb (they cannot know what a
digest is) and the digest layer free to evolve. For the real crate this
suggests the seam as built:
Backend= opaque byte keys + byte values (+list,name), exactly what was built in the POC (src/backend.rs).- typed keys live in the store/digest layer only.
The alternative — making Backend generic over K: Key — was not
tried; with only one key type in play, generics would have bought
nothing and the opaque boundary is also the wire/persistence boundary
(backend files/rows must survive digest-layer evolution: a redb row or
fs filename has no schema migration story). Opaque bytes with a
self-describing tag byte (finding 5) is the durable choice.
Finding 2: redb's list() returns what you iterate — read the key, not the value; missing-table startup; and the "list is load-bearing" proof
Three concrete redb behaviors worth recording for the real kv backend:
- Key-vs-value trap (caught by the tests, not by review):
table.range(..)yields(key, value)pairs; iterating with the wrong destructure produced alist()of values (the content bytes!). The failure showed up as a sweep that deleted 2 blobs when 1 was expected (the live-check compared garbage keys, so the pinned blob's "key" was found in the computed live set... of content values). Lesson recorded:list()correctness is only observable through GC — a wrong list() is silent until a sweep deletes the wrong things, i.e. the rudolfs "incomplete list = broken GC" lesson generalize: a malformed list is equally deadly and equally silent. The test caught it only because deletion counts asserted exactly. - Fresh-database read path: a redb database opened but never
written has no tables —
open_tableerrors withTableDoesNotExiston the first read. Read paths must tolerate the missing-table error (treat as empty/absent) or the firsthas()on a fresh store fails. In the POC,open_read_table()mapsTableDoesNotExist → None(andlist() → empty). The real crate will hit the same thing no matter the kv engine; the general shape is "read paths on a virgin store must be no-ops, not errors." - redb 2.x has no in-memory database (iroh-blobs'
previous-versions kv memory store notwithstanding — that is a
different engine). The POC used
open_temp()(temp dir file) for kv tests. For the real crate: an in-process ephemeral kv would be eitherMemBackend(the POC'sBTreeMapone) or sqlite.
Finding 3: pinning (TempTag equivalent) works at the store layer, above backends
The Pinned guard (src/pin.rs): put-path RAII that holds a per-key
refcount in the store's pin map. Sweep treats pin-map keys as part of
the live set. This proves the iroh-blobs TempTag conclusion without
their mechanism (no weak-pointer tag-drop plumbing): a boxed closure
dropped on guard release. What the POC did NOT yet build (and the real
crate must):
- Batch-scope pins (iroh's
Batch::temp_tag): a scoped lifetime that protects a set of in-flight blobs and drops them together. The POC's per-blob guard sufficed for single-put flows; multi-put batches (manifest writes are exactly this) will want the scope form. - The sweep-vs-put race: the POC's pins are a
parking_lot::Mutexmap, and sweep reads it under the lock before listing — for a real concurrent sweep (tokiotask) the race window (a pin added between "list" and "delete") is real; iroh solves it with their DeleteSet transactions (ProtectHandle/protect-cancel). Recorded as an open implementation requirement for Phase 1, not solved by this POC.
Finding 4: git-blob-sha-256 works — byte-exact against the git CLI
Digest::git_sha256(content) (preamble blob <len>\0 + content,
SHA-256) matched git hash-object --stdin --object-format=sha256 for
every test input: empty, short text, binary-with-NULs, 75 KB. Same for
git-sha1 (git 2.43's default) at up to 130 KB. The domain-separated
preamble abstraction (an algorithm defines its own input preprocessing)
was sufficient — no fn(content) → digest bare-func limitation was
felt anywhere. Note the empirical gotcha: --object-format=sha256 is
not a git hash-object flag in git 2.43 — it must go to
git init --object-format=sha256 and the hash-object then runs inside
that repo (test harness detail, but cost an iteration to learn).
Corollary validated (store_addresses_git_oids_directly test): an
oid produced by external git addresses the same pool entry our store
puts — Digest::from_git_oid_hex(...) round-trips into the store, the
put dedups against it, and the get returns the content. This is the
OQ-BL-03 "no mapping layer" resolution confirmed at the implementation
level, not just on paper.
Finding 5: tagged key format — one byte of tag + algorithm discriminant
The POC's key encoding: [tag=0x01][algorithm byte][digest bytes],
total 1+1+len. Notes for the real design:
- The single tag byte is probably needless in the real crate (there is
currently exactly one key kind); the algorithm byte is the
load-bearing part — it makes
git-sha256andgit-sha1keys disjoint in the same flat pool with zero mapping. Testkey_format_is_tagged_and_length_checkedvalidated length-checking rejects malformed keys (truncated, unknown algorithm). - Alternative considered but not built: variable-length length-prefixed keys. With only two algorithms, the enum-tagged fixed-length form is simpler and total-order stable (both key kinds sort cleanly in a BTreeMap — required for the sweep's live-set diffs).
- This encoding is inside the one-way door OQ-BL-03 flags; whatever the real crate picks, algorithm identity must be part of the key itself (a per-store single-algorithm config would not have survived the finding-4 corollary: external-git oids and pooled workspaces live in one table).
Finding 6: dual-dispatch works as simply as hoped
DualDispatch (kv small / fs large, size threshold): routing by
value.len(), fall-through get (small miss → large), the
wrong-backend-miss case, and per-backend backend_of(). The appfile
dispatch is genuinely a thin layer over the trait — no surprises. One
structural note: since put routes by size, a re-put of the same
content will always route to the same backend (same length), so
dispatch is deterministic for a given digest; the "blob migrates
between backends" question (phase-0 OQ-BL-02) remains open but is
independent of the trait shape — nothing in the built contract
prevents a migrate-between-backends sweep later.
The list() union across backends (dedup on key) passed with both
backends disjoint — completing the contract requirement that GC's
sweep sees the whole pool.
Finding 7: namespace tables + protect callback — the snapshot bug that proves the seam
The NamespaceTables (src/namespace.rs): reference sets keyed by
namespace name, installed into a store as a protect callback. The first
implementation cloned the tables into the callback (a snapshot) —
registering roots after install was invisible to GC, and liveness
broke. The fix (share the Arc<RwLock<...>> handle, not a snapshot) is
trivial but the failure mode is the important finding: the protect
callback must be live-shared, and consumers will trip on this if the
real crate's API implies "install" copies state. API-shape lesson
for Phase 1: call the seam register_liveness_source (or have
NamespaceTables hold the store reference and register itself)
rather than install_into — the "install" verb implied copy
semantics that broke exactly like the test showed.
With shared handles, the namespace tests pass cleanly: dropping a namespace leaves the other namespace's blobs alive, the shared blob survives both drops, and delete-then-recover re-puts byte-identically — the OQ-BL-05 mechanics, validated in miniature.
Finding 8: GC semantics — sweep counts are exact, and liveness composition works
The sweep composes liveness from three sources (pins, protect
callbacks, and the "nothing live" case) and the tests verify exact
deletion counts. The protect_callback_can_abort_sweep test validates
the iroh-blobs ProtectOutcome::Abort conclusion — a protection-
source failure aborts the run (typed error), nothing deleted.
What the POC did not test (honest scope limits, all Phase 1 work): concurrent sweep-vs-put (see finding 3), incremental/interval scheduling (the POC sweeps are explicit calls), and cross-hash registration (deferred by OQ-BL-03 pending any consumer asking).
Consequences for the phase-0 OQ register
- OQ-BL-02 (dispatch): the lean
Backendtrait contract with completelist()+ opaque keys is validated as the base. Remaining: migration-between-backends policy and (maybe) per-namespace backend config — both above-trait concerns, no further POC gate. - OQ-BL-03 (hash): the canonical git-family resolution is now implementation-validated, including external-git oid interop. Residual: only key-encoding mechanics for Phase 1 (finding 5 notes).
- OQ-BL-05 (pooling/GC): mark-and-sweep-with-protect-callback validated as the mechanism; namespace-as-root-tables works. Phase 1 ADR inputs now include: the callback-must-be-live-shared lesson (finding 7), the sweep-vs-put race requirement from finding 3, and the pack tension (unchanged — rides POC #3's reading).
- POC #4's GC worked example is effectively done in miniature by this POC (namespace tables + sweep honored + recover semantics). The register should mark #4 as covered by #1 with the concurrency half still open as Phase 1 implementation work (not a POC gate).
POC quality notes
- 17 tests total (5 lib-level, 12 integration), all passing; digest
vectors cross-checked against the real
gitCLI (2.43.0), not just hardcoded — though the hardcoded sha256 vectors were themselves initially wrong (transcription) and the CLI check is what actually proved the implementation; a good reminder that known- answer tests are only as trustworthy as their source of truth. - clippy
-D warningsclean,rustfmtclean. - Deliberate scope limits: no async I/O in backends (sync under
block_in_placefor the kv; fs is sync std), no streaming put/get (the POC'sVec<u8>API is the contract question, not the performance question — streaming shape rides POC #3's findings), no persistence tests across process restarts (redb/fs both durable engines; nothing store-layer-specific to prove).