Files
alkblobs/docs/research/poc-trait-dispatch-findings.md
T
glm-5.3-flash 493516424a docs(research): POC #1 passed — trait contract + digest + dispatch
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)
2026-10-01 06:51:32 +00:00

13 KiB

POC: trait contract + digest abstraction + dispatch — findings

POC register #1 (absorbing #2), per docs/research/phase-0.md OQ-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):

  1. Backend trait's minimum contract — can a lean Backend trait (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)?
  2. 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, can git-sha256 (canonical) and git-sha1 (tolerated) coexist in one flat pool, and does the pinned key encoding hold up?
  3. Dual-backend dispatch — does the kv-small/fs-large appfile dispatch hold together empirically, including the get-fall-through case?
  4. 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:

  1. Key-vs-value trap (caught by the tests, not by review): table.range(..) yields (key, value) pairs; iterating with the wrong destructure produced a list() 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.
  2. Fresh-database read path: a redb database opened but never written has no tables — open_table errors with TableDoesNotExist on the first read. Read paths must tolerate the missing-table error (treat as empty/absent) or the first has() on a fresh store fails. In the POC, open_read_table() maps TableDoesNotExist → None (and list() → 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."
  3. 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 either MemBackend (the POC's BTreeMap one) 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::Mutex map, and sweep reads it under the lock before listing — for a real concurrent sweep (tokio task) 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-sha256 and git-sha1 keys disjoint in the same flat pool with zero mapping. Test key_format_is_tagged_and_length_checked validated 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 Backend trait contract with complete list() + 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 git CLI (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 warnings clean, rustfmt clean.
  • Deliberate scope limits: no async I/O in backends (sync under block_in_place for the kv; fs is sync std), no streaming put/get (the POC's Vec<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).