Files
alknet/docs/research/alknet-filesystem/alknet-blobs-external-store-probe.md
T
glm-5.2 e024b50b1e docs(research): iroh/irpc version update findings + alknet-blobs external-store probe
Traces the version gap deferred by the alknet-blobs probe: verifies latest
stable iroh 1.0.2 / iroh-blobs 0.103 / irpc 0.17 against crates.io, rules out
the 'iroh 0.35 is a stale pin' hypothesis (it's load-bearing — 3 APIs broke),
identifies the real cheap win (irpc 0.16 is a dead dep in alknet-call, never
imported), and traces the iroh 1.0.x Preset trait so the migration is a
concrete 3-edit diff rather than a TODO.

Includes the sibling probe doc (alknet-blobs-external-store-probe.md) which
the findings cross-references.
2026-07-08 13:32:57 +00:00

23 KiB

alknet-blobs: External SQLite Store — Command-Surface Probe

Status: Probe complete. The external-crate path (no fork) is feasible. The required upstream change is a ~4-line visibility-widening PR with zero logic changes. Date: 2026-07-08 Scope: Resolves open question #1 from poc-summary.md ("FsStore (redb + filesystem) vs SQLite — the two-database question"). Determines whether a sibling alknet-blobs crate can implement a SQLite-backed blob store that speaks iroh-blobs' Command actor protocol without forking iroh-blobs, by checking whether the full public API surface is sufficient.


TL;DR

The POC summary framed this as "fork iroh-blobs to use SQLite instead of redb — a big maintenance commitment." That framing is a false dichotomy. There is a third path the summary didn't consider: implement a SqliteStore actor in an external crate that speaks iroh-blobs' public Command protocol, with zero changes to iroh-blobs core plus a trivial upstream visibility PR.

The probe traced every Command variant against the published iroh-blobs 0.103.0 source. Findings:

  1. There is no Store trait. Store is a concrete struct wrapping an irpc::Client<proto::Request> (an mpsc sender). MemStore and FsStore are two actor implementations that both call Store::from_sender(sender). A third store is a third actor, not a trait impl.

  2. The Command enum is pub (generated by the irpc #[rpc_requests] macro, which propagates the source pub enum Request's visibility). Every request struct, every channel payload type, and every helper type that crosses the actor boundary (Hash, HashAndFormat, Tag, TagInfo, TempTag, Bitfield, BlobStatus, AddProgressItem, ExportProgressItem, EncodedItem, BaoContentItem, Leaf, Scope) is pub. A downstream actor can match on Command and handle every variant using only pub surface.

  3. The only blockers are four pub(crate) items that need widening to pub: Store::from_sender, Store::ref_from_sender, the ApiClient type alias, and the Scope field. This is a ~4-line, zero-logic upstream PR. Optionally, exposing TempTags/TempTagScope would enable iroh-native temp-tag tracking, but a self-managed-GC fallback makes this unnecessary.

  4. No Command variant is BLOCKED. The redb-specific types (EntryState, DataLocation, OutboardLocation, the redb tables module) never cross the pub API boundary — they're confined to the pub(crate) store/fs/meta module. A SQLite store has its own schema and never touches them.

  5. GC is reusable, not re-implementable. run_gc (store/gc.rs:222) is pub and walks the store via the pub Store methods (tags().list(), tags().list_temp_tags(), blobs().list(), blobs().delete(), export_bao(), sync_db()). A downstream store that correctly implements those Command variants gets iroh's GC for free.

  6. ~100 lines of bao-tree orchestration must be re-implemented (the ImportBao/ExportBao/ExportRanges/Observe handlers). This is re-implementation, not reuse — but all of it sits on pub bao_tree APIs (traverse_ranges_validated, PreOrderMemOutboard::create, BaoTree, ChunkRanges). MemStore already demonstrates this pattern: it has its own BaoFileStorage/PartialMemStorage; FsStore has its own; alknet-blobs would have its own. One store impl = one copy of the bao glue.

Verdict: Build alknet-blobs as an external crate. Do not fork. Push the 4-line visibility PR upstream. If upstream rejects it, fork — but the fork is ~4 lines different from the external crate, not the "big maintenance commitment" the summary feared.


Background: Why the POC summary's framing was wrong

The POC summary (poc-summary.md §"Open Unknowns #1") said:

The alternative — forking iroh-blobs to use SQLite instead of redb — is a big maintenance commitment. The POC's write_chunks table proves SQLite can handle chunk-sized inline BLOBs at iroh's granularity, so the swap is mechanically possible. But it should only be done if the two-database coexistence proves problematic, not for aesthetics.

This assumed the choice was binary: (a) run redb + SQLite side-by-side, or (b) fork iroh-blobs to swap redb for SQLite. Reading the actual code reveals a third option: an external crate that implements the Command actor protocol against SQLite, with iroh-blobs untouched.

The key architectural fact the summary missed: iroh-blobs has no Store trait. Store (src/api.rs:213) is a concrete struct holding an irpc::Client<proto::Request> — a typed mpsc sender. MemStore (src/store/mem.rs) and FsStore (src/store/fs.rs) are not trait implementations; they are actor tasks that hold an mpsc::Receiver<Command>, handle commands, and produce a Store via Store::from_sender(sender). The seam is the Command enum, not a trait. A third store is a third actor.

This re-frames the risk calculus the summary was conservative about. The "two-database" concern (two WALs, two fsync paths, two crash-recovery stories, silent-corruption risk on the seam) is avoided not by "a big maintenance commitment" but by writing a sibling crate that owns its own single SQLite file — exactly the architecture the POC's path-tree + write-chunks + honker work already validated.


The Command protocol is pub

iroh-blobs 0.103.0, src/api/proto.rs:

  • pub enum Request (line 92), decorated with #[rpc_requests(message = Command, alias = "Msg", rpc_feature = "rpc")] (line 90).
  • The irpc-derive macro (/workspace/irpc/irpc-derive/src/lib.rs:146) emits #vis enum #message_enum_name { ... }, propagating the source enum's pub. So pub enum Command is generated, with one variant per Request variant, each wrapping WithChannels<RequestStruct, Request>.
  • WithChannels fields (inner, tx, rx) are pub (irpc/src/lib.rs:1162-1168).
  • Per-variant *Msg type aliases (e.g. ImportBaoMsg, ExportBaoMsg) are generated as pub type (irpc-derive/src/lib.rs:328-335).
  • From<WithChannels<T, Request>> for Command impls are generated and pub (irpc-derive/src/lib.rs:279-281).

Every request struct (ImportBaoRequest, ExportBaoRequest, BlobStatusRequest, SetTagRequest, ListTagsRequest, …) is pub with pub fields. Every channel payload type (Hash, HashAndFormat, Tag, TagInfo, TempTag, Bitfield, BlobStatus, AddProgressItem, ExportProgressItem, EncodedItem, BaoContentItem, Leaf, Scope, BatchResponse, ImportByteStreamUpdate, ExportRangesItem) is pub.

The Command enum is the actor's inbox type. A downstream crate that spawns a tokio task holding mpsc::Receiver<Command> can match on every variant and produce a Store via Store::from_sender(sender) — once that constructor is pub.


The four items that must be made pub

Mandatory (the ~4-line PR)

Item File:line Current Needed for
ApiClient type alias src/api.rs:33 pub(crate) type ApiClient = irpc::Client<proto::Request>; The external store wrapper holds an ApiClient field (as MemStore/FsStore do). Without it the from_sender signature can't be expressed. Alternative: make from_sender take irpc::Client<proto::Request> directly and keep ApiClient private — fewer surface changes.
Store::from_sender src/api.rs:299 pub(crate) fn from_sender(client: ApiClient) -> Self External actor constructs a Store from its mpsc::Sender. This is the single load-bearing change.
Store::ref_from_sender src/api.rs:303 pub(crate) fn ref_from_sender(client: &ApiClient) -> &Self Deref/AsRef<Store> for the external store wrapper (matches MemStore/FsStore pattern).
Scope field src/api/proto.rs:693 pub struct Scope(pub(crate) u64); Batch must construct a Scope to send back on its oneshot::Sender<Scope>; import variants receive a Scope they must destructure. Only Scope::GLOBAL is constructible today. Minimum: make the field pub (it's a u64), or add Scope::new(u64)/Scope::into_inner().

Optional (enables iroh-native temp-tag tracking, NOT required)

Item File:line Current Why
util::temp_tag module src/util.rs:4 pub(crate) mod temp_tag; TempTags, TempTagScope, TagDrop, TagCounter are sealed. Without this, imports can only emit untracked temp tags via TempTag::new(haf, None) (pub).
TempTags struct + create/create_scope/end_scope src/util/temp_tag.rs:173 pub(crate) struct TempTags (methods individually pub but in sealed module) Refcounted, GC-aware temp tags for imports and CreateTempTag.
TempTagScope + TagDrop src/util/temp_tag.rs:211,60 sealed Batch scope bookkeeping (scope.on_drop(&msg)).
Bitfield::new_unchecked src/api/proto/bitfield.rs:74 pub(crate) ImportBao fs handler uses it. Bitfield::new (pub) is a fine substitute. Not required.

The fallback if TempTags stays sealed: use TempTag::new(haf, None) (pub today) for imports. The tag is valid on the wire and protects the blob from GC for the tag's lifetime, but on_drop is None so the refcount never decrements — effectively a leak until process exit or explicit DeleteBlobs. This is acceptable because:

  1. alknet-blobs owns its SQLite schema; the path tree (from the POC) already tracks which hashes are live via path rows + tags. Self-managed GC against SQLite is the natural design.
  2. MemStore and FsStore each own their own TempTags instance already — there is no shared global temp-tag registry. A third store having its own is the existing pattern, not a deviation.
  3. iroh's run_gc (see below) walks the store via pub Store methods, so it works regardless of how temp tags are tracked internally — as long as ListTempTags returns the right set.

Under this fallback, the only strictly-required upstream change is the 4 lines above.


GC is reusable (a correction to the probe's first pass)

The first pass of this probe incorrectly concluded that run_gc was pub(crate) and that a downstream store would have to write its own GC loop. That is wrong:

  • pub async fn run_gc(store: Store, config: GcConfig) — src/store/gc.rs:222. It is pub.
  • gc_mark_task (gc.rs:34) walks live hashes via: store.tags().list(), store.tags().list_temp_tags(), store.export_bao(hash, ChunkRanges::all()).hashes().
  • gc_sweep_task (gc.rs:83) deletes dead blobs via: store.blobs().list().stream(), store.blobs().delete(batch), store.sync_db().

Every one of those is a pub method on Store backed by a Command variant (ListTags, ListTempTags, ExportBao, ListBlobs, DeleteBlobs, SyncDb). A downstream alknet-blobs store that correctly implements those six variants gets iroh's mark-sweep GC for free — it just calls run_gc(store.clone(), gc_config) in a spawned task, exactly as MemStore::new_with_opts (mem.rs:143) and FsStore::load_with_opts (fs.rs:1427) do.

The implication: even under the "self-managed temp tags" fallback, GC is not a thing alknet-blobs has to build. It's a thing it gets by implementing the Command variants it would implement anyway.


Per-variant verdict

All ~24 Command variants traced. Channel payload types are pub for every variant. EntryState/DataLocation/OutboardLocation (redb value types) never appear in pub channel types — confirmed by reading every #[rpc(tx=..., rx=...)] attribute in proto.rs. They are confined to the pub(crate) redb meta actor.

Variant Verdict Notes
ListBlobs PUB-OK Stream Hash from your DB.
Batch NEEDS-PUB Must construct Scope for the oneshot reply — needs Scope field pub. The TempTagScope::on_drop bookkeeping is sealed; implement your own ~30-line scope tracker or skip batch-scoped cleanup (acceptable for a SQLite store that owns its own GC).
DeleteBlobs PUB-OK Delete in your DB; protected-set is your own.
ImportBao PUB-OK (logic-heavy) Re-implement ~60-100 lines of bao-tree chunk assembly (BaoContentItem::{Parent,Leaf} → outboard + data + Bitfield). Uses pub bao_tree APIs. See "Re-implementation cost" below.
ExportBao PUB-OK Call pub traverse_ranges_validated with a 6-line Sender impl you write yourself (the iroh BaoTreeSender is sealed but trivial).
ExportRanges PUB-OK ~40 lines of range iteration + Bitfield superset checks + Leaf emission. Pub Bitfield/Leaf/RangeSet2.
Observe PUB-OK Implement your own watch::Sender<Bitfield> → mpsc::Sender<Bitfield> forwarder (~15 lines). Pub Bitfield.
BlobStatus PUB-OK Return NotFound/Partial/Complete from your DB.
ImportBytes NEEDS-PUB Scope field needed (request carries scope). Emit AddProgressItem::Done(TempTag) via TempTag::new(haf, None) (pub, untracked) or TempTags::create (sealed).
ImportByteStream NEEDS-PUB Same as ImportBytes.
ImportPath NEEDS-PUB Same as ImportBytes.
ExportPath PUB-OK Write your data to target path; emit ExportProgressItem.
ListTags PUB-OK Query your tag table.
SetTag PUB-OK
DeleteTags PUB-OK
RenameTag PUB-OK
CreateTag PUB-OK Tag::auto is pub (store/util.rs:72).
ListTempTags PUB-OK Return your own temp-tag list. Required for run_gc to work — it calls this.
CreateTempTag NEEDS-PUB Scope field. TempTag::new(value, None) (pub) for untracked, or TempTags::create (sealed) for tracked.
SyncDb PUB-OK Flush your SQLite.
WaitIdle PUB-OK Track your own task set.
Shutdown PUB-OK
ClearProtected PUB-OK Your own protected set.

NEEDS-PUB count: 6 variants (Batch, ImportBytes, ImportByteStream, ImportPath, CreateTempTag) — all blocked only by the Scope field. One change (Scope(pub u64)) unblocks all six.

BLOCKED count: 0.


Re-implementation cost (the real maintenance burden)

The visibility PR is trivial. The actual work — and the honest maintenance cost — is re-implementing the bao-tree orchestration that the ImportBao/ExportBao/ExportRanges/Observe handlers do. These handlers don't wire pub pieces together; they orchestrate pub bao_tree APIs. A downstream store writes its own copy.

Handler Lines to re-implement Uses (all pub)
ImportBao ~60-100 BaoContentItem, BaoTree, ChunkRanges, Bitfield::new, PreOrderMemOutboard (for compute-on-import path), Leaf. Re-derive the "size-1 leaf validates size" check (is_validated, sealed in pub(crate) bitfield module — ~3 lines).
ExportBao ~6 traverse_ranges_validated + a Sender for &mut mpsc::Sender<EncodedItem> impl (the iroh BaoTreeSender is a 6-line ref_cast wrapper at store/util.rs:400-415).
ExportRanges ~40 Bitfield superset check, RangeSet2 iteration, Leaf emission.
Observe ~15 tokio::sync::watch → mpsc::Sender<Bitfield> forwarder.

Total: ~120-160 lines of bao-tree glue to re-implement in alknet-blobs. This is the same glue MemStore has its own copy of (src/store/mem.rs, the BaoFileStorage/PartialMemStorage/import_bao/export_bao functions) and FsStore has its own copy of (src/store/fs/bao_file.rs, 749 lines). The pattern is "one store impl = one copy of the bao glue." alknet-blobs is a third store impl; it gets a third copy. The copy is smaller than FsStore's because a SQLite store doesn't need the partial-blob-on-filesystem lifecycle (the POC's write_chunks table handles partial state in SQLite).

This is re-implementation, not reuse — but it is re-implementation against stable, pub bao_tree APIs, not against iroh internals. The maintenance cost is: track bao_tree version bumps. That is a much smaller surface than "track all of iroh-blobs."


alknet-crate reuse for the write/read path

The four existing alknet crates (post-POC) reduce some of the work the POC flagged as unknown:

  • alknet-tty (crates/alknet-tty/) — TtyHandle already exposes tokio::io::AsyncWrite stdin and Stream<Item=Bytes> stdout (src/backend.rs:139-182). The MockStdinSink (src/backend.rs:366-403) is a working AsyncWrite-over-mpsc::Sender<Bytes> adapter — directly reusable as a pattern for the SFTP write path's chunk bridge into WriteSession::write_chunk. The kill-on-Drop contract pattern (exit_code future, ADR-056) is the same shape as write-session abort-on-drop.
  • alknet-call (crates/alknet-call/) — already uses irpc (workspace dep irpc = "0.16") for structured RPC. The EventEnvelope/length-prefixed JSON framing (src/protocol/wire.rs) is a different protocol than iroh-blobs' Command actor protocol, but the irpc usage pattern (matching on request variants, channel-based replies) is the same shape alknet-blobs' actor will use.
  • alknet-core (crates/alknet-core/) — optional iroh = "0.35" dep. Version mismatch: alknet-core is on iroh 0.35/irpc 0.16; published iroh-blobs 0.103 requires iroh 1.0/irpc 0.17. alknet-blobs will pull iroh-blobs 0.103's irpc 0.17 transitively. This needs resolution at spec time — either alknet-core bumps to iroh 1.0, or alknet-blobs pins an iroh-blobs version compatible with irpc 0.16. Open question for the spec.

None of the existing alknet crates implement the blob store's bao-tree orchestration — that is genuinely new work. But the async-IO adapter patterns (AsyncWrite-over-mpsc, Stream-of-Bytes, kill-on-drop futures) are already proven in alknet-tty and reduce the write-path wiring risk.


Versioning reality

Three iroh-blobs generations are in play:

Source Version iroh irpc Notes
/workspace/iroh-blobs (local checkout) 0.100.0 0.98 0.14 Pre-n0-rebrand. Used for the original architecture reading.
crates.io published (cached) 0.103.0 1.0 0.17 What the POC used; what alknet-blobs would target.
alknet-core dep — 0.35 0.16 Two majors behind iroh-blobs 0.103.

This probe was run against 0.103.0 (the published version). The visibility findings (Store::from_sender pub(crate), Scope(pub(crate) u64), Command pub via macro, no Store trait, EntryState confined to pub(crate) meta) all hold on 0.103.0. The 4-line upstream PR would target the 0.103+ line.

The alknet-core iroh 0.35 vs iroh-blobs iroh 1.0 gap is a spec-time decision, not a probe blocker. It affects which iroh QUIC API the distributed-sync layer (POC 3) binds against, but not the local Command-actor surface this probe examined.


  1. Build alknet-blobs as a workspace member (crates/alknet-blobs/), not a fork. It depends on iroh-blobs = "0.103" (published) and implements the Command actor against a single SQLite file (schema: the POC's path-tree tables + write_chunks + a blob-metadata table replacing redb's 4 tables + honker tables, all in one .db).

  2. Push the 4-line visibility PR upstream to n0-computer/iroh-blobs: Store::from_sender/ref_from_sender + ApiClient (or change from_sender to take irpc::Client<proto::Request> directly) + Scope field. Zero logic changes. If merged, alknet-blobs is a pure downstream crate. If rejected, alknet-blobs forks those 4 lines — the fork is 4 lines different from the external crate, which is the opposite of "big maintenance commitment."

  3. Use the self-managed-GC fallback (TempTag::new(haf, None) + own SQLite-backed liveness tracking) unless upstream agrees to expose TempTags. Reuse iroh's run_gc by correctly implementing ListTempTags/ListTags/ListBlobs/DeleteBlobs/ExportBao/SyncDb. This is the same pattern MemStore/FsStore use (each owns its own TempTags); alknet-blobs owns its own, just backed by SQLite.

  4. Re-implement the ~120-160 lines of bao-tree orchestration (ImportBao/ExportBao/ExportRanges/Observe) against pub bao_tree APIs. Copy the structure from MemStore (the simpler reference; src/store/mem.rs), not FsStore (which has the partial-blob-on-filesystem complexity alknet-blobs doesn't need).

  5. Reuse alknet-tty's async-IO adapter patterns (AsyncWrite-over-mpsc, Stream-of-Bytes, kill-on-drop) for the SFTP write-path bridge into WriteSession.

  6. Resolve the iroh 0.35 vs 1.0 version gap at spec time. This is an open question for the architecture doc, not a probe finding.


What this closes and what remains

Closes: POC open question #1 ("FsStore (redb + filesystem) vs SQLite — the two-database question"). The answer is: neither. Build a third store (alknet-blobs) as an external crate with its own single SQLite file. No fork, no two-database coexistence. The upstream cost is a 4-line visibility PR.

Does not close (deferred to spec):

  • The iroh 0.35 vs iroh 1.0 version gap (alknet-core dependency alignment).
  • The exact SQLite schema for the blob-metadata tables (the redb 4-table shape is a reference; a SQLite store may unify inline_data/inline_outboard into BLOB columns or split differently — schema design is a spec task).
  • Whether to expose TempTags upstream or commit to the self-managed-GC fallback (a spec decision; the fallback works, exposing TempTags is a nicety).
  • SFTP wiring (POC open question #3 — unchanged; the alknet-tty patterns help but don't close it).
  • The distributed sync layer's binding to iroh 1.0 vs 0.35 (POC 3 used iroh 1.0; alknet-core is on 0.35).

References

  • Published source probed: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-blobs-0.103.0/
    • src/api.rs:33,213,299,303 — ApiClient, Store, from_sender/ref_from_sender (pub(crate))
    • src/api/proto.rs:693 — Scope(pub(crate) u64)
    • src/api/proto.rs:90-139 — pub enum Request + #[rpc_requests] (generates pub enum Command)
    • src/store/mem.rs — reference actor (1127 lines, zero redb)
    • src/store/fs/meta.rs, src/store/fs/meta/tables.rs — redb confined here (~1100 lines)
    • src/store/fs/entry_state.rs — EntryState/DataLocation/OutboardLocation (pub(crate), never in pub channel types)
    • src/store/gc.rs:222 — pub async fn run_gc (reusable, walks store via pub methods)
    • src/util/temp_tag.rs:85 — TempTag::new (pub, untracked fallback); :173 — TempTags (pub(crate))
  • irpc derive macro: /workspace/irpc/irpc-derive/src/lib.rs:146,279,328 — visibility propagation, From impls, *Msg aliases
  • POC summary: docs/research/alknet-filesystem/poc-summary.md (this doc's parent context)
  • alknet crates surveyed: crates/alknet-tty/src/backend.rs (AsyncWrite/Stream/kill-on-drop patterns), crates/alknet-call/src/protocol/wire.rs (irpc usage), crates/alknet-core/Cargo.toml (iroh 0.35 version gap)