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.
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:
-
There is no
Storetrait.Storeis a concrete struct wrapping anirpc::Client<proto::Request>(an mpsc sender).MemStoreandFsStoreare two actor implementations that both callStore::from_sender(sender). A third store is a third actor, not a trait impl. -
The
Commandenum ispub(generated by theirpc#[rpc_requests]macro, which propagates the sourcepub 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) ispub. A downstream actor can match onCommandand handle every variant using only pub surface. -
The only blockers are four
pub(crate)items that need widening topub:Store::from_sender,Store::ref_from_sender, theApiClienttype alias, and theScopefield. This is a ~4-line, zero-logic upstream PR. Optionally, exposingTempTags/TempTagScopewould enable iroh-native temp-tag tracking, but a self-managed-GC fallback makes this unnecessary. -
No
Commandvariant is BLOCKED. The redb-specific types (EntryState,DataLocation,OutboardLocation, the redbtablesmodule) never cross the pub API boundary — they're confined to thepub(crate)store/fs/metamodule. A SQLite store has its own schema and never touches them. -
GC is reusable, not re-implementable.
run_gc(store/gc.rs:222) ispuband walks the store via the pubStoremethods (tags().list(),tags().list_temp_tags(),blobs().list(),blobs().delete(),export_bao(),sync_db()). A downstream store that correctly implements thoseCommandvariants gets iroh's GC for free. -
~100 lines of bao-tree orchestration must be re-implemented (the
ImportBao/ExportBao/ExportRanges/Observehandlers). This is re-implementation, not reuse — but all of it sits on pubbao_treeAPIs (traverse_ranges_validated,PreOrderMemOutboard::create,BaoTree,ChunkRanges).MemStorealready demonstrates this pattern: it has its ownBaoFileStorage/PartialMemStorage;FsStorehas its own;alknet-blobswould 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-derivemacro (/workspace/irpc/irpc-derive/src/lib.rs:146) emits#vis enum #message_enum_name { ... }, propagating the source enum'spub. Sopub enum Commandis generated, with one variant perRequestvariant, each wrappingWithChannels<RequestStruct, Request>. WithChannelsfields (inner,tx,rx) arepub(irpc/src/lib.rs:1162-1168).- Per-variant
*Msgtype aliases (e.g.ImportBaoMsg,ExportBaoMsg) are generated aspub type(irpc-derive/src/lib.rs:328-335). From<WithChannels<T, Request>> for Commandimpls 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:
- 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.
MemStoreandFsStoreeach own their ownTempTagsinstance already — there is no shared global temp-tag registry. A third store having its own is the existing pattern, not a deviation.- iroh's
run_gc(see below) walks the store via pubStoremethods, so it works regardless of how temp tags are tracked internally — as long asListTempTagsreturns 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 ispub.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/) —TtyHandlealready exposestokio::io::AsyncWritestdin andStream<Item=Bytes>stdout (src/backend.rs:139-182). TheMockStdinSink(src/backend.rs:366-403) is a workingAsyncWrite-over-mpsc::Sender<Bytes>adapter — directly reusable as a pattern for the SFTP write path's chunk bridge intoWriteSession::write_chunk. The kill-on-Drop contract pattern (exit_codefuture, ADR-056) is the same shape as write-session abort-on-drop.alknet-call(crates/alknet-call/) — already usesirpc(workspace depirpc = "0.16") for structured RPC. TheEventEnvelope/length-prefixed JSON framing (src/protocol/wire.rs) is a different protocol than iroh-blobs'Commandactor 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/) — optionaliroh = "0.35"dep. Version mismatch: alknet-core is oniroh 0.35/irpc 0.16; publishediroh-blobs 0.103requiresiroh 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.
Recommended path
-
Build
alknet-blobsas a workspace member (crates/alknet-blobs/), not a fork. It depends oniroh-blobs = "0.103"(published) and implements theCommandactor 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). -
Push the 4-line visibility PR upstream to n0-computer/iroh-blobs:
Store::from_sender/ref_from_sender+ApiClient(or changefrom_senderto takeirpc::Client<proto::Request>directly) +Scopefield. 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." -
Use the self-managed-GC fallback (
TempTag::new(haf, None)+ own SQLite-backed liveness tracking) unless upstream agrees to exposeTempTags. Reuse iroh'srun_gcby correctly implementingListTempTags/ListTags/ListBlobs/DeleteBlobs/ExportBao/SyncDb. This is the same patternMemStore/FsStoreuse (each owns its ownTempTags); alknet-blobs owns its own, just backed by SQLite. -
Re-implement the ~120-160 lines of bao-tree orchestration (
ImportBao/ExportBao/ExportRanges/Observe) against pubbao_treeAPIs. Copy the structure fromMemStore(the simpler reference;src/store/mem.rs), notFsStore(which has the partial-blob-on-filesystem complexity alknet-blobs doesn't need). -
Reuse
alknet-tty's async-IO adapter patterns (AsyncWrite-over-mpsc, Stream-of-Bytes, kill-on-drop) for the SFTP write-path bridge intoWriteSession. -
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
TempTagsupstream or commit to the self-managed-GC fallback (a spec decision; the fallback works, exposingTempTagsis a nicety). - SFTP wiring (POC open question #3 — unchanged; the
alknet-ttypatterns 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](generatespub 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,Fromimpls,*Msgaliases - 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)