Skip to content

wasm32 groundwork: the engine stack compiles and runs on wasm32-unknown-unknown, native untouched - #1714

Merged
aaj3f merged 29 commits into
mainfrom
wasm/groundwork
Sep 2, 2026
Merged

aaj3f merged 29 commits into
mainfrom
wasm/groundwork

Conversation

@aaj3f

@aaj3f aaj3f commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

This PR makes the whole query/api stack build — and actually run — on wasm32-unknown-unknown, while leaving the native build byte-identical everywhere we could manage it and provably-neutral everywhere we couldn't. It's the first of two PRs toward an in-browser Fluree: a "try Fluree in the browser" playground and, longer-term, a browser cache query peer that speaks the peer-proxy wire contract we already ship (CID-verified GET /storage/objects/{cid}, which turns the immutability of content-addressed blocks into a cache-forever story on the client). The second PR (browser crate + npm packaging + the server CORS/caching headers) is staged behind this one; nothing browser-facing ships here.

The headline evidence is executable, not a compile check: wasm-probe/run-node.mjs builds the probe cdylib with wasm-pack, instantiates it under Node, and drives the real end-to-end path — FlureeBuilder::memory().build_memory() → create_ledger → JSON-LD insert → SPARQL query — asserting the future's own Ok (exit 0 only on the positive marker, never on "didn't crash"). Dev and release profiles both pass. Size, for the curious: the full engine (r2rml, reasoner, BM25, and Cypher still linked in) is 6.7 MB after wasm-opt -Oz, 1.8 MB brotli over the wire.

What's actually in here

It's over-scoped on purpose — we'd rather review one coherent claim than five stacked rebases. Four layers:

1. Cargo/target surgery. The wasm-hostile deps (tokio full, memmap2, fluree-db-spatial's s2 stack, fluree-db-indexer, dirs, sysinfo) move behind [target.'cfg(...)'] splits; wasm builds get tokio's wasm-supported feature subset only. The recurring native trap was default = ["native"] cascading through dependency defaults — several deps now say default-features = false with the native feature re-enabled through fluree-db-api's native cascade, so native default builds resolve exactly as before (verified via cargo tree diffs where it seemed worth being paranoid, e.g. chrono's wasmbind). Two genuinely shippable fixes fell out regardless of wasm: fluree-db-sparql --no-default-features compiles again (fluree-vocab was optional-but-referenced — burn-down ROADMAP §5 item 9), and import_sink now sits behind the import feature with fluree-db-indexer optional, so non-import consumers of transact stop building the indexer entirely. A handful of dead deps (spatial's tokio, query's memmap2, binary-index's fs2) are gone.

2. Runtime shims — compiling was not the hard part; not panicking was. wasm32-unknown-unknown panics at runtime on Instant::now/SystemTime::now, has no threads, and std::fs returns Unsupported. So: fluree-db-core::clock re-exports std::time types on native (zero-cost alias, no wrapper) and web-time on wasm; chrono gets wasmbind (native-inert, tree-diffed); a spawn_detached seam is #[inline] tokio::spawn on native and spawn_local on wasm; moka — which has no wasm clock and panics at cache construction — stays untouched on native and swaps to an lru-backed twin behind the existing wrapper seams on wasm (all in-scope caches are TTL-free, and moka drops out of the wasm artifact entirely); and disk_cache treats Unsupported reads as a miss rather than an error, so CAS consumers fall through to fetch instead of poisoning the query (try_read_cached_bytes previously special-cased only NotFound). The full hazard audit with per-site verdicts is in wasm-tier2/C-runtime-hazards.md — it doubles as the review map for this layer.

3. Read-path groundwork ("async residency, sync compute"). The engine's five CAS-bridge chokepoints (run_sync_on_runtime and friends in fluree-db-binary-index) can't exist on single-threaded wasm, so misses there are now reported through a store-level miss register (fluree_db_core::storage::residency): the failing arm records Want { cid, kind } into the register — deduped, and multi-want where the caller knows its routed set (a cursor leaf-open miss registers the scan's whole remaining leaf set; the batched join probes pre-register every non-resident leaf across their chunk ranges) — before surfacing an error. A retry driver (fetch_wants / RetryBudget::after_error) drains the register, fetches concurrently into a pinning store, and re-enters; for scans the retry happens in-frame (the two cursor frames in binary_scan.rs re-enter the same cursor in place — no whole-query re-run, no wasted decode), with whole-unit re-run kept only as the backstop for one-shot paths (fast paths, policy sub-queries). Termination is progress-based (the pinned set grows monotonically against a finite want set), with a sanity cap guarding contract violations only. We deliberately did not make the error itself the channel: the engine stringifies io::Error at dozens of sites (QueryError::Internal(String)), so a payload-carrying error is unreliable by construction — a typed QueryError::NeedFetch variant exists as belt-and-braces (mirroring the fuel-exceeded precedent, with the fast-path sites routed through from_io), but the register is the contract. Residency itself is StorageRead::resolve_cached_bytes(&ContentId) -> Option<Arc<[u8]>> — default None, forwarded through StorageContentStore and BranchedContentStore, an Arc clone per hit on wasm. The cfg story: production native builds compile the pre-residency read path (the arms are behind cfg(any(target_arch = "wasm32", feature = "residency")) and additionally store-driven at runtime) — semantically identical, with one inlined delegation, since open_leaf_handle now splits into a wrapper over open_leaf_handle_native; "byte-identical" was one word too strong for a claim this load-bearing; the dev-only residency feature exists so native CI exercises everything — it_residency_retry runs four real-query recovery tests (operator scan, fast-path COUNT, dir-walk, policy f:query sub-query) differentially against a plain native instance over the same storage, with positive miss markers and three mutation checks. CI's --all-features clippy/nextest jobs build and run all of it — no browser required. Along the way we found and fixed two real resume hazards that exist independent of wasm: BinaryCursor advanced its leaf index before the fallible open (a caught-and-retried error would silently skip a leaf), and the leaflet-level equivalent was worse — the open leaf is take()n for the leaflet loop, so an error mid-loop dropped the remaining leaflets of that leaf. Both fixes are mutation-tested: cursor_retries_failed_cas_reads_without_losing_rows replays a remote scan against a fail-exactly-call-N store for every N, asserting exactly one surfaced error and zero lost rows; reverting either fix fails the sweep at the expected call position.

4. Toolchain + CI. zstd-sys needs a wasm32-capable clang (Apple's has no WebAssembly backend), so .cargo/config.toml routes the wasm target through scripts/wasm-clang.sh/wasm-ar.sh (Homebrew LLVM on macOS, system clang on Linux/CI). A new wasm32 CI job checks the full engine stack plus the no-default-features parse layer on the pinned toolchain — the door this PR opens stays open. One subtlety worth a reviewer's eye: an environment-level RUSTFLAGS overrides target-scoped config rustflags, so the CI job restates the getrandom_backend="wasm_js" cfg explicitly rather than trusting the config file.

The native-neutrality case

This was the prime constraint (speed and memory), and it's argued three ways. Structurally: every wasm behavior lives behind cfg(target_arch = "wasm32") or a target-split dep; native branches are the previous code, and the shims that do touch shared lines are zero-cost re-exports or #[inline] one-liners. The one deliberate shared-code touch is a factored-out success_or_size_error helper in the multi-query dispatcher (once-per-subquery, not hot). Empirically: the native suites are green at this HEAD — workspace check --all-targets, core (803), binary-index (383, including the new mutation-tested sweep), query (1416+), transact/novelty/nameservice-sync (89 incl. wiremock), api (774 lib + all grp_* targets), server. And going forward: the nightly bench baseline gate watches the same paths; we'd suggest a workflow_dispatch bench compare on this branch as an advisory pre-merge check. Locally, a criterion A/B on fluree-db-api's query_hot_bsbm and transact_commit benches (merge-base vs. this branch, run back-to-back so both see the same thermal state; 10 samples each) comes out indistinguishable: q3 −3.1% (p=0.39), q5 −0.4% (p=0.30), q9 −1.0% (p=0.01 — the one statistically significant point, and it's one percent), transact_commit fresh −0.4% (p=0.18), populated +2.5% (p=0.13), where negative means the base was faster. Fwiw, a first naive pass right after the full release build showed a uniform "+120%" that vanished on re-run of the identical branch binary — the box, not the code — which is a good reminder that the shared-runner compare being advisory is the right call. Two transparency notes on top of that: this PR touches perf-relevant paths, so the new bench-compare CI gate will run and annotate both TIME and PEAK_MEM against the committed baseline — I'd treat its output as part of this review; and I did not run a dedicated memory-profile A/B locally (the structural argument for memory neutrality is the same cfg story as speed — native allocates nothing new — but the tracking-allocator numbers from CI/nightly are the honest check, and I'll watch them).

On the bench-compare annotations (please read before reacting to them). This PR's run posts advisory wall-clock regressions — query_hot_bsbm/q3 +54.77%, groupby_overlay +100.60%, a dozen more in that range. They are not this PR. The committed baseline was captured with host_class=local and CI runs ci-ubuntu-latest, which ci.yml's own comments call a refusal-by-default comparison — that mismatch is exactly why every one of those lines says advisory and the job exits 0. The check: main's own runs against the same baseline show the same annotations, and larger ones — main at 8b6a0454a reports q3 +81.44% / groupby_overlay +117.77%, and at 5d3afe284 q3 +94.77% / groupby_overlay +110.79%, versus this branch's +54.77% / +100.60%. We are below main on the same yardstick; the spread is shared-runner noise against a stale local baseline. Meanwhile peak_mem, which does compare meaningfully across host classes, is flat to four decimal places on every scenario (−1.85% to +0.08%, most exactly 0.00%) — that is the memory-neutrality evidence, and it is clean. The way to make these lines meaningful again is to capture a ci-ubuntu-latest baseline via bench.yml's workflow_dispatch and commit it, at which point both metrics start enforcing automatically; that is a repo-level task this PR deliberately doesn't smuggle in.

One rebase-reconciliation note: this branch is rebased over bf63e54e6 ("gate rand behind native"), which independently fixed a wasm32 build break in fluree-db-core and observed that "nothing in CI builds it" for the wasm target — the wasm32 CI job in this PR is exactly that missing enforcement, so these two changes compose rather than collide (the optional-rand gating and the target-scoped JS-randomness unification serve different layers of the same problem).

Decisions that warrant scrutiny (and the alternatives they beat)

These are the calls I'd want a second opinion on if someone else had made them — each with what we didn't do and why:

  • PolicyQueryExecutor is now Send + Sync on all targets. Alternative: keep the pre-existing cfg'd ?Send wasm twin. Rejected because the engine's operator futures are uniformly Send-bounded, so the ?Send variant could never actually carry a policy executor through the engine — it predates that design and had become the blocker rather than the enabler. Single-threaded wasm implementors satisfy the bounds trivially; a future JS-backed executor should bridge via channels rather than infect the engine with ?Send.
  • The miss channel is a store-side register, not an error payload. Alternative (and our own first implementation): carry NeedFetch inside io::Error and downcast above the operator boundary. Our internal adversarial review killed it with evidence — QueryError has no io-carrying variant and the engine stringifies at ~dozens of sites, so the payload provably never survives a real path. The register is immune to stringification and also enables multi-want reporting (a round learns a scan's whole routed leaf set, not one CID). The typed QueryError::NeedFetch variant still exists as belt-and-braces on the frames that do preserve io errors.
  • disk_cache compiles on wasm with a zero byte-budget (writes disabled, reads miss) instead of being cfg'd out. Keeps one code path for CAS consumers; if you'd rather see it excluded wholesale on wasm, happy to talk that through.
  • Clocks: web-time re-export shim, not stubbed-out timing. Alternative: cfg the instrumentation away on wasm. Rejected — the query timing fields are wanted in a browser too (performance.now works there), and on native the shim is pub use std::time::{Instant, SystemTime}, a zero-cost alias with no codegen change.
  • moka's wasm stand-in is lru + an uncontended parking_lot Mutex, not quick_cache. No new workspace dep, and on a single-threaded target an uncontended lock beats sharding. The trade accepted: get_with is check-compute-insert, not single-flight — correct single-threaded, and it makes re-entrant use inside a decode closure deadlock-free. Native keeps real moka everywhere; the wasm artifact drops moka entirely.
  • The residency arms are cfg(any(wasm32, feature = "residency")) + runtime store-driven, rather than wasm-only. The extra feature exists purely so native CI can execute the whole machinery (--all-features picks it up); production native builds compile the pre-residency path — semantically identical, one inlined delegation through the open_leaf_handle wrapper — and even with the feature on, the check is one predictable branch per blob open — never per row — engaged only by register-bearing stores.

Honest caveats

  • This PR makes wasm run, not ship: no JS API, no browser storage, no prefetch loop. A cache miss on a remote ledger under wasm errors with NeedFetch by design until PR-2's retry loop lands.
  • One mid-assembly mistake is worth naming because it's in the history: the original spike commit replaced .cargo/config.toml wholesale and deleted the workspace RUST_MIN_STACK = 8388608, which made proxy_integration/grp_graphsource abort with stack overflows that looked exactly like code regressions. Restored in fix(build): restore RUST_MIN_STACK… with the mechanism documented; if you've ever wondered whether that env entry is load-bearing — it is.
  • Commit hygiene: two probe support files (wasm-probe/README.md, Cargo.toml metadata) landed in the chore: zero out wasm32-target warnings commit rather than the harness commit due to a concurrent-assembly race; content is correct, attribution is smeared. Flagging rather than rewriting shared history.
  • The design memos this work executed against (read-path design, runtime-hazard audit, worker/parallelism research, browser I/O contract) are deliberately kept out of the repo; happy to share them directly with anyone reviewing.

Approving reviewers may want to start with fluree-db-core/src/clock.rs + wasm_cache.rs (the shim map), then fluree-db-binary-index/src/read/need_fetch.rs and the cursor fixes in binary_cursor.rs — that's where the substance is; the Cargo diffs are wide but mechanical.

Pre-review remediation (before undrafting)

Before asking anyone to spend review time here, we ran this PR through an internal blind pre-review (a reviewer with no context on the work, applying the repo's own review rubric, checking out the PR like any colleague's). Verdict was approve-with-notes (no critical or high findings). Every finding was then either fixed or rebutted with evidence — the remediation commits are on the branch, findings referenced in their messages. Highlights: the MissRegister now documents its one-draining-query-per-store scope; the wasm32 CI job gates warnings (-D warnings) so the zero-warning build can't rot silently; the node smoke asserts the inserted names actually round-trip in the FORMATTED result (the first fix of this fix asserted against Debug output, which renders encoded bindings — the smoke's own honest exit code caught it); spawn_maintenance/bm25 worker are native-gated; two stale comments corrected. The pre-review also independently re-applied the cursor mutation to confirm the test sweep catches it, and machine-diffed cargo tree to confirm native dependency-resolution neutrality.

One more found by CI rather than by us, worth stating plainly: the first CI run failed clippy on five workspace-denied manual_assert sites in the residency test helper. Local per-crate clippy runs had reported clean — a clippy invocation over an already-checked tree can be a fingerprint no-op that prints nothing and exits 0. Fixed, and re-verified by grepping the pattern to zero, forcing a re-check, and running the CI-shaped workspace invocation with -D warnings.

Three corrections from review, and where a regression would show first

Raised in review, verified independently, and worth carrying in the description rather than only in the thread.

The benches compile the residency build, not the production one. fluree-db-api's dev-dependencies enable residency, and under resolver = "2" that unifies into every target needing dev-deps — including cargo bench -p fluree-db-api --bench query_hot_bsbm (ci.yml:227, bench.yml:101) and the local A/B above. Measured:

cargo tree -p fluree-db-api -e features,no-dev  ->  no residency
cargo tree -p fluree-db-api -e features         ->  fluree-db-binary-index feature "residency"
                                                    fluree-db-query        feature "residency"

Two consequences, and both cut in directions worth stating plainly:

  1. The A/B is stronger than claimed, not weaker. It measured a residency-enabled build against a base containing none of that code, and still landed in the noise. That is the conservative direction.
  2. No perf gate measures the production configuration. "Production native builds compile the pre-residency read path" is true, and it rests on the cfg argument alone — which is sound, because cfg'd-out code is absent rather than predicted-away. But the numbers demonstrate something adjacent to that sentence rather than that sentence itself. Keeping the feature out of bench unification is fiddly and buys little; saying so exactly is better than implying the benches cover what ships.

That second point is also why this PR now caches the residency-tier probe (residency_mode) as a bool at construction instead of calling dyn ContentStore::miss_register() — six sites, up to ~3 per leaf open. It is a small win on wasm, and it removes dyn dispatch from the build the benches actually compile, narrowing the measured-vs-shipped gap rather than widening it.

Where to look first if a regression ever appears. binary_cursor.rs:417 is the only unconditional native change on a scan path — the success path is unchanged and it is per-leaflet against a µs-scale column decode, so codegen-perturbation risk is low (CLAUDE.md's own note is that fat-bodied loops tolerate this fine). It is named here rather than leaving the diff to read as uniformly risky: that line is the first place to check, and the bench covering it runs the feature-on build.

Caught up to main + adversarial-review remediation (2026-09)

Rebased onto current main (49 commits; semantically verified, not just textually — cargo check --all-targets after restack plus the wasm and residency suites at each level). A second independent adversarial review of the wasm read-path (the surface the earlier review deliberately set aside) then added, all verified by execution:

  • Bounded residency read-ahead + airtight retry termination. One leaf-open miss registered the WHOLE remaining routed leaf set, so a LIMIT-shaped scan fetched the entire predicate run and — under a byte-budgeted resident tier — livelocked (re-fetching an evicted tail forever, stopped only by the sanity cap). Now records a bounded window, and RetryBudget::after_error gates progress on the specific blocking object becoming resident, so termination holds against any tier size. Mutation-checked (bounded-window count; a non-pinning store fails on round 1, not at the cap).
  • create_dir_all no longer aborts a wasm index load (cherry-picked the ensure_cache_dir no-op-on-Unsupported fix down so this PR is self-consistent; it drops patch-identical when the fix's own commit rebases through).
  • The reasoner's Instant::now() — which traps on wasm — routes through clock::Instant.
  • The wasm32 CI job now runs clippy, not just check, which caught three latent wasm-only lints (a dropped future in the CAS bridge stub, a needless return in a storage arm, an into_iter loop in the sequential multi-query twin) that check and the host clippy job could never see.

bplatz's four inline items from the first review remain addressed as replied; these are additive on top.

aaj3f added a commit that referenced this pull request Aug 27, 2026
Internal pre-review of #1714 (approve-with-notes; findings applied on merit):
- MissRegister documents its one-draining-query-per-store scope — a future
  multi-query driver must serialize drains or scope wants (M-1)
- the wasm32 CI job now gates warnings via -D warnings in its restated
  RUSTFLAGS; cargo check alone exits 0 on warnings (M-2)
- the node probe asserts the inserted names round-trip instead of
  accepting any Ok (L-1)
- spawn_maintenance and the bm25_worker module are native-gated: they can
  only spawn onto an ambient tokio runtime (L-2)
- the core uuid-unification comment names the real wasm uuid consumers
  (query UUID()/STRUUID(), policy) instead of native-only moka; wasm_cache
  documents its TTL-free boundary
@aaj3f
aaj3f marked this pull request as ready for review August 27, 2026 16:58

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Reviewed for native performance impact specifically — not the wasm design or read-path correctness. Please look at the four inline items before merging; only the Cargo one changes anything you've written.

What I verified, independently rather than from the description:

  • cargo tree -p fluree-db-api -e features,no-dev — no residency in the production graph. -e features — present via dev-deps.
  • The three files CLAUDE.md names as hot-loop-critical (count_plan_exec, fast_path_common, fast_union_star_count_all) contain only from_io inside .map_err() on open_leaf_handle (error path) and two Instant::now() calls outside any loop. No per-row or per-group code added.
  • clock.rs native is pub use std::time::{Instant, SystemTime} — real alias, same function.
  • wasm_compat::SpatialIndexProvider native is a re-export of the same trait, so the spatial_provider_map signature change is an import-path rename, same vtable.
  • leaflet_cache.rs keeps moka and in_blocking_region unchanged; only arc_io_error on the loader-error path.
  • leaf_access.rs additions wholly cfg-gated; no existing handle modified.
  • raw_txn_upload.rs native code unchanged, attributes only.
  • residency_mode is itself cfg-gated, so a default-feature native build gets no dead_code warning (--all-features clippy would not have caught that one).

I did not re-run benches — a criterion A/B here is hours and shared-runner noise is the documented problem. The confidence above is structural.

Comment thread fluree-db-api/Cargo.toml
# Residency-mode read arms for the wasm read-path recovery tests
# (it_residency_retry): dev-only feature unification — production native
# builds keep the feature off and compile the pre-residency read path.
fluree-db-query = { path = "../fluree-db-query", features = ["residency"] }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These dev-deps enable residency, and under resolver = "2" that unifies into every target needing dev-deps — including cargo bench -p fluree-db-api --bench query_hot_bsbm (ci.yml:227, bench.yml:101) and the local A/B in the description. Verified:

cargo tree -p fluree-db-api -e features,no-dev  ->  no residency
cargo tree -p fluree-db-api -e features         ->  fluree-db-binary-index feature "residency"
                                                    fluree-db-query        feature "residency"

(cargo test -p fluree-db-query alone stays off.)

Two consequences worth stating in the description:

  1. The A/B measured the residency-enabled build against a base with none of that code, and still landed in the noise — the conservative direction, so the result is stronger than claimed.
  2. No perf gate measures the production configuration. "Production native builds compile the byte-identical pre-residency read path" is true and rests on the cfg argument alone, which is sound — cfg'd-out code is absent, not predicted-away. The numbers demonstrate something adjacent to that sentence rather than that sentence.

Suggest saying both rather than changing the Cargo setup — keeping the feature out of bench unification is fiddly and not worth it.

/// bridge tiers. Always true for browser stores; native stores opt in
/// (tests) by exposing a register.
#[cfg(any(target_arch = "wasm32", feature = "residency"))]
pub(crate) fn residency_mode(&self) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest caching this as a bool field set at construction.

It's a vtable call through dyn ContentStore, and it's called at six sites (:654, :733, :814, :1049, arena/vector.rs:706, :736) — up to ~3 per leaf open. The CAS and its miss register are fixed for the store's lifetime, so the result can't change.

Second reason beyond the call itself: it removes dyn dispatch from the feature-on build, which is the build every bench measures per the Cargo comment. Narrows the gap between what's benchmarked and what ships.

need_replay: bool,
) -> io::Result<Box<dyn super::leaf_access::LeafHandle>> {
#[cfg(any(target_arch = "wasm32", feature = "residency"))]
if self.residency_mode() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wording: with the split into open_leaf_handle + open_leaf_handle_native, "byte-identical" in the description is one word too strong — native is now a delegation through a wrapper, almost certainly inlined but structurally one hop.

No code change suggested. "Semantically identical, one inlined delegation" would be accurate, and this claim carries enough weight in the PR to be worth stating exactly.

// cursor re-enterable — the next `next_batch` call retries
// this same leaflet (transient I/O, wasm NeedFetch miss)
// instead of dropping it and the rest of the leaf.
let load_result = (|| -> io::Result<ColumnBatch> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change requested — flagging as the watch point.

This is the only unconditional native change on a scan path. Success path is unchanged and it's per-leaflet against a µs-scale column decode, so codegen-perturbation risk is low (CLAUDE.md's own note: fat-bodied loops tolerate this fine). But it's where shared code moved inside the hot scan driver, and the bench covering it runs the feature-on build.

Worth naming in the description as the first place to look if a regression ever appears, rather than treating the whole diff as uniformly risky.

aaj3f added a commit that referenced this pull request Aug 28, 2026
Internal pre-review of #1714 (approve-with-notes; findings applied on merit):
- MissRegister documents its one-draining-query-per-store scope — a future
  multi-query driver must serialize drains or scope wants (M-1)
- the wasm32 CI job now gates warnings via -D warnings in its restated
  RUSTFLAGS; cargo check alone exits 0 on warnings (M-2)
- the node probe asserts the inserted names round-trip instead of
  accepting any Ok (L-1)
- spawn_maintenance and the bm25_worker module are native-gated: they can
  only spawn onto an ambient tokio runtime (L-2)
- the core uuid-unification comment names the real wasm uuid consumers
  (query UUID()/STRUUID(), policy) instead of native-only moka; wasm_cache
  documents its TTL-free boundary
@aaj3f

aaj3f commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — this is the review I wanted, and the independent verification is appreciated. All four addressed; one changed code, three changed what the description claims.

The dev-dep feature unification. Reproduced your cargo tree result exactly (0 in the no-dev graph, 3 with dev-deps). Taking your suggestion to state it rather than fight the Cargo setup, the description now says both consequences plainly: that the A/B measured a residency-enabled build against a base with none of that code and still landed in the noise — the conservative direction, so the result is stronger than it claimed — and that no perf gate measures the production configuration, so "production native builds compile the pre-residency read path" rests on the cfg argument alone. That argument is sound because cfg'd-out code is absent rather than predicted-away, but the numbers demonstrate something adjacent to that sentence rather than that sentence, and saying so exactly is better than implying otherwise.

The vtable call. Implemented. residency_mode is now a bool computed at construction. I checked your premise before relying on it: cas is assigned once in each of the two constructors, never reassigned, and the field is private, so the cached answer cannot go stale. The field carries the same cfg as the method, so a production native build has neither; verified building in all three configurations (default native, --features residency, wasm32).

Your second reason is the one that persuaded me over the call cost itself — it takes dyn dispatch out of the build the benches actually compile, so it narrows the measured-vs-shipped gap rather than widening it, which given the point above is worth more than the cycles.

"Byte-identical." Fair, and corrected in both places it appeared. It now reads "semantically identical, with one inlined delegation," naming the open_leaf_handle / open_leaf_handle_native split as the reason. A claim doing this much load-bearing work should be stated exactly.

binary_cursor.rs:417. Adopted as written — the description now names it as the first place to look if a regression ever appears, with your reasoning (only unconditional native change on a scan path, success path unchanged, per-leaflet against a µs-scale column decode, and the bench covering it runs the feature-on build). Better than leaving the diff to read as uniformly risky.

aaj3f added 21 commits September 2, 2026 08:43
Compile-focused spike: fluree-db-api (--no-default-features) and every crate
under it now cargo-check clean for wasm32-unknown-unknown, and wasm-probe/
links a size-measurement cdylib driving the real end-to-end path
(memory ledger -> create_ledger -> JSON-LD insert -> SPARQL query).

Measured (opt-level=z, fat LTO, strip, panic=abort):
  raw 7.59 MB -> wasm-opt -Oz 6.67 MB -> gzip 2.5 MB / brotli 1.8 MB

Seam classes (details in root wasm-tier1-spike.md deliverable):
- target-split Cargo deps: tokio full vs wasm feature subset; memmap2,
  fluree-db-spatial, fluree-db-indexer, dirs native-only
- wasm_compat shims: mmap stand-in (fails over to Owned/CAS bytes),
  SpatialIndexProvider stub trait, api-side indexer type stubs
- sync->async CAS bridge (run_sync_on_runtime) stubbed on wasm: bytes
  must be pre-cached; real async read path is the follow-up design work
- policy PolicyQueryExecutor unified to Send+Sync on all targets
  (removed stale ?Send wasm variant that predated the Send-bounded engine)
- 32-bit usize hazards: 8GiB budget const, 1usize<<62 cgroup sentinel
- js randomness unification (getrandom 0.2/0.3, uuid) declared in core
- fixes shippable on their own: sparql no-default-features vocab bug
  (ROADMAP item 9), dead tokio dep in spatial, dead memmap2+stale
  comment in query, dead fs2 in binary-index, import_sink now behind
  the import feature with fluree-db-indexer optional

Native: cargo check --workspace --all-targets green except the
pre-existing usearch/cxx host-toolchain failure (fails identically on
untouched main).
scripts/wasm-clang.sh / wasm-ar.sh pick a wasm32-capable clang per platform
(Homebrew LLVM on macOS where Apple clang has no WebAssembly backend, system
clang on Linux/CI); .cargo/config.toml routes zstd-sys through them instead
of a hardcoded macOS path. New ci.yml wasm32 job checks the full engine
stack and the no-default-features parse layer for the wasm target.
Three shared foundations for making the wasm32-unknown-unknown build
survive at runtime (std::time clock reads and moka cache construction
abort there):

- fluree-db-core::clock — Instant/SystemTime that work on every target.
  Native neutrality: on native these are plain re-exports of std::time
  (zero-cost type aliases; call sites compile to exactly what they had),
  wasm32 gets web-time (performance.now/Date.now via wasm-bindgen).
  web-time is already in the wasm dependency graph via ulid, so no new
  transitive deps.

- fluree-db-core::wasm_cache — clock-free moka::sync::Cache stand-in
  (byte-weighted exact LRU over the lru crate, uncontended parking_lot
  Mutex — cheap on a single-threaded target). moka 0.12 constructs its
  eviction clock from std::time::Instant::now() inside Cache::builder(),
  which aborts on wasm32. None of the wasm-reachable caches use TTL/TTI,
  so LRU-with-weigher is a faithful replacement. Compiled on native only
  under cfg(test) so its unit tests run there; native builds never link
  it (lru is a wasm-target + dev dep only).

- chrono 'wasmbind' at the workspace dep: activates only chrono's own
  wasm32 cfg branch (js-sys/wasm-bindgen are target-gated optional deps
  inside chrono) so Utc::now() reads Date.now() instead of aborting in
  SystemTime::now(). Native cargo tree for chrono verified byte-identical
  before/after.
…efault

std::time::Instant::now()/SystemTime::now() abort on wasm32-unknown-
unknown. Every reachable call site moves to fluree_db_core::clock —
which on native re-exports std::time, so these are import-line swaps
with zero native codegen change; the timing instrumentation itself is
kept (performance.now works on wasm via web-time):

- query operator driver (execute/runner.rs — fired on EVERY query) and
  the sort/groupby/aggregate/group_aggregate/optional/join drain timers,
  datalog fixpoint timing, COUNT fast-path timers
- binary-index dict reader slow-lookup timing
- novelty bulk_apply_commits timing (ledger-load path)
- core tracking (opt-in track_time)
- transact generate_txn_id: SystemTime via the shim — the previous
  unwrap_or_default() could not help because the panic is inside now()
  itself; fires on every staged transaction
- api ledger_manager idle-TTL stamps (monotonic_secs, entry touch) and
  compaction timing

ledger_manager also gets a cfg'd default_cache_dir(): the previous
std::env::temp_dir() default PANICS on wasm32 ("no filesystem on this
platform") and runs inside FlureeBuilder::memory(), before any build
method. The wasm synthetic path is never touched at runtime — the disk
cache opens with a zero write budget there and reads map Unsupported to
a miss.
moka 0.12 aborts at Cache::builder()/new() on wasm32 (std Instant clock
origin), which made every Fluree init path panic: the leaflet cache,
GovernanceCache, and R2rmlCache are all constructed inside
finalize_with_backend, and the ontology-imports + fulltext caches on
first use.

Each seam shadow-imports fluree_db_core::wasm_cache::Cache under
cfg(target_arch = "wasm32") — one cfg'd use line (or a SyncCache alias
where the moka path was spelled inline). Native neutrality: the native
arm of every cfg pair is the exact moka type as before; no wrapper, no
dyn. The seams' moka API surface (get/get_with/try_get_with/insert/
contains_key/invalidate_all/run_pending_tasks/weighted_size/entry_count/
policy().max_capacity()) is mirrored by the stand-in, including
try_get_with's Arc<E> error type.

moka itself moves to cfg(not(wasm32)) target deps in binary-index,
query, and api — native resolves identically; the wasm artifact drops
moka entirely (smaller binary, and no accidental future construction).

wasm-bindgen-futures lands in api's wasm target deps here as well; its
consumer (the spawn_detached seam) arrives in the next commit.
…beat cfg

wasm32 has no ambient tokio runtime (tokio::spawn panics "must be
called from within a runtime") and cannot spawn OS threads
(std::thread::spawn panics). Per-site treatments, chosen so the native
path keeps its exact concurrency shape:

- api::wasm_compat::spawn_detached — #[inline] tokio::spawn on native
  (same discarded JoinHandle as before), wasm_bindgen_futures::
  spawn_local on wasm32. Applied at the init-path cache event listener,
  which fires on every build with ledger caching on (the default).

- cypher_typed prefetch fallback: the native chunked tokio::spawn
  fan-out is unchanged inside cfg(not(wasm32)); wasm32 runs the same
  point reads inline (single thread — the fan-out buys nothing, and
  available_parallelism there reported a fictitious 4).

- transact PendingRawTxnUpload (opt-in store_raw_txn): wasm32 twin
  defers the upload as data and runs it inside finish(); nothing is
  stored before that, so abort/Drop have nothing to release and the
  native Drop-guard/abort machinery isn't needed. Native struct and
  impls are byte-identical inside their cfg.

- multi-query dispatcher: native parallel JoinSet + semaphore + deadline
  path unchanged inside cfg(not(wasm32)); wasm32 twin runs sub-queries
  sequentially with no deadline enforcement (JoinSet::spawn would panic
  and tokio::time aborts at its first clock read). The per-sub-query
  size check is factored into success_or_size_error, shared by both —
  the one native-code refactor in this series (once-per-subquery call,
  not hot-loop).

- batched_lookup progress heartbeat (telemetry only): native-only cfg
  block; its file-level Instant import moves to core::clock (the scan
  timer at the top of the lookup was another every-call abort).

dict_io.rs / binary-index lib.rs / tx.rs hunks are rustfmt import-order
corrections to spike-branch files (this branch now passes cargo fmt
--check as a whole).
On wasm32-unknown-unknown every std::fs call fails with
ErrorKind::Unsupported. try_read_cached_bytes mapped only NotFound to a
miss, and fetch_cached_bytes/_cid apply ? to its result BEFORE
attempting the CAS fetch — so on wasm the pre-checks propagated
Unsupported as the read's error and the authoritative fetch was never
reached: every binary-index artifact load failed outright. (The
coalesced_fetch leader path already had the correct tolerant shape;
only the outer pre-checks were wrong.)

Fix at the single choke point: map Unsupported to Ok(None) alongside
NotFound, extracted as read_result_as_cache_outcome so the mapping is
unit-testable with synthetic errors on native. Real I/O failures
(EIO, EACCES) still propagate. Correct on all targets — native gains
nothing but loses nothing (Unsupported from a local fs read means the
cache dir can't serve reads; falling through to the remote fetch is
exactly what the coalesced leader already did in that case).

Writes were already safe: budget 0 on wasm (create_dir_all fails →
writes disabled) and best_effort_write early-returns at budget 0, so
eviction/scan paths never run there.
On wasm32 there is no sync->async CAS bridge (single thread, no block_on), so
the Tier-1 spike stubbed `run_sync_on_runtime` to a flat error: a cache miss
failed the query with no way to recover. Give the sync read path a way to
report *what* it needs instead.

`ContentStore::resolve_cached_bytes(&ContentId) -> Option<Arc<[u8]>>` is the
new sync residency tier: default `None`, forwarded through the `Arc<dyn>`
blanket impl and `BranchedContentStore`. On a hit it is one hash lookup
returning a shared `Arc` clone — no copy, no I/O, never blocks. On a miss the
read path raises `NeedFetch { cid, kind }`, carried inside `io::Error` as a
custom payload so no accessor signature changes: the same channel the fuel
tracker already tunnels through the sync cursor. An async caller above the
operator boundary catches it (`from_io_error`, or `find_in_chain` for wrapped
errors), fetches the named object, and re-runs.

All five CAS-backed read families get a wasm arm that consults residency and
bypasses the filesystem tiers — those probe `std::fs`, which returns
`Unsupported` on wasm and would propagate as a hard error rather than falling
through to a fetch: leaf blobs and history sidecars (via a zero-copy
`SharedBlobLeafHandle`), leaf byte ranges, dict-tree leaves, forward packs,
and vector shards. Reaching the bridge stub now means a read path gained a
CAS call without a residency arm, so it stays an error rather than becoming
unreachable.

Native is untouched by construction: each accessor cfg-selects between its
existing body (renamed `*_native`, unchanged) and the wasm arm, so there is no
added branch, no dynamic dispatch, and no layout change on the hot leaf-open
path. `NeedFetch` is constructed on zero native paths.

Tests drive the miss/fetch/retry round trip natively against a miss-injecting
content store, asserting the miss actually fires, names its CID, walks a
wrapped `source()` chain, and returns the same allocation on a repeat hit.
`next_batch` advanced its cursor state before the fallible reads, so an error
lost data instead of being retryable:

- The leaf open ran after `current_leaf_idx += 1` and overlay slicing, so a
  failed open skipped that leaf entirely on any later call.
- Worse at leaflet granularity: the open leaf is `take()`n for the duration of
  the loop, so a `?` on the column load or the history-sidecar read dropped
  the leaf handle along with every remaining leaflet in it.

Neither was reachable as data loss on native today (a failed CAS read fails
the query), but both block retry-after-fetch — the recovery model wasm needs,
where a miss is normal and the caller fetches the named object and re-enters.

Open the leaf before advancing, and isolate the leaflet's fallible reads so a
failure rewinds the leaflet index and restores the leaf before propagating.

The test measures a remote scan's CAS call count, then replays the scan once
per call position against a store that fails exactly that call, requiring
exactly one surfaced error and zero lost rows each time. Both fixes were
mutation-checked against it: restoring the advance-before-open loses rows at
call 1, dropping the leaflet rewind loses them at call 4.
Found by RUNNING the wasm probe under node (wasm-pack --target nodejs +
manual poll loop), not by the static audit: with every other fix in
place, build_memory/create_ledger/insert all survived and the trap
moved to Fluree::query_with_options — six std::time::Instant::now()
sites in view/query.rs (parse/plan/exec timers on both the
QueryInput and the reasoning-override arms).

These are the api-layer counterparts of the fluree-db-query operator
timers already routed through the shim; the audit's api sweep missed
them because it filtered by native-gated MODULE and these live in an
un-gated one. Same treatment, same native neutrality: on native
core::clock::Instant IS std::time::Instant.

With this the probe completes the full path in a real wasm runtime —
FlureeBuilder::memory().build_memory() -> create_ledger -> JSON-LD
insert -> SPARQL query returns Ok on the first poll, in both the dev
and release builds.
Each item is attribution, not behavior: Durability's env/sync helpers cfg-allow
dead when the native FileStorage that consumes them is compiled out; the
connection File-storage arm becomes cfg-if instead of early-return (unreachable
statement); DT_ID's re-export follows its only consumers behind the import
feature; two api helpers whose callers are native-gated get matching gates.
wasm32 api check and native workspace check both clean.
run-node.mjs builds the probe with wasm-pack (--target nodejs), appends a
raw-exports handle to the generated glue (the nodejs wrapper re-exports only
wasm_bindgen items and the probe's entry points are deliberately raw
extern-C), instantiates under Node, and drives probe_start + probe_poll to
completion.

Exit 0 requires the positive ran-marker — poll code 1, the probe future's
own Ok — never "didn't crash": Pending exhaustion, Err, not-started, and
traps all exit non-zero. The failure lane is exercised (poll-before-start
returns -2 and would exit 1).

wasm-pack's bundled wasm-opt pass is disabled for the release profile via
package metadata: installed wasm-opt binaries can reject bulk-memory output,
and correctness is this harness's job — size measurement keeps its own
explicit wasm-opt invocation (README).

Run at the wasm/groundwork merge (shims + stage0): dev and release both
PASS — build_memory -> create_ledger -> JSON-LD insert -> SPARQL query
returns Ok on the first poll under node v22.
…ut dirs

The probe is becoming the CI smoke test — a fresh clone must resolve the
same dep graph the harness was verified against. pkg-node-{dev,release}
are generated by run-node.mjs and stay untracked.
… rewrite

The tier-1 spike replaced .cargo/config.toml wholesale with the wasm
clang wiring, deleting the workspace [env] RUST_MIN_STACK = 8MB setting
whose comment names proxy_integration as a suite whose debug-build async
chains overflow the 2MB default test-thread stack. Result: on the spike
base (before any later commit) test_remote_mount_queries_and_write_rejection
aborts deterministically with a stack overflow, while untouched main
passes — verified three-point (main pass 3/3, spike base fail 3/3,
this branch fail 3/3, all isolated runs).

Merge both concerns: the original [env] block is restored verbatim with
the spike's wasm CC/AR entries appended and the wasm32 target rustflags
kept unchanged.
…ning)

Review finding: the wasm32 job was compile-only, so the entire runtime-hazard
class this branch fixes (clock panics, thread spawns, moka construction) could
regress unseen. The job now installs wasm-pack and runs wasm-probe/run-node.mjs,
which exits 0 only on the probe future's own Ok.
Two gaps kept the sync residency tier from working end to end:

The typed `NeedFetch` error was the only miss-reporting channel, and it is
severed before any retry frame can see it — `QueryError` has no io-carrying
variant, `from_io` stringifies, and ~200 query-crate sites format errors
into strings. The new `storage::residency` module makes the STORE the
carrier instead: a `MissRegister` on the content store records every
`(ContentId, FetchKind)` want at miss time, deduplicated, and a retry frame
reacts to ANY execution error by draining it — non-empty means fetch the
wants and re-run, empty means the error was real. Immune to every present
and future error conversion. Callers that know their unit's whole routed
want set record all of it, so one retry round learns N objects, not one.

The hook was also unreachable through the assembly path a browser peer
uses: `build_with(storage)` wraps `StorageRead` in `StorageContentStore`,
which never overrode `resolve_cached_bytes`, and `StorageRead` had no
residency method to forward to. `StorageRead` now carries the same
CID-keyed `resolve_cached_bytes` (no address formatting on the lookup) and
`miss_register`, forwarded through `Arc<dyn Storage>`,
`StorageContentStore` (mirroring `resolve_local_path`), `Arc<dyn
ContentStore>`, and `BranchedContentStore`.

The participation contract lives on `ContentStore::miss_register`:
returning `Some` switches the sync read path to residency-only serving, and
obliges the store to pin bytes returned by `get` (fetch-pins), which is
what makes retry progress monotone and progress-based termination sound.
…ing, retry primitives

Reworks the read tier's wasm arms around the store-level miss register and
makes the residency machinery natively testable:

- The residency arms are no longer wasm-only: every dispatch site compiles
  under `cfg(any(wasm32, feature = "residency"))` and engages at runtime
  only for a store that exposes a miss register (`residency_mode()`), so a
  native test with a residency-tier store drives the exact read path wasm
  gets. Production native builds keep the feature off and compile the
  pre-residency path unchanged; wasm without a residency store falls
  through to the native body rather than a dead end.

- Multi-want reporting (one round learns N objects, not one): a cursor
  leaf-open miss registers the scan's whole remaining routed leaf set (and
  sidecars when replaying); the three `batched_lookup_*` probes register
  every non-resident leaf their routed chunk ranges will touch before
  opening anything; `resident_or_need_fetch` records each individual miss.

- Async retry primitives in `need_fetch`: `fetch_wants` (concurrent
  fetch-and-verify against the fetch-pins contract) and `RetryBudget`
  (drain → fetch → re-run with progress-based termination — a round must
  pin at least one new object — plus a sanity cap). These are the drain
  API the api-level loop and the browser driver build on.

- `leaflet_cache` loader errors preserve a typed `NeedFetch` through the
  `Arc<io::Error>` re-wrap instead of stringifying it (the `open_leaf_dir`
  dir-load closure is the path that cannot resolve residency before
  entering the loader; everything else should, and the dict-leaf path
  does).

- The module docs no longer claim the typed error is catchable above the
  operator boundary — it is not (real wrappers stringify); the register is
  the contract and the typed payload is best-effort for io-preserving
  frames and log messages.

Tests: register round trip (miss recorded, drained, fetched, re-run hits,
zero-copy pin), want-set dedupe and one-round multi-fetch, no-progress
stall surfacing as an error, and — feature-gated — a two-leaf cursor scan
whose first miss must register BOTH routed leaves and complete after one
fetch round (mutation-checked: dropping the whole-set registration fails
it on the single-CID register).
The scan operator's cursor loops (`next_batch` and `drain_count`) gain an
in-frame drain/fetch/retry arm under `cfg(any(wasm32, feature =
"residency"))`: on a cursor error with a register-bearing store, the async
frame drains the miss register, awaits the fetches (concurrently, the
unit's whole want set), and re-enters the SAME cursor — which the earlier
re-enterability fix made safe — so scans recover in place with no
whole-query re-run and no wasted decode. One-shot paths (fast paths,
policy sub-queries) still surface the error and recover through the outer
drain loop.

Belt-and-braces typing alongside the register: `QueryError::NeedFetch`
carries the typed payload where the `io::Error` survives, downcast in
`from_io` exactly like the `FuelExceededError` precedent, and the 30
`Internal(format!("leaf open: {e}"))` sites across the fast paths now
route through `from_io` (same message when the payload is absent). The
register remains the load-bearing channel — these conversions only improve
what io-preserving frames and logs see.

Also: the wasm `Pattern::S2Search` arm returns `UnsupportedFeature`
instead of `Internal` (it is a capability gap, not a bug), and the
`wasm_compat` module docs stop calling themselves spike artifacts — they
are the canonical native import path for the re-exported types.
…embly

Drives REAL queries through a residency-mode storage backend assembled the
way a browser peer is built — `FlureeBuilder::memory().build_with(storage,
NameServiceMode::ReadOnly(..))`, storage behind `StorageContentStore` — so
the hook forwarding, the register, the operator-frame retry, and the outer
drain/fetch/re-run loop are all exercised against a plain native instance
as ground truth (identical results required):

- operator path: completes with leaf misses consumed IN-FRAME by the scan
  operator (the outer loop must see no index-leaf wants — mutation-checked
  by disabling the frame arm);
- fast path (predicate COUNT): the first attempt must surface the miss
  (one-shot, no in-frame retry) with the want already in the register
  (mutation-checked by disabling register recording), then recovers
  through the outer loop;
- dir-only walk (COUNT(DISTINCT ?s), `open_leaf_dir` inside the
  leaflet-cache dir-load closure — the stringification point): registers
  and recovers; a whole-graph COUNT(*) cannot exercise this path because
  it is answered from index stats without touching leaves;
- policy-filtered query (f:query sub-query): the policy layer stringifies
  errors, so recovery works only because the register, not the error
  chain, carries the wants.

Every test asserts a positive miss marker; recovery is never inferred from
the absence of an error. The `residency` feature is enabled through
dev-dependencies only — production native builds keep the pre-residency
read path.
Internal pre-review of #1714 (approve-with-notes; findings applied on merit):
- MissRegister documents its one-draining-query-per-store scope — a future
  multi-query driver must serialize drains or scope wants (M-1)
- the wasm32 CI job now gates warnings via -D warnings in its restated
  RUSTFLAGS; cargo check alone exits 0 on warnings (M-2)
- the node probe asserts the inserted names round-trip instead of
  accepting any Ok (L-1)
- spawn_maintenance and the bm25_worker module are native-gated: they can
  only spawn onto an ambient tokio runtime (L-2)
- the core uuid-unification comment names the real wasm uuid consumers
  (query UUID()/STRUUID(), policy) instead of native-only moka; wasm_cache
  documents its TTL-free boundary
The L-1 assertion checked Debug output, which renders encoded bindings —
the literal names never appear there, so the previous commit's probe
failed. Format to SPARQL JSON (via the view's snapshot) and assert both
inserted names, verified passing with an unmasked exit code this time.
The tracing import and the encryption-key decoder were gated on
target_arch while their only callers are native-FEATURE-gated, so the
native-host no-default-features shape (any build of fluree-db-wasm's
dependency graph, including workspace --all-targets checks) warned on
both. Gate them on the callers' condition instead.
CI's clippy (--all --all-features --all-targets -D warnings) failed on
five workspace-denied manual_assert sites in it_residency_retry.rs.
They were invisible to the local per-crate runs that reported clean: a
clippy invocation over an already-checked tree can be a fingerprint
no-op that prints nothing and exits 0. Verified this time by grepping
the pattern to zero, forcing a re-check, and running the CI-shaped
workspace invocation with -D warnings (minus the vector feature, whose
usearch/cxx build is broken on this box).

Suite still 4/4.
…e call per use

`residency_mode()` asked `dyn ContentStore::miss_register()` every time — a
vtable call, at six sites, up to ~3 times per leaf open. The answer is a fixed
property of the store: `cas` is assigned once in each constructor and never
reassigned, and the field is private, so it cannot change under us. Compute it
at construction and read the bool.

Second reason, beyond the call itself: it takes dyn dispatch out of the
`residency`-enabled build. Per the dev-dependency feature unification noted in
fluree-db-api/Cargo.toml, that is the build every api bench compiles, so this
narrows the gap between what the benches measure and what production ships.

The field carries the same cfg as the method, so a production native build has
neither. Verified in all three configurations: default native (field absent),
`--features residency`, and wasm32.
…e missed

The wasm32 CI job runs `cargo check`, and the local clippy gate only ever
targeted the two browser crates — so no gate ever ran `clippy --target
wasm32` over the engine crates, and two lints sat red under it:

- binary_index_store.rs run_sync_on_runtime: `let _ = fut` trips
  clippy::let_underscore_future (a dropped, un-awaited future). The drop is
  intentional — there is no runtime to drive it on wasm and reaching the stub
  is itself the bug — so make it an explicit `drop(fut)`.
- connection/src/lib.rs (two File-storage arms): `return Err(...)` inside the
  non-native cfg block is needless once the native sibling is cfg-stripped on
  wasm, so it becomes the arm tail. Baseline code; only a wasm-target clippy
  pass sees it.

A follow-up commit adds the wasm-clippy gate that would have caught both.
…here is no filesystem

The wasm read path already treats a cache read as a miss and suppresses
every cache write, but both index loaders still opened with an
unconditional create_dir_all. On wasm32 that returns Unsupported, so a
browser peer's first ledger open died with 'failed to load binary index:
operation not supported on this platform' before a single CAS fetch was
attempted. Found by the browser-peer smoke against a real server.
…progress on the blocking object

Two coupled defects in the wasm/residency read path, both surfaced by an
adversarial review of the retry machinery:

- register_remaining_wants recorded the WHOLE remaining routed leaf set on one
  leaf-open miss. A LIMIT-shaped scan that reads one leaf and stops therefore
  pulled the entire predicate run over the network first, and a byte-budgeted
  resident tier turned it into a livelock: each retry round re-fetched a tail
  the tier had just evicted, so newly_resident>0 held every round while the
  cursor never advanced — only the sanity cap stopped it, after thousands of
  wasted fetches. Now records a bounded read-ahead window (32); the consumer
  stops pulling at its LIMIT, so only the first window is ever fetched.

- RetryBudget::after_error treated 'some want became resident' as progress.
  Under a tier too small to hold one round that is satisfiable forever without
  the cursor advancing. Now gates Ok(true) on the specific blocking object
  (the first-recorded want; MissRegister is insertion-ordered) being resident
  after the fetch — a finite, monotonically-advancing guarantee. A tier that
  cannot hold even the blocking object fails fast and names it, instead of
  spinning to the cap.

Tests: a 40-leaf scan registers exactly the 32-leaf window (mutation-checked
against an inflated window); the non-pinning store fails on round 1 at the
blocking-object gate, not by spinning to the cap.
std::time::Instant::now() TRAPS (unreachable) on wasm32 rather than returning
an error, and the OWL2-RL fixpoint runs on a query path reachable ungated from
wasm (reasoning_prep -> reason_owl2rl -> run_fixpoint). clock::Instant aliases
web_time on wasm and std::time on native, so this is a no-op off-wasm.
…it surfaces

The wasm32 job ran 'cargo check', which cannot see clippy-only lints, and no
other job runs clippy against the wasm target — so a class of wasm-only lints
could rot undetected (this stack shipped three: a dropped future in the CAS
bridge stub, a needless return in a wasm-cfg storage arm, and an explicit
into_iter loop in the sequential multi-query twin). Run clippy instead; it
lints every workspace member in the -p closure at lib level. No --all-targets:
it drags in fluree-db-api's dev-deps, whose tokio net/mio does not build for
wasm32. Fixes the last of the three lints (dispatch.rs) here.
@aaj3f
aaj3f merged commit 2141cfa into main Sep 2, 2026
15 checks passed
@aaj3f
aaj3f deleted the wasm/groundwork branch September 2, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants