Repository navigation
wasm32 groundwork: the engine stack compiles and runs on wasm32-unknown-unknown, native untouched - #1714
Conversation
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
bplatz
left a comment
There was a problem hiding this comment.
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— noresidencyin 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 onlyfrom_ioinside.map_err()onopen_leaf_handle(error path) and twoInstant::now()calls outside any loop. No per-row or per-group code added. clock.rsnative ispub use std::time::{Instant, SystemTime}— real alias, same function.wasm_compat::SpatialIndexProvidernative is a re-export of the same trait, so thespatial_provider_mapsignature change is an import-path rename, same vtable.leaflet_cache.rskeeps moka andin_blocking_regionunchanged; onlyarc_io_erroron the loader-error path.leaf_access.rsadditions wholly cfg-gated; no existing handle modified.raw_txn_upload.rsnative code unchanged, attributes only.residency_modeis itself cfg-gated, so a default-feature native build gets nodead_codewarning (--all-featuresclippy 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.
| # 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"] } |
There was a problem hiding this comment.
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:
- 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.
- 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 { |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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.
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
|
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 The vtable call. Implemented. 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
|
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.
9f2901f to
3dc32d0
Compare
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-verifiedGET /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.mjsbuilds 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-LDinsert→ SPARQLquery— asserting the future's ownOk(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 afterwasm-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 (
tokiofull,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 wasdefault = ["native"]cascading through dependency defaults — several deps now saydefault-features = falsewith the native feature re-enabled throughfluree-db-api'snativecascade, so native default builds resolve exactly as before (verified viacargo treediffs where it seemed worth being paranoid, e.g. chrono'swasmbind). Two genuinely shippable fixes fell out regardless of wasm:fluree-db-sparql --no-default-featurescompiles again (fluree-vocabwas optional-but-referenced — burn-down ROADMAP §5 item 9), andimport_sinknow sits behind theimportfeature withfluree-db-indexeroptional, 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-unknownpanics at runtime onInstant::now/SystemTime::now, has no threads, andstd::fsreturnsUnsupported. So:fluree-db-core::clockre-exportsstd::timetypes on native (zero-cost alias, no wrapper) and web-time on wasm; chrono getswasmbind(native-inert, tree-diffed); aspawn_detachedseam is#[inline] tokio::spawnon native andspawn_localon 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); anddisk_cachetreatsUnsupportedreads as a miss rather than an error, so CAS consumers fall through to fetch instead of poisoning the query (try_read_cached_bytespreviously special-cased onlyNotFound). The full hazard audit with per-site verdicts is inwasm-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_runtimeand friends influree-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 recordsWant { 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 inbinary_scan.rsre-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 stringifiesio::Errorat dozens of sites (QueryError::Internal(String)), so a payload-carrying error is unreliable by construction — a typedQueryError::NeedFetchvariant exists as belt-and-braces (mirroring the fuel-exceeded precedent, with the fast-path sites routed throughfrom_io), but the register is the contract. Residency itself isStorageRead::resolve_cached_bytes(&ContentId) -> Option<Arc<[u8]>>— defaultNone, forwarded throughStorageContentStoreandBranchedContentStore, anArcclone per hit on wasm. The cfg story: production native builds compile the pre-residency read path (the arms are behindcfg(any(target_arch = "wasm32", feature = "residency"))and additionally store-driven at runtime) — semantically identical, with one inlined delegation, sinceopen_leaf_handlenow splits into a wrapper overopen_leaf_handle_native; "byte-identical" was one word too strong for a claim this load-bearing; the dev-onlyresidencyfeature exists so native CI exercises everything —it_residency_retryruns four real-query recovery tests (operator scan, fast-path COUNT, dir-walk, policyf:querysub-query) differentially against a plain native instance over the same storage, with positive miss markers and three mutation checks. CI's--all-featuresclippy/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:BinaryCursoradvanced 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 istake()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_rowsreplays 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.tomlroutes the wasm target throughscripts/wasm-clang.sh/wasm-ar.sh(Homebrew LLVM on macOS, system clang on Linux/CI). A newwasm32CI 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-levelRUSTFLAGSoverrides target-scoped config rustflags, so the CI job restates thegetrandom_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-outsuccess_or_size_errorhelper in the multi-query dispatcher (once-per-subquery, not hot). Empirically: the native suites are green at this HEAD — workspacecheck --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 + allgrp_*targets), server. And going forward: the nightly bench baseline gate watches the same paths; we'd suggest aworkflow_dispatchbench compare on this branch as an advisory pre-merge check. Locally, a criterion A/B onfluree-db-api'squery_hot_bsbmandtransact_commitbenches (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_commitfresh −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 newbench-compareCI 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-compareannotations (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 withhost_class=localand CI runsci-ubuntu-latest, whichci.yml's own comments call a refusal-by-default comparison — that mismatch is exactly why every one of those lines saysadvisoryand the job exits 0. The check: main's own runs against the same baseline show the same annotations, and larger ones — main at8b6a0454areports q3 +81.44% / groupby_overlay +117.77%, and at5d3afe284q3 +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. Meanwhilepeak_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 aci-ubuntu-latestbaseline via bench.yml'sworkflow_dispatchand 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 influree-db-coreand observed that "nothing in CI builds it" for the wasm target — thewasm32CI job in this PR is exactly that missing enforcement, so these two changes compose rather than collide (the optional-randgating 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:
PolicyQueryExecutoris nowSend + Syncon all targets. Alternative: keep the pre-existing cfg'd?Sendwasm twin. Rejected because the engine's operator futures are uniformly Send-bounded, so the?Sendvariant 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.NeedFetchinsideio::Errorand downcast above the operator boundary. Our internal adversarial review killed it with evidence —QueryErrorhas 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 typedQueryError::NeedFetchvariant still exists as belt-and-braces on the frames that do preserve io errors.disk_cachecompiles 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.web-timere-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 ispub use std::time::{Instant, SystemTime}, a zero-cost alias with no codegen change.lru+ an uncontendedparking_lotMutex, notquick_cache. No new workspace dep, and on a single-threaded target an uncontended lock beats sharding. The trade accepted:get_withis 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.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-featurespicks it up); production native builds compile the pre-residency path — semantically identical, one inlined delegation through theopen_leaf_handlewrapper — 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
NeedFetchby design until PR-2's retry loop lands..cargo/config.tomlwholesale and deleted the workspaceRUST_MIN_STACK = 8388608, which madeproxy_integration/grp_graphsourceabort with stack overflows that looked exactly like code regressions. Restored infix(build): restore RUST_MIN_STACK…with the mechanism documented; if you've ever wondered whether that env entry is load-bearing — it is.wasm-probe/README.md,Cargo.tomlmetadata) landed in thechore: zero out wasm32-target warningscommit rather than the harness commit due to a concurrent-assembly race; content is correct, attribution is smeared. Flagging rather than rewriting shared history.Approving reviewers may want to start with
fluree-db-core/src/clock.rs+wasm_cache.rs(the shim map), thenfluree-db-binary-index/src/read/need_fetch.rsand the cursor fixes inbinary_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
MissRegisternow 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-diffedcargo treeto 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_assertsites 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
residencybuild, not the production one.fluree-db-api's dev-dependencies enableresidency, and underresolver = "2"that unifies into every target needing dev-deps — includingcargo bench -p fluree-db-api --bench query_hot_bsbm(ci.yml:227,bench.yml:101) and the local A/B above. Measured:Two consequences, and both cut in directions worth stating plainly:
That second point is also why this PR now caches the residency-tier probe (
residency_mode) as a bool at construction instead of callingdyn 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:417is 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-targetsafter 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: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, andRetryBudget::after_errorgates 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_allno longer aborts a wasm index load (cherry-picked theensure_cache_dirno-op-on-Unsupportedfix down so this PR is self-consistent; it drops patch-identical when the fix's own commit rebases through).Instant::now()— which traps on wasm — routes throughclock::Instant.into_iterloop in the sequential multi-query twin) thatcheckand 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.