Status: implemented (format + index + reconstruction + persistence + wasm),
plus a decided-but-unimplemented garbage-collection architecture (2026-06-30).
The packing store (stores/packing, crate s5_store_packing) bundles many small
content-addressed blobs into large packs before they reach a slow or expensive
backend, and serves reads back out of those packs.
Related: architecture-directions.md
(where packing/tiered backends sit in the store stack),
registry-durability.md (the HEAD enumeration that
recovery — and GC — root on), snapshot-publication.md
(snapshot trees = the reachability graph), the compression pipeline
(why blobs are small in the first place).
Sia stores data in 4 MiB sectors, erasure-coded into slabs. A backup at S5's default ~64 KiB chunking produces millions of tiny blobs; writing each as its own erasure-coded object wastes a whole slab per blob (~40 s, ~0.002 MiB/s) and is economically absurd. Packing bundles many small blobs into one large (40–256 MiB) content-addressed pack so each upload fills real slabs — ~1 MiB/s and bounded memory (staged blobs spill to disk, not RAM).
The same mechanism is useful anywhere small-blob-over-slow-backend is the shape: it's a generic decorator, not Sia-specific.
PackingStore<B> is a BlobsRead + BlobsWrite + BlobsDelete provider keyed by
the blob's own BLAKE3 hash — not a path-based Store. There are no
arbitrary string paths: a blob is addressed by its hash, the in-pack index key is
that hash's 12-byte prefix (hash[..12]), uploads hash their own input
(blob_upload_bytes = Hash::new, self-verifying), and blob_sync lives on
BlobsWrite so the vault flushes through the same dyn Blobs handle it reads/writes.
Why not a Store? A Store is a path→bytes map; packing is content-addressed
by construction. Modelling it as a Store forced a synthetic path namespace and
hid that the address is the hash. s5_core intentionally hides BlobStore
behind the dyn BlobsRead/BlobsReadWrite traits; the packing store is the payoff
— it plugs into the vault as Arc<dyn Blobs> directly, with no path-store view.
PackingStore<B> holds one generic backend and two Arc<dyn Store> locals:
| field | type | role |
|---|---|---|
blobs: B |
B: BlobsReadWrite |
durable, content-addressed backend for whole pack bodies (e.g. a BlobStore over indexd/Sia). The hot read path; stays generic for monomorphization. |
index_cache |
Arc<dyn Store> |
local cache of the pack index — per-pack manifests, the bulk snapshot, and todo markers. Rebuildable from the bodies; never load-bearing. |
staging |
Arc<dyn Store> |
local scratch: blobs live under hex(hash) until a pack fills, then stream to blobs and are deleted. |
Why only B is generic (PackingStore<B>, not <B, M, S>): the index cache
and staging are always plain local stores, so the two extra type params were pure
verbosity. Arc<dyn Store> costs a vtable hop that is noise next to their disk
I/O. B stays generic because it's the perf-sensitive, content-addressed,
possibly-remote backend.
Why staging is a Store, not a blob trait (it is content-addressed in
spirit — keyed by hex(hash)):
- The caller already knows the key. Packing computes
Hash::new(&bytes)before staging (it needs the key for the dedup check + the pending map), thenput_bytes(hex(hash), bytes). The content-addressed write API inverts control —blob_upload_bytesre-derives the hash — so routing staging through it means a redundant BLAKE3 pass over every staged blob, on the hottest path. The path-store API stores under the key we already hold. - It belongs with
index_cache, notblobs. The three deps split into one durable content-addressed backend (blobs) and two local keyed scratch/cache stores.index_cachemust be a pathStore(it holds non-hash keys: the snapshot, the todo sibling). Staging is its sibling; making it a blob trait splits the "local keyed byte-slots" concept for no gain. - Staging is ephemeral (write → read-once → delete); it wants the minimal keyed-slot API, not the blob traits' self-hashing/verification machinery.
A pack body is header ++ data. The header is prepended, not a trailer
(stores/packing/src/manifest.rs):
byte 0 MAGIC = "S5.pro" 0x5b 'P' = [53 35 2e 70 72 6f 5b 50] 8 B
byte 8 reserved (zero, 3 B) | VERSION = 1 (byte 11) 4 B
byte 12 blob_count: u32 LE 4 B
byte 16 member[i] = hash_prefix[12] | offset: u32 LE (sorted by prefix) N×16 B
then end_offset: u32 LE (= total pack size) 4 B
then zero-pad to the next 16 KiB boundary (DATA_ALIGN)
aligned data = blob0 ++ blob1 ++ … ++ blobN-1 (same order as members)
- Offsets are absolute → a reader does a direct ranged GET
[off[i] .. off[i+1]), no cumulative sum; the last member runs toend_offset, which is also the pack size, so the header is fully self-describing. pack_hashis NOT in the header (it would be circular — the hash is over the whole body); it's recovered as the body's content address.
Why prepended, not a trailer. A reader resolves any member with pure ranged
GETs from offset 0 — read the 16 B fixed prefix for the count, then the
16 + N·16 + 4 B header region, then the member's slice. No pack-size lookup,
no tail seek, never a full-pack download. That's exactly what a cold device or a
share-recipient (holding only a pack_hash) needs. A trailer's only advantage is
append-friendliness, which doesn't apply: packs are written once (immutable) and
every member is known before the flush, so the header is computed up front.
Why the data start is 16 KiB-aligned but blobs are not individually padded.
Aligning the data start lets one generous front GET grab the whole header for a
typical pack. Per-blob alignment would only waste space: Sia's 4 MiB sectors
dominate read granularity, so sub-sector alignment buys nothing on the durable
path. u32 offsets cap a pack at 4 GiB — fine for 256 MiB targets; asserted at flush.
Lookup answers "which pack holds blob H, and at what offset/length." The index
(Index in lib.rs) is four structures (three lookup tiers + the discovery set):
| tier | structure | holds |
|---|---|---|
bulk |
boomphf MPHF key → slot + pack_idx: Vec<u32> |
settled packs, folded into the perfect hash |
recent |
HashMap<Key, u32> |
packs flushed since the last rebuild (the MPHF is static) |
pending |
HashMap<Key, PendingBlob> |
blobs staged, not yet packed |
todo |
HashSet<[u8;32]> |
pack hashes known but not yet read (see §6) |
locate tries the bulk candidate, then the recent map; pending answers reads of
not-yet-packed blobs.
The naive index is a HashMap<prefix → pack>, ~50 B/key → ~800 MB at 16M blobs
(a 1 TiB / 64 KiB backup) — too much for a phone. A benchmark
(recorded in the storage-rewrite handoff) compared, at 16M blobs / 8889 packs:
| candidate | hit | miss | bits/key | portable? |
|---|---|---|---|---|
| per-pack BinaryFuse8 + binary search | 57 µs | 132 µs | 9 | ✅ |
| per-pack BinaryFuse16 + binary search | 48 µs | 97 µs | 18 | ✅ |
| ptrhash | 44 ns | 49 ns | 2.99 | ❌ (gxhash needs HW AES) |
| ph (FMPH) | 307 ns | 288 ns | 2.80 | ✅ |
| boomphf (BBHash) | 88 ns | 97 ns | 3.00 | ✅ |
- Per-pack probabilistic filters don't scale. They're O(packs) per lookup, and the false-positive rate compounds (bf8's 0.4% × 8889 packs ≈ 36 wasted binary searches per miss). A full 16M-blob restore would spend ~13 min in index lookups with filters vs ~0.5 s with an MPHF. A global MPHF is O(1) in pack count.
ptrhashrejected for production: itsgxhashdependencycompile_error!s without hardware AES, so it's un-buildable on phones / non-AES targets without globalRUSTFLAGS. (A target-gatedptrhashcargo feature is a noted TODO; the 44 ns vs 88 ns gap is invisible end-to-end next to the blob's network read.)ph(FMPH) rejected: smallest, but ~3.5× slower lookups and observed nondeterministicNonefor in-set keys across runs — a correctness smell for a recovery-critical index.boomphfchosen: portable pure-Rust, robust, 88 ns (≈650× over filters). Usetry_hash(returnsOption), neverhash(which panics on non-members).
The lookup is dwarfed by the blob's actual read, so the win is RAM, not speed.
An MPHF maps any key to some slot, so a non-member must be rejected. The early
design carried verify: Vec<Key> (slot → key, 12 B/key) to do that. It was
redundant: locate already binary-searches the candidate pack's sorted member
table to get the offset — and that search is the membership check (a non-member
routes to an arbitrary pack whose search misses). So Bulk is just
{ mphf, pack_idx } (~4.4 B/key, down from ~16.4). The one subtlety: locate must
fall through to the recent map on a candidate miss, because without verify the
MPHF always returns some bulk slot, which would otherwise shadow a recent-only
key. contains and staging reconciliation go through locate (definitive), never
a bare candidate — a false positive there would wrongly drop a staged blob.
New packs land in recent; once it exceeds RECENT_REBUILD_KEYS (200k) it folds
into a freshly-built bulk MPHF. The build is CPU-bound, so it runs off a blocking
thread (spawn_blocking on native; inline on wasm — see §7) over a snapshot of
the keys; the write lock is held only to swap the result in. Known limitation:
each rebuild rebuilds the whole MPHF, so total build work over a growing backup is
superlinear (~N²/200k). It is off-lock background CPU, not latency, and the
persisted snapshot (§5) removes the restart cost — a tiered/incremental MPHF is a
deferred scalability option, not a current need.
boomphf::Mphf::new panics on a duplicate key (it retries a bounded number of
times, then aborts). A key can legitimately appear in two packs: two devices
packing the same content-addressed blob beside different neighbours before they
sync, or a pack re-enriched after a todo marker survived a failed delete. Left
unguarded, one duplicate would make rebuild_index panic and the whole store
un-openable — catastrophic, since it lands on the recovery path. So build_bulk
folds its input to unique keys before the MPHF build (last-wins on the pack index;
both packs hold identical bytes for a shared key, so either serves the read). That
one choke point makes the "each key → one MPHF slot" invariant structurally true no
matter how a duplicate arose. As a second line of defence, load_todos skips
re-adding an already-enriched pack, so the common (single-device) case never
produces a duplicate in the first place, and the write path errors rather than
duplicating (see §6). The MPHF is also probed only with try_hash (returns
Option), never hash (which panics on non-members).
The durable index is each pack body's prepended header (§3). The index_cache
store is purely a local cache of that index so warm restarts are fast; it can be
lost and rebuilt from the bodies. It holds:
- Per-pack manifests at
manifests/<path_for_hash(pack_hash)>— one pack's member table, written on flush. - A consolidated bulk snapshot at
manifests/index-snapshot.v1— postcard of{ version, packs, mphf }, written after each rebuild. On a warm open it's installed wholesale (no per-pack reads, no MPHF rebuild) and only the tail of per-pack manifests flushed since the snapshot is folded in. The slot arrays (pack_idx) are not persisted — they're reconstructed from the MPHF + packs on load (onetry_hashper member; skips the costlyMphf::new), halving the blob. todo/markers atmanifests.todo/<hex(pack_hash)>— discovered-but-not-yet- enriched packs (§6), in a sibling namespace so the per-pack scan skips them.
Naming: the field is
index_cache/ configindex_cache_prefix; the on-disk key prefix stays"manifests/"for cache continuity. The old "manifests store" framing was misleading — there are no durable manifest objects, only this cache.
Why a Store blob, not mmap/epserde. The packing store must also work in the
browser (to read shared packed files), where mmap and file-backed zero-copy
deserialization don't exist. Persisting to a portable Store blob via postcard
(a no_std/wasm-friendly serde format) works on any cache backend — memory, local
disk, IndexedDB, or a composed tier. The snapshot is a pure cache: a missing,
stale, version-mismatched, or corrupt blob is ignored and the index rebuilds from
per-pack manifests (and ultimately from headers). It is never a durability
dependency, so correctness never rests on it.
A cold/wiped device opens with an empty index cache, so the membership index is
empty and packed vault roots would read as "not found." Recovery rebuilds the
index from pack headers. The model is the same "a cache must not lie" principle
as IndexdStore sync-on-open, one layer down — split into a cheap discovery step
and an async enrichment step:
note_pack_hashes(hashes)— record each unknown pack hash as a persistedtodo/marker + an in-memorytodoentry. No header reads, no blocking. Fed the blob backend's enumeration (blob3/<hash>) once the backend itself is synced. (APackingStoreoverB: BlobsReadWritecannot enumerateB— enumeration is the backend's job; the indexd layer owns it and passes the hashes in.)enrich()— draintodoconcurrently (front-bytes header read per pack), write each manifest, fold it into the live index. Single-flighted so a storm of read-misses triggers one drain.
enrich_one reads the first 16 B, then the exact header region, and classifies:
Enriched— valid header → write manifest,add_pack, delete the marker.NotAPack— bad magic / version / decode → delete the marker (terminal).Transient— the header couldn't be read (network/I/O) → keep the marker, retry on a later pass.
The S5.pro magic check is not just hygiene: a non-pack hash (a foreign object on
a shared account) can never enrich, so if a magic failure merely "skipped with a
warning" and left the marker, todo would never drain → every negative answer
would block and re-fetch the bad hash forever (see below). Dropping on a definitive
non-pack is what lets todo reach empty; keeping on a transient failure is what
avoids confusing "couldn't read it" with "isn't a pack."
While todo is non-empty, a lookup miss might be a blob living in an un-enriched
pack. So:
- A positive answer is always trustworthy immediately — a hit in
bulk/recentserves at once, even with packs pending (a blob can legitimately live in several packs after a dedup miss). - A negative answer is only trustworthy once
todois empty —blob_downloadmiss,blob_contains == false, and theblob_upload_bytesdedup check all firstenrich()(drain the pending packs), then re-check (locate_honest/contains_honest). Steady state (todoempty) is a single read-lock, no work.
Writes block on a negative too (decided): a dedup miss while packs are pending would re-stage — and re-upload to Sia — a blob that already lives in an un-enriched pack. A redundant Sia slab is exactly what packing exists to avoid, and recovery rarely overlaps an active backup, so the warmup stall is a non-issue.
enrich() is best-effort — a Transient failure keeps the marker, so todo can
still be non-empty when it returns. A negative lookup then has three cases, and the
third is a deliberate design decision:
- found after enrich → the positive (always trustworthy, never delayed).
- not found,
todonow empty → an authoritative negative (Ok(false)/Ok(None)): every known pack was read and none holds the key. - not found,
todostill non-empty → some known packs are unreadable right now (a Sia/network blip kept their markers). The blob might live in one of them, so absence is unprovable. The lookup returns a retryable error (unreadable_packs_error) — never a false "not found."
Why error rather than answer false? Two reasons, both about not lying on the
recovery path:
- A false "not found" is silent data loss. During a transient backend outage a
vup recoverread of a genuinely-present packed root would report "gone" instead of "backend unavailable — retry." The error carries the truth; the retainedtodomarkers re-enrich on a later read, so the store self-heals once the backend returns. - On the write path a false negative mints a duplicate pack. The dedup check
treats "not present" as "new," re-stages, and re-uploads → a second pack now
carries the same blob. That is precisely the condition that could otherwise
panic the MPHF build (§4). Erroring the write leaves staging untouched, so no
duplicate is created — the retryable-negative rule and the
build_bulkdedup guard reinforce each other.
We deliberately do not retry-with-backoff inside the lookup: retry policy and timeouts belong to the caller, and blocking a read indefinitely on a dead backend is worse than a clean, classified error. Positives are never blocked or delayed — only would-be negatives pay the enrichment/uncertainty cost, and only while packs remain unread.
Open of create_raw_store therefore does not block: it note_pack_hashes(the
enumerated bodies) then enriches in a detached task; the negative-gate is the
correctness backstop, so vup recover reading a packed root simply waits for the
pack it needs. reconstruct_from_headers is kept as a thin note + enrich wrapper
for one-shot "rebuild now" callers (benches/tests).
The crate compiles to wasm32-unknown-unknown so a browser can read shared packed
files. Three native-only things are gated:
- boomphf is taken with
default-features = false(drops theparallelfeature and itsrayondependency, which doesn't build on wasm). We only ever call the single-threadedMphf::new, nevernew_parallel, so nothing is lost. tokio'sfsfeature is acfg(not(target_arch = "wasm32"))target dep; its only user,blob_upload_file, is already wasm-gated.rebuild_indexusesspawn_blockingon native and an inline build on wasm (no thread pool there; a browser share-reader indexes only a handful of packs, so the build is tiny — owner-scale rebuild is a native concern).
postcard's alloc feature provides to_allocvec and is wasm-friendly.
Status: decided, NOT implemented.
blob_deleteandblob_retainreturnNotSupported. This section records the agreed architecture and, importantly, why the simple/conservative design is the correct one rather than a concession.
To delete safely you must know a blob is referenced by no retained root. In a distributed, multi-writer, content-addressed-with-dedup system there are exactly two ways to know that:
- Track liveness explicitly — pinning / reference counts.
- Recompute liveness — mark-sweep: walk every retained root, delete the rest.
Why not pinning / refcounts. In a decentralized setting this is not merely "extra state" — it's distributed mutable state. Counts must be incremented/decremented by every writer across every device, transactionally; a lost decrement leaks, a lost increment or double-decrement is silent data loss. Getting it right needs consensus or CRDT counters — exactly what a lazily-syncing multi-device account doesn't have. So refcounts here are not just ugly, they're unsound without machinery that defeats the point.
Why not diff-based GC ("walk-diff old vs new vault, delete what was removed"). Seductive but unsafe: a Merkle diff is cheap because it skips subtrees whose hash is unchanged — and "unchanged subtrees" is exactly where a surviving reference to a "removed" blob hides. With vault-scoped (and cross-vault) content dedup, "removed from path X" ≠ "dead." Turning a diff candidate into a confirmed-dead requires answering "is this hash referenced anywhere retained?" — which is the full reachable set again. So diff-then-confirm collapses into a full mark with extra steps; the diff is only useful as an input to refcounting, which we reject.
Mark-sweep's one hard requirement is a complete, consistent enumeration of all
live roots at sweep time. This architecture already has that primitive: the
registry is the authoritative set of all HEADs, and walking it is the same
walk recovery already does (reconstruct_from_indexer → all HEADs → their trees).
So GC and cold-recovery share one mechanism; GC is not a new global-view system.
Two properties seal it:
- Crash-idempotent, fail-safe. A mark-sweep can be interrupted, re-run, or run twice — worst case is leaked garbage (reclaimed next pass), never lost data. Refcounts have no such property.
- The concurrency window has a stateless fix. The only gap is a write landing after the root set was enumerated. A generation / timestamp floor closes it: never reclaim a pack newer than the moment the roots were enumerated. No coordination — just "don't touch recent packs," set old enough that every device has provably synced those roots.
Granularity is per-account, not per-vault. Reachable = union over all retained HEADs in the account; a per-vault sweep would wrongly reclaim a blob another vault in the same account still references (cross-vault dedup is real). One account = one owner (one AppKey), so there is no cross-owner pack sharing and thus no multi-party-consent problem.
Shares are not a liveness source (decided). The mark is pure snapshot-tree reachability; pruning a snapshot breaks any outstanding share of a blob unique to it, by design. This keeps the mark simple (no share registry, no extra source).
This is the realization that makes the conservative design correct rather than a compromise. Every blob is encrypted, and the per-blob key is derived from the plaintext hash and a per-vault secret (keyed convergent encryption), not the plaintext hash alone. Keys are reference-scoped: the only places a blob's key exists are in the parent reference that points at it (which carries it) and derivable by a holder of both the plaintext and the secret. Decryptability flows top-down from a root.
Consequences:
- Orphaning ≈ erasure for non-secret-holders. Update the root to drop a subtree and the orphaned ciphertext becomes inert to anyone lacking the vault secret — the bytes remain on disk, but there is no path to the key. The storage operator only ever saw ciphertext, so orphaning reveals nothing new; a prior-key-holder could always have copied the data before deletion (no system protects against that). Physical deletion adds no confidentiality the encryption + reference-drop didn't already provide.
- The per-vault secret is what makes it robust — and is why this is not the weak form of convergent encryption. Plain convergent encryption (key = plaintext hash alone) is confirmable: guess the plaintext, recompute the key, decrypt. Mixing in the secret defeats that — an adversary who guesses the plaintext still can't derive the key. So confidentiality does not depend on content entropy. It also scopes dedup to per-vault (same plaintext in two vaults → different secret → different ciphertext → different address), deliberately trading global dedup — itself a privacy leak — for confidentiality. (Load-bearing assumption: the combine is a keyed hash / KDF such as BLAKE3 keyed mode, not a naïve concat.)
Therefore GC is a storage-cost optimization only — not a confidentiality mechanism, not on any correctness or security path. Which flips the risk calculus:
- The risk is maximally asymmetric toward safe: wrongly deleting a live blob is unrecoverable data loss; failing to delete a dead blob costs a few cents of storage. So the design must be maximally conservative — "leak rather than lose," floor-gated, full-mark-only-when-convenient.
- There is no justification for risky machinery (distributed refcounts, incremental diff-GC) to optimize a cost. Mark-sweep-with-floor run rarely is the correct ceiling of effort, not a shortcut.
- GC is opt-in and lazy: a deployment can run with monotonic growth and lose
nothing but money. This is why
blob_delete/blob_retainstayNotSupporteddefaults and were not rushed.
- Packing = mechanism. It exposes (when built)
blob_retain(reachable, floor)- the sweep/compact primitives, policy-agnostic — zero knowledge of snapshots,
vaults, registries, or shares. It is handed the complete reachable set and a
floor, and reclaims below the floor. Its only GC-specific bookkeeping is stamping
each pack with a generation/timestamp so the floor is meaningful. To delete pack
bodies it needs
B: BlobsReadWrite + BlobsDelete(the indexdBlobStorealready implements delete).
- the sweep/compact primitives, policy-agnostic — zero knowledge of snapshots,
vaults, registries, or shares. It is handed the complete reachable set and a
floor, and reclaims below the floor. Its only GC-specific bookkeeping is stamping
each pack with a generation/timestamp so the floor is meaningful. To delete pack
bodies it needs
- Vault = policy. It computes the complete reachable set (walk all retained HEADs — the recovery primitive) and chooses the floor and retention policy. The liveness definition (full mark) is fixed; how the set is produced (full walk now; refcount-derived deltas only if a real corpus proves the walk too slow) is a vault concern, fully separable from the packing mechanism.
- Phase A — whole-dead-pack sweep (cheap, no re-upload). Any pack with zero
reachable members is entirely dead → delete its body + manifest, drop it from the
index, then
rebuild_index+ re-snapshot. Handles the common case (prune old snapshots → whole packs die) at delete-only cost. - Phase B — partial compaction (expensive, threshold-gated). A pack with some
live + some dead members → rewrite the live ones into a fresh pack, upload, delete
the old. Each rewrite is a new Sia slab, so gate on
dead_fraction ≥ threshold.
Because removing/replacing packs shifts the packs Vec positions that bulk/
recent index, a retain pass ends with a full rebuild_index; so GC is a
maintenance pass run under the flush lock / on idle, not concurrent with an active
backup.
"Simple by default, powerful when needed": the store must never surprise a phone or a PiB owner. The one genuinely unbounded resource — the staging spool — is now capped; the rest are bounded and scale with a knob.
Staging spool (disk) — the backpressure watermark. blob_upload_bytes returns
as soon as a blob is durably staged locally; the pack flush to the backend is
async. So without a bound, an ingest faster than the sustained upload rate spools
the whole backlog to local disk (bounded only by free space — we saw 3.6 GiB pile
up before a publish barrier). The fix: a live staged_bytes counter + a watermark
staging_max_packs × max_group_size (default 4 × 256 MiB = 1 GiB). Once staging
reaches it, blob_upload_bytes blocks (nudging the flush loop) until flush_group
uploads a pack and decrements the counter — so ingest self-throttles to the upload
rate instead of filling the disk. Requires a running flush loop (the daemon spawns
run_upload_loop) or flush_on_put; staging_max_packs = 0 disables it. This is
the only place a backend→ingest backpressure signal belongs.
Everything else is bounded (formula → number at 16M blobs ≈ 1 TiB @ 64 KiB):
| Resource | Bound | ~ at 16M blobs |
|---|---|---|
| Upload RAM | max_inflight × total_shards × 4 MiB (knob, default 8 × 30) |
~960 MiB |
| Resident index | packs member tables + bulk MPHF/pack_idx ≈ ~20 B/blob |
~330 MiB |
| Rebuild transient | sort-dedup over (key,pack_idx) pairs (16 B) + unique keys — no hash table |
~512 MiB (off-lock) |
| Snapshot save/load | whole postcard blob in one buffer | ~330 MiB (transient) |
| Recovery (cold boot) | pack-hash Vec + todo set, by pack count |
~hundreds MB at PiB |
Notes: the rebuild dedup deliberately sorts rather than building a
HashMap/HashSet over every key — at 10M+ keys a hash table is a multi-hundred-MB
transient that sorting avoids. The max_inflight figure is the sia-SDK default (a
documented per-device knob: lower on phones). A corrupt/huge member-count can't force
a giant read — the header fetch is a ranged GET clamped to the actual body size, then
decode_header errors.
Known RAM cliff (not yet fixed): whole-file buffering. FileChunkingStrategy's
default is None (one blob per file), so a large file ingested without a chunking
route becomes a single in-RAM Bytes — worst case max_concurrent_ops × largest_file.
The Gearhash CDC chunker (64 KiB target, MAX_CHUNK_SIZE-clamped) exists and
node.rs flags it as the intended default; making CDC the default (never None above
a size cap) closes this. See §9.
-
Default to Gearhash CDC chunking (never unbounded
None) — bounds ingest RAM tomax_concurrent_ops × MAX_CHUNK_SIZE; today the enum default isNone(whole file → one blob), the RAM cliff above. The CDC chunker already exists. -
Lazy / on-disk member tables — the resident index is dominated by the per-pack member tables (~16 B/member). Keeping only the MPHF +
pack_idxresident (~4.4 B/key) and reading a candidate pack's member table from the on-disk manifest cache on demand would cut resident RAM ~5× at the cost of a local read per cold lookup. -
Chunked snapshot save/load — serialize/read the bulk snapshot in bounded windows (or re-derive member tables from per-pack manifests on load) so it isn't a single ~hundreds-MB buffer at extreme scale.
-
Pack-upload cold-start timeout/retry (B3) — the first-ever upload on a fresh Sia account can stall before hosts warm / contracts form, with no timeout → apparent hang. Warm-before-first-flush or a per-pack timeout surfacing a retryable error (the background flush loop then retries once warm).
-
GC (§8) — designed, unimplemented. Phase A first.
-
boomphf mmap/epserde or
ptrhashcargo feature — only if profiling on a real 10M+ blob corpus shows the rebuild or load cost matters; the snapshot already removes the restart cost. -
Tiered/incremental MPHF — to make rebuild work linear rather than ~N²/200k; off-lock background CPU only, so low priority.
-
Prune snapshot-covered per-pack manifests — saves ~2× index-cache disk but removes the per-pack fallback when a snapshot is corrupt; the disk dup is small relative to actual data, so deprioritized.
-
Single speculative header GET. Reading a pack header is currently two ranged GETs from offset 0 (16 B fixed prefix → member count → the exact header region). Since the data region starts on a 16 KiB boundary, one speculative ~16 KiB front GET would cover the entire header in a single round-trip for any pack whose member table fits under 16 KiB (the common case), falling back to a second GET only for very large member counts. Halves header-read round-trips on the enrich/recovery and share-read paths. Low effort, bounded; not yet done.
| What | Where |
|---|---|
| Pack body format | stores/packing/src/manifest.rs |
| Index, MPHF, reconstruction, persistence, GC stubs | stores/packing/src/lib.rs |
| Bin-packing heuristic | stores/packing/src/binpack.rs |
| Sia store wiring (discover + background enrich) | s5_node/src/lib.rs create_raw_store |
| Live recovery bench | stores/indexd/tests/bench_indexd.rs (bench_recover_from_headers) |