Repository navigation
Conversation
…ndle ContentBytes derefs to [u8] and is Arc-backed in every variant (heap buffer, shared slice, file mapping), so a clone is a refcount bump. MemoryContentStore now hands out its stored bytes without copying. Callers that move the buffer use into_vec, which moves a uniquely owned buffer instead of copying it.
StorageRead::resolve_local_path and resolve_local_bytes, and ContentStore::resolve_local_path and resolve_cached_bytes, become a single get_local returning Result<Option<ContentBytes>>: the bytes when this machine already has them, None when they must be fetched, and an error when a local copy exists but cannot be read. FileStorage maps the file, or reads it when at most 64 KiB (the threshold the pack reader used, now ContentBytes::from_file). ContentBytes::from_file and open are unsafe fns: a mapping is sound only while the file is never rewritten, which every caller upholds by landing writes with a rename. MemoryStorage shares its allocation. EncryptedStorage decrypts its inner storage's local copy, so encrypted file storage gets the local lane instead of whole-object reads through the range path. TieredStorage routes it like every other read. The CID-keyed StorageRead::resolve_cached_bytes stays only as the residency tier the bridge consults first. The binary index reads every artifact as cache entry, then get_local, then the interim disk-cache file, then fetch: - Leaves: one FullBlobLeafHandle over ContentBytes replaces the mapped, shared and owned handles. LeafletCache holds LeafBytes weighed by ContentBytes::cache_weight (mappings at len/64, heap bytes in full). Bytes the store already shares are not cached, and the lookup runs outside the cache loader so memory-backed opens do not pay block_in_place. - Directory-only opens decode a local leaf in place and leave its bytes out of the cache. - Dict trees resolve each leaf at lookup; building a reader no longer stats every leaf. - Forward packs, vector shards and sidecars take the same lane.
ContentStore::supports_ranged_reads says whether get_range reads only the requested bytes. Its default is false, matching the default get_range, which fetches the whole object; the bridge forwards the storage's answer, so encrypted storage (which must decrypt the whole object for any range) answers false. For such a store the leaf reader no longer range-reads a leaf's header, directory and columns, each a whole-object fetch and decrypt, before promoting it on the second open: it fetches the leaf whole on the first open, full or directory-only. A whole-fetched leaf now enters the leaflet cache, as the mapping of the disk-cache copy when one was written and as heap bytes otherwise, so later opens neither fetch nor re-map it. Encrypted remote stores, which get no disk cache, were refetching every promoted leaf on every open.
Readers no longer ask a content store whether it is remote or may be cached in plaintext, and no longer carry a cache directory. The disk artifact cache is a ContentStore wrapper applied where the store stack is assembled: StorageBackend::with_disk_cache fronts a remote plaintext storage with the cache, gives a remote storage that decrypts on read coalesced fetches and nothing on disk, and leaves a local one alone. Every Fluree and LedgerManager constructor applies it, over LedgerManagerConfig::cache_dir or the default, and so does the indexer start; the directory is created only once something is cached in it. Fetches are coalesced only among one backend's readers of one namespace, since a waiter takes the leader's outcome without running its own read: a child branch's miss must not fail its parent's concurrent read, nor a wrong-key reader receive plaintext. Cache entries stay keyed by CID alone. Single-flight is its own Flights type; DiskArtifactCache keeps coalesced_fetch on top of it. Ledger verification reads through StorageBackend::without_disk_cache, so a cached copy cannot hide an object the storage lost. CachedContentStore serves reads from the inner store's local tier, then its cache entry, then a coalesced fetch that writes the entry, and never copies what the inner store serves locally. Range reads come from the entry when present. A release reaches the inner store first and evicts the entry after, batch releases included; the process-wide eviction walk is gone. The per-directory DiskArtifactCache singleton stays, so every store over one directory shares one budget. Removed: ContentStore::is_remote and permits_plaintext_cache (the storage still answers both, read once at assembly), uses_disk_cache, needs_disk_copy, seed_disk_cache, fetch_cached_bytes*, evict_cached_cid, IndexerConfig::artifact_cache_dir, and the cache_dir parameters of BinaryIndexStore, the dict and pack readers, vector shards, the arena loaders, incremental resolution and the GC walks. The sweep reads chains through the backend. Two write-side hints replace the seeding and prewarm call sites: ContentStore::keep_local (bytes just written, for reads about to follow) and ContentStore::prefetch (warm the local tier ahead of a sync read). Both default to doing nothing, so a local store needs no is-remote check before them. put does not write through, so a full rebuild does not churn the cache. Cache entries are keyed by CID throughout; entries written under the old digest-and-extension names are left to budget eviction.
The raft push path and its multi-node test wrapped ContentStore::get results in Base64Bytes, which holds a Vec<u8>. Both are behind the raft feature, so only the --all-features CI build compiled them.
with_disk_cache fronts IPFS with the cache, so build_ipfs now yields Cached(Permanent). The test is behind the ipfs feature and only ran under --all-features.
Adapts next's additions to the store-level cache: - The triple-term dictionary reader and the incremental annotation attachment lookup no longer take a cache directory. - Artifact read accounting (FLUREE_IO_STATS) counts local reads in the bridge's get_local and cache hits in CachedContentStore, replacing the per-reader hooks. - FLUREE_FORCE_REMOTE_READS makes get_local answer nothing and fronts a local backend with the disk cache, so reads take the remote path.
…e misses A ranged read served from a cache entry sized its buffer from the range, so `mid..u64::MAX`, the trait's spelling of "read to the end", asked for a usize::MAX allocation and panicked; a single read_at could also come back short. It is now sized from the entry and read exactly. An entry renamed into place without an fsync can come back empty after a host crash, and was served as the object until budget eviction. Empty entries now read as misses, nothing empty is written, and the next fetch replaces one.
CachedContentStore wrote every remote get to the cache, so commits, transactions, config and graph-source snapshots landed there too. A reindex or replay of a long history then pushed hot leaves out of a cache that evicts in write order, and transaction data sat on local disk in plaintext. Before the wrapper only index readers, the indexer and GC used the cache, and the commit walk skipped it on purpose. Roots, branches, leaves, dictionaries, sidecars, stats sketches, spatial artifacts and garbage records are cached; other kinds are fetched and coalesced but never written. The verify test now plants the stale commit entry itself, as a release that cached commits would have left it.
…sync workers A leaf served by get_local was looked up outside the leaflet cache's single-flight loader, which suits a file mapping or memory storage's shared bytes. On storage that encrypts at rest, get_local reads and decrypts the whole leaf, so N cursors cold-opening one leaf decrypted it N times, each on a Tokio worker without handing it off. ContentStore::costly_local_reads says whether get_local decrypts; the bridge answers from encryption_admin, and every wrapper forwards it. On such a store the leaf is read through the loader: once per leaf, inside a blocking region.
A/B benchmark: this PR vs
|
| Geomean latency | Queries ≥ 100 ms | |
|---|---|---|
| File storage | 1.005 | 0.997 |
| Remote, empty disk cache | 0.990 | 1.000 |
| Remote, after restart over the filled disk cache | 1.005 | 1.001 |
| Remote, hot | 0.966 | 0.981 |
- All 315 saved outputs (105 queries × 3 runs) are byte-identical between a and b.
- Storage requests are the same: about 56,300 per cold pass and 38,300 after a restart, in every repeat.
BSBM 100M, file storage, 2 repeats each. Query mixes per hour, so above 1 means this PR is faster.
| b ÷ a | |
|---|---|
| Explore, 1 client | 1.032 |
| Explore, 8 clients | 1.033 |
| BI, 1 client | 1.007 |
| BI, 8 clients | 1.008 |
Writes: incremental index builds look ~4–6% slower
BSBM 100M Update, file storage, background indexing on, 3 repeats each.
-
With the leaflet cache at 22,000 MB, both variants peaked at 54–59 GB on a 64 GB box, and b's third run was killed for running out of memory at 63 GB. So close to the limit, throughput says little about either variant.
-
Rerun with the cache at 8,000 MB, both peaked at 36–50 GB with no kills:
a b Update query mixes/hour (mean of 3) 8,465 7,934 (0.94×) Median steady-state index build (90–100 commits) 6.49 s 6.74 s (+4%) With the 22,000 MB cache, the same build measure was 7.07 s for a and 7.51 s for b (+6%).
The samples are small and Update throughput is noisy (b ranged from 7,162 to 9,262). Even so, every way of slicing the build times points the same direction. Not profiled yet. One candidate is fetch_noting_local, which now maps each leaf through get_local and copies it with into_vec, where it used to read the file in one call.
Memory pressure, and #2068
Both variants run near the box's limit under Update because next still warms every leaflet of every rewritten leaf, with all columns and no concurrency bound. #2068 (stacked on #2053, targeting main) warms only the leaflets readers had cached, on a bounded pool. In its own BSBM Update measurements it cut peak RSS from about 56 GB to about 40 GB and dictionary re-reads from about 220 GB to about 25 GB. I expect much of the gap above to be this pressure: the index build competes with warming for memory and for reads of the dictionary.
The build-time difference persisted with the 8,000 MB cache, so the rerun doesn't settle it. Once #2068 reaches next, I'll rerun the Update comparison to separate the two.
Also seen, not caused by this PR
On SIGINT, the server panics after main returns ("A Tokio 1.x context was found, but it is being shutdown"). It happens on both a and b, in every remote run.
aaj3f
left a comment
There was a problem hiding this comment.
@bplatz this is the design #2029 asked for, and I'm fully on board, including deferring stage 4. Your three reasons (CID derivation, in-place key rotation, cross-key collisions) are exactly why it isn't a relocation. I spent most of the review on plaintext. I drove get/get_local/get_range/prefetch/keep_local over encrypted remote storage as a managed backend, a branched child and a routed mount, and nothing reached disk; dropping the gate in with_disk_cache turns your crypto test red. Two headline notes:
(1) The plaintext invariant now lives in one chokepoint, which is the point. But that chokepoint can't see a Permanent store and caches it unconditionally (inline). Narrower alternative: make Permanent opt in to the cache.
(2) This overlaps #2048 head-on. It adds the same whole-leaf-on-first-open mechanism for stores without ranged reads, and removes the four things #2048 builds on (fetch_cached_bytes_cid, uses_disk_cache, cache_dir, SharedLeafBytes; conflicts in eight regions of binary_index_store.rs). I'd land #2048 on main and merge main into next before this merges, so the Whole policy becomes one more trigger of fetch_whole_leaf here, in a branch with its own tests, rather than being re-derived in a routine merge. There's also a one-line conflict with #2051 in rebuild.rs. Otherwise good to merge once the write-side A/B meets the PR's own gate.
Full review below:
✅ Approving to unblock you — with 1 thing that needs to be addressed before this merges.
This is a big, careful refactor, @bplatz. The departures section, the operator-visible list and the "55 reverts, all red" testing note made it far easier to review than its size suggests. The read side checks out: your A/B shows no regression with byte-identical outputs, and I found no correctness behavior lost among the removed hooks. A read-only pass traced all ~300 base call sites of resolve_local_path/resolve_cached_bytes/resolve_local_bytes/is_remote/permits_plaintext_cache/uses_disk_cache/fetch_cached_bytes*/evict_cached_cid/artifact_cache_dir to their replacements, and I re-checked the flagged ones.
Must address before merge:
- The incremental-build regression against the PR's own ≤ 1.02× gate (
incremental.rs:129).fetch_noting_localnow maps each local leaf and copies it withinto_vec, and the CoW closure clones it again. I measured that step at +25% per 721 KiB leaf versus base'sfs::read. HoldingContentBytesinleaf_map/sidecar_map, and lettingupdate_branch's fetch closures return it, removes both copies (−30% versus base in the same bench). Worth doing before the Update A/B re-run.
Should address:
- The plaintext-staging warning for encrypted storage is gone from the CLI's
fluree indexand direct indexer callers (rebuild.rs:120). It now fires only fromreindexand background indexing start.
Optional, in the inline notes: Permanent stores are always disk-cached, since with_disk_cache can't ask them; and two operator-visible changes aren't in the body's list (the indexer cache moving off <data_dir>/binary_artifact_cache, orphaning it; FLUREE_MMAP_MIN_BYTES missing from the config docs).
One question with no anchor: Windows is a dist target, and mapping now covers every FileStorage blob over 64 KiB, not only leaves and packs. A mapped file on Windows can't be deleted or renamed over, so GC or drop on a Windows file store may now hit more "in use" failures. No CI job runs Rust storage on Windows, so I couldn't check it.
Adherence to repo commitments:
- Patterns/abstractions: ✔ This is the class fix for per-reader cache decisions (#2018's local copies, #1916's decrypted artifacts, base's process-wide
fetch_uncachedflights): one assembly chokepoint and a wrapper readers can't bypass. Two stragglers are noted inline (Permanent, the staging warning). - Performance (speed first, memory second): ✖ Reads are at parity per your A/B. CRITICAL until fixed: Update builds −4–6% against the PR's ≤ 1.02× gate, with a measured +25% per-leaf mechanism in
fetch_noting_localand a fix inline. Memory: more artifact kinds are now mapped rather than heap, which is a win; evicted-but-mapped entries pin disk blocks only as long as readers hold them, same as leaves at base. - Deployment targets:
⚠️ Shared code. Embedded in solo: no compile impact, Lambda's cache stays in/tmp, and encrypted S3 gets coalescing only. The standalone's oldindex/binary_artifact_cacheis orphaned on a pin bump. wasm32 is a no-op wrapper and green. Windows mapping semantics are unexercised (question above). No fence violation found. - Testing: ✔ The new pins run, and the ones I mutated have teeth. Dropping the plaintext gate turned
an_encrypted_remote_backend_writes_nothing_to_the_disk_cachered. Dropping the namespace from the flight key turneda_child_branch_miss_does_not_fail_a_concurrent_parent_readred. My own matrix (encrypted × managed/branched/routed × every read and hint entry point) wrote nothing. - Conventions: ✔ One commit per stage with thorough bodies; clippy and fmt green; storage-traits, content-id and encryption design docs updated. Config-doc gap noted inline.
Verified locally at branch HEAD (a66519271):
- PR tests by name: crypto lib (29), core disk_cache/storage/content_bytes (179), binary-index lib (367), indexer
fuel(6), apigrp_indexincl. all sevenit_disk_cache_localitytests (11). - Two mutation checks, red and restored.
- Scratch tests: the encrypted-composition matrix and the
Permanentprobe; thefetch_noting_localcopy bench. git merge-treeagainstorigin/next(clean), #2048 and #2051.
Approving now so you can merge without waiting on another pass from me — just be sure the fetch_noting_local copy is gone and the Update A/B is back inside your 1.02× gate before you do.
| id: &ContentId, | ||
| ) -> fluree_db_core::Result<(Vec<u8>, bool)> { | ||
| if let Some(bytes) = content_store.get_local(id)? { | ||
| return Ok((bytes.into_vec(), true)); |
There was a problem hiding this comment.
🔴 Must address before merge: the incremental-build regression in your A/B has a measurable cause here, and a small fix that removes two copies
Your 2026-10-11 comment shows Update builds at 0.94× throughput and +4–6% build time, against this PR's own merge gate of ≤ 1.02× of main. You named fetch_noting_local as a candidate, and it holds up when measured. For a local leaf over 64 KiB, get_local maps the file, into_vec copies the mapping, and the munmap follows. Base did one fs::read. Then, as on base, the CoW's fetch closure .cloned()s that Vec again (:303-308), because update_branch takes Fn(&ContentId) -> io::Result<Vec<u8>> (run_index/build/incremental_branch.rs:112-124).
I timed it with a dev-fast scratch bench: 64 × 721 KiB files in page cache, every 4 KiB of the result touched, best of 3, macOS aarch64. Base fs::read + clone: 45–47 µs per leaf. This PR's map + into_vec + clone: 56–58 µs (+25%). With 8 threads: 11.2–11.6 → 14.4 µs. Linux's 4 KiB pages mean 4× the faults per mapped leaf, so I'd expect no smaller a gap on the EC2 box.
The fix that more than recovers it is to stop converting at all. Return ContentBytes from fetch_noting_local, hold it in leaf_map/sidecar_map, and let update_branch's fetch closures return ContentBytes (or a generic B: AsRef<[u8]>). The .cloned() becomes an Arc bump, and the prefetched set sits in page cache instead of heap. Same bench: 32 µs single-threaded (−30% vs base), parity at 8 threads. Illustrative:
async fn fetch_noting_local(
content_store: &dyn ContentStore,
id: &ContentId,
) -> fluree_db_core::Result<(ContentBytes, bool)> {
if let Some(bytes) = content_store.get_local(id)? {
return Ok((bytes, true));
}
Ok((content_store.get(id).await?, false))
}
// leaf_map: HashMap<ContentId, ContentBytes>; update_branch<F, G> with
// F: Fn(&ContentId) -> io::Result<ContentBytes>, G: … -> io::Result<Option<ContentBytes>>I didn't profile a whole build, so I can't say this is all of the 4–6% (the warming pressure you mention from #2068 may be the rest). But it's PR-sized, and it should be in before the Update A/B re-run that decides whether the gate is met.
|
|
||
| // Determine output directory for binary index artifacts | ||
| let staging_in_temp = config.data_dir.is_none(); | ||
| let data_dir = config |
There was a problem hiding this comment.
🟠 Should address: the plaintext-staging warning for encrypted storage no longer fires on the CLI's fluree index or any direct indexer caller
Before this PR, the warning lived here, so every rebuild passed through it: data_dir unset over a store that refuses plaintext caching meant "index build staging defaults to the system temp directory". It moved to warn_if_staging_plaintext in the API (fluree-db-api/src/lib.rs:1451), which is called only from reindex (admin.rs:2264) and background-indexing start (lib.rs:3142). fluree index (fluree-db-cli/src/commands/index.rs:46-57) builds IndexerConfig::default(), so data_dir is None, and goes straight to build_index_for_ledger. On an encrypted store (the CLI has fluree encryption …), it now stages plaintext runs and leaves under $TMPDIR/fluree-index without a word. None of the commit bodies mention dropping it there.
The smallest fix is to call warn_if_staging_plaintext from the CLI's index_ledger too. A sturdier one is to keep the warning in the indexer, where every build path passes, by carrying "this store refuses plaintext" into IndexerConfig from the assembly that already knows it. Either way it's a few lines.
| flights: Arc::default(), | ||
| })) | ||
| } | ||
| StorageBackend::Permanent(store) => StorageBackend::Cached(Arc::new(CachedBackend { |
There was a problem hiding this comment.
🟡 Optional: with_disk_cache can't ask a Permanent store whether it may be cached in plaintext, so it always caches it
This is more of a design note than a bug today. With permits_plaintext_cache/is_remote gone from ContentStore, this arm has no way to ask, and the doc assumes every Permanent store is IPFS. At base the reader asked the ContentStore, and StorageContentStore forwarded the storage's answer. So an encrypted store handed over as StorageBackend::Permanent(Arc::new(StorageContentStore::new(Arc::new(EncryptedStorage::new(…)), …))) was never cached. At this head, a scratch probe shows that same construction writes both a leaf and a root to the cache directory in plaintext.
In-repo only IPFS builds Permanent (fluree-db-api/src/lib.rs:2848), and I didn't find an embedder that does, so this is latent. But Permanent is a public variant, and this is now the one place the invariant lives. A narrow fix: leave Permanent uncached here and have the IPFS constructor opt in (e.g. StorageBackend::cached_permanent(store, dir)), so "a store that refuses plaintext never gets a disk" holds for every public construction. Minor and non-blocking, but if you agree it's right I'd rather see it folded in now than lost in the backlog.
| /// indexer; applying it twice changes nothing, and every application over one | ||
| /// directory shares one cache. | ||
| #[cfg(not(target_arch = "wasm32"))] | ||
| pub(crate) fn with_artifact_cache( |
There was a problem hiding this comment.
🟡 Optional: two operator-visible changes the body's list doesn't include
- The indexer's disk cache moves. At base,
IndexerConfig::artifact_cache_dir()put it at<data_dir>/binary_artifact_cachewheneverdata_dirwas set. Here the indexer's backend gets this wrapper withLedgerManagerConfig::cache_dir(default$TMPDIR/fluree_binary_cache). A deployment that setsdata_dirbut notcache_dirmoves its indexer cache from a persistent path to the temp dir. Every deployment that had one leaves the oldbinary_artifact_cachedirectory orphaned: nothing opens it, so the budget never evicts it. FLUREE_MMAP_MIN_BYTES(the rename the body does call out) isn't indocs/operations/configuration.md's table. perf(index): fewer requests for remote leaf reads; whole-leaf fetch by default #2048 addsFLUREE_REMOTE_LEAF_FETCHthere.
A line for each under "Operator-visible changes", plus the docs row, would cover it. Stale binary_artifact_cache assertions remain in it_storage_encrypted.rs:325-385; they're vacuous now and could go in the same pass.
`fetch_noting_local` mapped each local leaf and copied it with `into_vec`, and the CoW's fetch closure cloned that `Vec` again. The prefetch maps now keep the store's `ContentBytes`, and `update_branch` takes any `B: AsRef<[u8]> + Send` from its fetch closures, so a clone is a refcount bump and mapped leaves stay in the page cache rather than the heap.
… temp The plaintext-staging warning moved from the indexer to the API, where only reindex and background indexing reached it; the CLI built its own `IndexerConfig` and called the indexer directly. `Fluree::build_index` now owns that assembly (full-text provider, warning, branch-aware store), and `fluree index` calls it. Also drops the `binary_artifact_cache` assertions in the encryption tests: the indexer shares the disk cache those tests already check.
`with_disk_cache` can't ask a `Permanent` content store whether its bytes may sit on disk in plaintext, so it cached every one, including an encrypted store handed over that way. It now only coalesces their reads, and `StorageBackend::cached_permanent` opts a store in. IPFS, which serves bytes as published, does.
Partially addresses #2029: stages 1–3. Stage 4 (encryption above the cache) is not done; see Departures from #2029.
Readers used to decide how to read by asking the store around the trait:
resolve_local_path,resolve_cached_bytes,resolve_local_bytes,is_remoteandpermits_plaintext_cache. The disk cache sat outside every store and needed those answers, so acache_dirwas threaded through the binary index, dictionary, pack and vector readers, the arena loaders, incremental resolution and the GC walks. After this PR, readers ask a store for bytes and get a shared handle. The cache is a store wrapper that is applied once, where the store stack is assembled.75 files, +3,218 / −3,473, in one commit per stage.
1.
ContentStore::getreturns a sharedContentByteshandleContentBytesderefs to[u8]and has three variants. Every variant isArc-backed, so cloning one never copies the bytes.Owned(Arc<Vec<u8>>): heap bytes.Shared(Arc<[u8]>): bytes the store already holds, such as memory storage's.Mapped(Arc<Mmap>): a file mapping. Not compiled on wasm.cache_weight()keeps the existing leaflet-cache discount for mappings and charges heap bytes in full. Callers that need aVecuseinto_vec(), which moves the bytes when the handle is the only owner.Incremental builds keep the handles: the prefetched leaves and sidecars stay
ContentBytes, andupdate_branchtakes anyB: AsRef<[u8]> + Sendfrom its fetch closures. A mapped leaf is never copied into the heap, and handing it to the copy-on-write update is a refcount bump.2. One synchronous
get_localreplaces the local-read hooksget_local(id) -> Result<Option<ContentBytes>>, on bothStorageReadandContentStore, replacesresolve_local_path,resolve_local_bytesandContentStore::resolve_cached_bytes:Some(bytes)when this machine already has the object;Nonewhen it must be fetched;How each storage serves it:
None, since it is failed-write debris. The rest is read into the heap at or below 64 KiB and mapped above that (the threshold the pack reader used).ContentBytes::from_fileandopenareunsafe fn: a mapping is sound only while the file is never rewritten, and every writer lands files by renaming a staged file into place.Arc<[u8]>.StorageRead::resolve_cached_bytessurvives only as the wasm residency hook, which the bridge consults first.The leaf, dictionary-leaf, forward-pack, vector-shard and sidecar readers try
get_localfirst and fetch onNone. Arenas and branches read throughget.FullBlobLeafHandle,SharedBlobLeafHandle,MmapLeafHandle) become one.stating every leaf when the reader is built.get_localis looked up outside the leaflet cache's single-flight loader. Otherwise every memory-storage open would pay ablock_in_place.Sharedbytes are not inserted into the leaflet cache, since the store already holds them.get_localdecrypts the whole leaf. A new hint,ContentStore::costly_local_reads, says so; the bridge answers it fromencryption_admin. On such a store the leaf is read through the loader: once per leaf however many cursors open it, inside a blocking region.3. Fetch leaves whole from stores without ranged reads
A new
ContentStore::supports_ranged_reads()hint defaults tofalse. On a store without ranged reads every range request is a whole-object fetch, plus a decrypt for encrypted storage. A leaf on such a store is now fetched whole on its first open, and the fetched bytes go into the leaflet cache. Stores with ranged reads keep the existing policy: range reads first, and a whole fetch from the leaf's second open.BranchedContentStorereports ranged reads only when every ancestor supports them.4. The disk cache becomes
CachedContentStoreStorageBackend::with_disk_cache(cache_dir)is the one place that asks whether a storage is remote:Permanentstores: coalesced fetches only.with_disk_cachecan't ask one whether its bytes may sit on disk in plaintext, so a store opts in withStorageBackend::cached_permanent. IPFS, which serves bytes as published, does.Applying it twice changes nothing. Every
FlureeandLedgerManagerconstructor applies it overLedgerManagerConfig::cache_dir(or the default directory), and so does the indexer start.CachedContentStorebehaviour:mid..u64::MAXreads to the end.Flights<K>type, andDiskArtifactCache::coalesced_fetch, which Iceberg uses, keeps its path keys on top of it.Two hints replace the explicit seeding and prewarm calls. Both are no-ops by default:
keep_local: bytes the indexer just wrote, for reads about to follow.prefetch: warm the local tier ahead of a synchronous read. Used by the indexer and byBinaryIndexStore::prefetch_*on the query path.putdoes not write through, so a full rebuild does not churn the cache.Removed:
ContentStore::is_remoteandpermits_plaintext_cache. The storage still answers both, and assembly reads them once.uses_disk_cache,needs_disk_copy,seed_disk_cache,fetch_cached_bytes*andevict_cached_cid.IndexerConfig::artifact_cache_dir()andCleanGarbageConfig::artifact_cache_dir.cache_dirparameter on the readers, incremental resolution and the GC walks.Ledger verification reads through
StorageBackend::without_disk_cache(), because a cached copy would hide an object the storage has lost.Departures from #2029
Stage 4 (encryption above the cache) is not done. It is a separate design, not a relocation of existing code:
put(ciphertext)derives a different CID, andput_with_id(plaintext_cid, ciphertext)fails the bridge's verification.Encrypted remote stores remain uncached, as today. Storage: return shared handles from get and move the disk cache into a store wrapper #2029 stays open for this stage.
is_remoteandpermits_plaintext_cachestay onStorageRead, still required with no default, but are read only at assembly, not by readers.No write-through on
put; seeding is explicit, throughkeep_local.The range-first and promotion policy stays in the leaf reader. The wrapper serves range reads from its entries.
Operator-visible changes
FLUREE_DICT_PACK_MMAP_MIN_BYTESis nowFLUREE_MMAP_MIN_BYTES, and covers every blob file storage serves, not only dictionary packs. A deployment that set the old name loses its setting.{digest}.{ext}names are left until budget eviction removes them.Fluree::new,from_backend,with_indexing_modeand a directly constructedLedgerManagercache remote reads, in the default directory orLedgerManagerConfig::cache_dir. Before this PR they did so through each reader's own cache directory.<data_dir>/binary_artifact_cachewheneverIndexerConfig::data_dirwas set; it is now the shared cache inLedgerManagerConfig::cache_dir(default$TMPDIR/fluree_binary_cache). A deployment that setsdata_dirbut notcache_dirnow caches index artifacts in the temp directory. Nothing opens or evicts an existingbinary_artifact_cachedirectory any more, so it can be deleted.FLUREE_MMAP_MIN_BYTESis documented indocs/operations/configuration.md.API changes for code that implements storage traits
ContentStore::getreturnsContentBytes.ContentStoreandStorageReadgainget_local, which replacesresolve_local_pathandresolve_local_bytes.ContentStoregainssupports_ranged_reads,costly_local_reads,prefetchandkeep_local, each with a default.Arc<dyn ContentStore>,BranchedContentStore,MeteredContentStoreandCachedContentStoreeach have a test that it does.update_branchandupdate_branch_streamingtake a byte typeB: AsRef<[u8]> + Sendfrom their fetch closures; closures returningVec<u8>still work.StorageBackend::cached_permanent(store, cache_dir)is new.Fluree::build_index(ledger_id)builds a ledger's index in-process with the API's indexer setup: the full-text config provider and the plaintext-staging warning.fluree indexcalls it. The warning moved out of the indexer, and the CLI, which built its ownIndexerConfig, had stopped reaching it.Tests
Each lane change and wrapper rule is pinned by a test. I checked each pin by reverting the code it covers, 55 reverts in all, and every one turned its test red. The main pins:
ContentBytesthreshold and weight tests;FileStorage::get_local: zero-length, small/large, unreadable;get_localat every candidate address on memory storage;Arc<dyn>andBranchedContentStore, including the parent fallback and the all-ancestors rule for ranged reads;CachedContentStore: served from its entry, never copies inner-local bytes, release before evict,keep_local/prefetch, coalescing in both modes, open-ended range reads from an entry, empty entries as misses, only index artifacts written.MeteredContentStoreforwards every hint.a_permanent_store_is_disk_cached_only_by_opt_in. GivingPermanenta disk cache by default turns it red.build_index_warns_when_encrypted_storage_stages_in_temp: encrypted storage warns and plain storage doesn't. Removing the warning frombuild_indexturns it red. The encryption tests no longer checkbinary_artifact_cache, which nothing writes; the shared cache they already check covers the indexer.get_localserves plaintext; an encrypted remote backend writes nothing to the cache directory.it_disk_cache_locality:Flureeconstructor wraps remote storage;LedgerManager::newcaches in its configured directory.get_local.Run on this branch, rebased on
745736a04:native)residency)grp_indexgrp_miscgrp_ledgergrp_transactit_sql_pushdown_laneThe fetch-count pins (
it_rebuild_fetch_counts,it_reindex_pre_build_perf,it_memory_storage_reads) pass unchanged.After the review fixes: core 999 (all features), indexer 439, api lib 839,
grp_index127,grp_misc320.Adjacent paths checked
getreturningNotFoundas a signal (commit export, merge, pack, sidecar prefetch) only read what exists. A cached copy has the same bytes, so they are unaffected. Verify was the one caller asking whether storage itself holds an object, and it now reads storage directly.artifact_cache_dir. A cached copy of a root that storage has already released lets the walk run past where it should end. This is documented onPrevIndexChainWalk::next_entry, and the sweep compensates with an existence check. It is not changed here.Not verified
fetch_noting_localcopying each local leaf twice, which is now fixed. The Update A/B has not been re-run since.annotation_hydration/scanre-measure for annotation_hydration/scan is 11–18% slower than #2018's merge base over memory storage #2030.MemoryStorage::simulating_remoteand fetch counts, not S3 timings.