diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a61b09..2159ddf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -262,6 +262,51 @@ jobs: - name: cargo check --lib run: cargo check -p pagedb --target ${{ matrix.target }} --lib ${{ matrix.features }} + # ───────────────────────────────────────────────────────────────────────── + # Runtime wasm coverage. The `wasm` job above only compiles: a wall-clock + # read inside the txn layer compiles fine on wasm32 and panics at the first + # commit ("time not implemented on this platform"), which is invisible to a + # check. This job runs both smoke crates under node — the `opfs` build, where + # a clock exists, and the crate that builds `pagedb` without `opfs` at all, + # where it does not and age retention has to be refused. + wasm-tests: + name: WASM / node smoke (wasm32) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Rust + target + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + prefix-key: wasm-smoke + + # The runner must match the wasm-bindgen version the lockfile resolves, + # so read it from `cargo metadata` instead of pinning a number here. + - name: Install wasm-bindgen-test-runner + run: | + version=$(cargo metadata --format-version 1 --locked \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print(next(p['version'] for p in d['packages'] if p['name']=='wasm-bindgen'))") + cargo install wasm-bindgen-cli --version "$version" --locked + + - name: Run wasm smoke tests (node) + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + run: cargo test -p pagedb-wasm-smoke --target wasm32-unknown-unknown --test commit_smoke + + # Same target, the other configuration: `pagedb-wasm-smoke-no-clock` + # depends on `pagedb` without `opfs`, so `clock_available()` is false + # there. This is what runs the refusal — which `wasm-smoke` cannot reach, + # because its own dependency on `opfs` fixes the feature for every test + # target in that build. + - name: Run no-clock wasm smoke tests (node) + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + run: cargo test -p pagedb-wasm-smoke-no-clock --target wasm32-unknown-unknown --test refusal + # ───────────────────────────────────────────────────────────────────────── features: name: Feature matrix (${{ matrix.flags }}) diff --git a/Cargo.lock b/Cargo.lock index 6e8ea5c..74c2d0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -219,6 +230,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.4.0" @@ -973,6 +990,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "librocksdb-sys" version = "0.17.3+10.4.2" @@ -1070,6 +1093,16 @@ dependencies = [ "libc", ] +[[package]] +name = "minicov" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1133,6 +1166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1162,6 +1196,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1223,6 +1263,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "pagedb-wasm-smoke" +version = "0.0.0" +dependencies = [ + "pagedb", + "tokio", + "wasm-bindgen-test", +] + +[[package]] +name = "pagedb-wasm-smoke-no-clock" +version = "0.0.0" +dependencies = [ + "pagedb", + "tokio", + "wasm-bindgen-test", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1622,6 +1680,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2081,6 +2148,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2151,6 +2228,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" + [[package]] name = "web-sys" version = "0.3.103" @@ -2171,6 +2287,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 94a0c60..1e8757e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["benchmarks/engine-comparison"] +members = ["benchmarks/engine-comparison", "wasm-smoke", "wasm-smoke-no-clock"] default-members = ["."] resolver = "3" diff --git a/src/btree/tree/core.rs b/src/btree/tree/core.rs index daa85a8..8bbd290 100644 --- a/src/btree/tree/core.rs +++ b/src/btree/tree/core.rs @@ -620,15 +620,21 @@ mod tests { tree.free_page(id); } + // The loop is functional coverage on every target; the bound is a + // wall-clock regression guard, so it only runs where a clock exists. + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] let start = std::time::Instant::now(); for _ in 0..N { tree.allocate_page(); } - let elapsed = start.elapsed(); - assert!( - elapsed < std::time::Duration::from_secs(10), - "{N} allocations against {N} held-back frees took {elapsed:?} — \ - allocation is scanning the freed list again" - ); + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(10), + "{N} allocations against {N} held-back frees took {elapsed:?} — \ + allocation is scanning the freed list again" + ); + } } } diff --git a/src/clock.rs b/src/clock.rs new file mode 100644 index 0000000..5de1ede --- /dev/null +++ b/src/clock.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Wall-clock access. +//! +//! `wasm32-unknown-unknown` has no std clock: `SystemTime::now()` panics with +//! "time not implemented on this platform". The OPFS build reads the host +//! clock through `js_sys` instead; every other target, `wasm32-wasip1` +//! included, uses std. +//! +//! A wasm build *without* the JS bindings has no clock at all, and that case is +//! represented rather than papered over: [`unix_seconds`] returns `None`, and +//! [`clock_available`] lets a caller refuse a policy that a frozen clock would +//! silently neuter. Returning `0` instead would be worse than useless — the +//! commit-history ordering tolerates a zero timestamp, but the age-based +//! retention policy reads it as "nothing is older than the threshold" and stops +//! pruning, so `RetainPolicy::Age` would quietly behave as `Unbounded`. + +/// Seconds since the Unix epoch, or `None` when this build has no clock. +/// +/// `None` on `wasm32-unknown-unknown` without the `opfs` feature. Every other +/// target answers, `wasm32-wasip1` included. +#[cfg(all(target_arch = "wasm32", target_os = "unknown", feature = "opfs"))] +pub(crate) fn unix_seconds() -> Option { + Some((js_sys::Date::now() / 1000.0) as u64) +} + +/// No clock in this configuration: `wasm32-unknown-unknown` without the JS +/// bindings. Callers either tolerate the absence (commit-history ordering keeps +/// its total order without timestamps) or refuse the configuration up front +/// (age retention) — see [`clock_available`]. +#[cfg(all(target_arch = "wasm32", target_os = "unknown", not(feature = "opfs")))] +pub(crate) fn unix_seconds() -> Option { + None +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +// Clippy sees only this arm's body, where the answer is always `Some`, and +// calls the wrapper unnecessary. It is not: the `wasm32-unknown-unknown` arm +// above returns `None`, and one signature across the three arms is what lets a +// caller ask "is there a clock" without a second, drift-prone cfg. +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn unix_seconds() -> Option { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()), + ) +} + +/// Whether this build can read a wall clock at all. +/// +/// A caller that needs time to *mean* something refuses its configuration when +/// this is false; a caller that only needs a total order can read +/// [`unix_seconds`]'s absence as "no timestamp". +pub(crate) const fn clock_available() -> bool { + cfg!(not(all( + target_arch = "wasm32", + target_os = "unknown", + not(feature = "opfs") + ))) +} + +// The three arms above are selected by a cfg triple, and a cfg that is always +// true compiles into a configuration nobody builds. This pins the direction a +// build can get wrong silently: a `wasm32-unknown-unknown` build without the JS +// bindings that reports a clock anyway, which would leave `Age` unrefused. The +// other direction — `opfs` present, clock absent — is caught at runtime by the +// smoke tests, which commit under age retention on exactly that build. Both +// configurations are compiled in CI: `wasm-smoke` brings `opfs`, and +// `wasm-smoke-no-clock` is the build that has none. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +const _: () = assert!( + cfg!(feature = "opfs") || !clock_available(), + "a wasm32-unknown-unknown build without `opfs` must report no clock" +); diff --git a/src/errors.rs b/src/errors.rs index b081296..ab7704a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -189,6 +189,15 @@ pub enum PagedbError { )] HeaderCapabilityUnsupported { unknown_flags: u32 }, + /// A policy that prunes against `now` was requested on a build with no + /// clock, where a zero stand-in would silently prune nothing. Refused at + /// open, before the store is touched. + #[error( + "retain policy {policy} needs a wall clock and this target has none — \ + use Unbounded or Count, or build with the `opfs` feature" + )] + RetainPolicyNeedsClock { policy: &'static str }, + /// The caller opened the store with a different page size than it was /// created with. /// diff --git a/src/lib.rs b/src/lib.rs index 6f26987..206a1f5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ // must not be able to reach around. pub(crate) mod btree; pub(crate) mod catalog; +pub(crate) mod clock; pub(crate) mod compaction; pub(crate) mod crypto; pub(crate) mod diag; diff --git a/src/pager/core.rs b/src/pager/core.rs index d11b264..fdead26 100644 --- a/src/pager/core.rs +++ b/src/pager/core.rs @@ -1028,7 +1028,8 @@ impl Pager { let file_handle = self.open_file_handle(file).await?; // Observer-mode retry loop: on AEAD failure retry up to - // `observer_retry_count` times (10 ms backoff) to absorb torn reads + // `observer_retry_count` times (10 ms backoff, a yield where no time + // driver exists — see the per-target split below) to absorb torn reads // from a concurrent writer. In non-observer mode (retry_count == 0) // the loop body executes exactly once and any AEAD failure is a hard // corruption signal. @@ -1044,7 +1045,17 @@ impl Pager { let mut last_envelope: Option = None; for attempt in 0..max_attempts { if attempt > 0 { + // Yield the executor on `wasm32-unknown-unknown` rather than + // sleeping: this loop runs on an embedder's single-threaded + // executor, which has no Tokio time driver, and + // `tokio::time::sleep` panics without one — turning the + // corruption this loop exists to absorb into a panic before it + // can report `PagedbError::Corruption`. Every other target + // sleeps, so a torn read still gets its backoff. + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] tokio::time::sleep(std::time::Duration::from_millis(10)).await; + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + tokio::task::yield_now().await; } let mut buf = vec![0u8; page_size]; { diff --git a/src/txn/db/catalog/history.rs b/src/txn/db/catalog/history.rs index c0006da..4d3445c 100644 --- a/src/txn/db/catalog/history.rs +++ b/src/txn/db/catalog/history.rs @@ -148,9 +148,10 @@ impl Db { state.commit_history_count = Some(total.saturating_sub(deleted)); } crate::options::RetainPolicy::Age(duration) => { - let now_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()); + // `Age` is refused at open on a build without a clock, so + // the clock is present on every path that reaches this arm. + let now_secs = crate::clock::unix_seconds() + .ok_or(PagedbError::RetainPolicyNeedsClock { policy: "Age" })?; let threshold = now_secs.saturating_sub(duration.as_secs()); // History keys are the commit id big-endian, so lexicographic // key order is commit order and the prunable rows are always a @@ -456,4 +457,56 @@ mod tests { assert!(matches!(err, PagedbError::Corruption(_))); } } + + /// Age retention must actually prune, which it can only do when `now` is a + /// real time. + /// + /// This is the clocked half of the defect the wasm work fixed at the other + /// end. There the clock answered `0` for the whole build; the arithmetic is + /// the same one — `threshold = 0.saturating_sub(d)` is `0`, every recorded + /// timestamp compares as not older than it, the walk ends at the first row, + /// and `Age` silently behaves as `Unbounded`. So the assertion is that an + /// older entry really disappears: a `now` of zero cannot produce that, and + /// no error reports its absence. + /// + /// `Age(ZERO)` prunes every entry older than the current second, so the + /// wait only has to cross one second boundary, and no commit prunes the row + /// it just inserted. + #[tokio::test(flavor = "current_thread")] + async fn age_retention_prunes_entries_older_than_its_threshold() { + use std::time::Duration; + + let db = Db::open_internal_with_options( + MemVfs::new(), + [7u8; 32], + PAGE, + REALM, + OpenOptions::default().with_commit_history_retain(RetainPolicy::Age(Duration::ZERO)), + ) + .await + .unwrap(); + + let oldest = db.begin_write().await.unwrap().commit().await.unwrap(); + assert!( + db.begin_read_at(oldest).await.is_ok(), + "the only commit so far must be readable" + ); + + // Integer-second timestamps: 1.2 s crosses a boundary whichever + // fraction of a second the first commit landed on. + std::thread::sleep(Duration::from_millis(1200)); + let newest = db.begin_write().await.unwrap().commit().await.unwrap(); + + assert!( + db.begin_read_at(newest).await.is_ok(), + "a commit must not prune the row it inserts" + ); + let Err(pruned) = db.begin_read_at(oldest).await else { + panic!("an entry older than the age threshold must be pruned"); + }; + assert!( + matches!(pruned, PagedbError::CommitGone { .. }), + "a pruned commit is gone, not corruption and not a stall: {pruned:?}" + ); + } } diff --git a/src/txn/db/open/existing.rs b/src/txn/db/open/existing.rs index 2f060ef..3630f66 100644 --- a/src/txn/db/open/existing.rs +++ b/src/txn/db/open/existing.rs @@ -121,6 +121,30 @@ impl Db { mode: DbMode, ) -> Result { let main_db_path = "/main.db".to_string(); + // Every reopen path funnels through here — including the public + // `open_existing_with_counterpart_kek`, which does not pass through + // `open_with_mode` and therefore cannot rely on the check there. + // Repeating it is deliberate: this is the chokepoint that makes "no + // clock means no age retention" true of the API surface, not just of + // `Db::open`. + super::modes::check_policy_needs_clock( + &options.commit_history_retain, + crate::clock::clock_available(), + )?; + // Same derivation as `open_with_mode`, for the same reason: a mode + // that does not allow observer retries must not inherit a caller's + // retry budget, and this path is reachable without passing through + // that normalization. Without it the page-read retry loop — and its + // platform-specific backoff — would be reachable from a `Standalone` + // handle, contradicting the reachability the loop documents. + let options = if mode.open_capabilities().allows_observer_retry() { + options + } else { + OpenOptions { + observer_retry_count: 0, + ..options + } + }; let capabilities = mode.open_capabilities(); let file_mode = capabilities.main_db_open_mode(); let read_only = capabilities.read_only_file_access(); diff --git a/src/txn/db/open/modes.rs b/src/txn/db/open/modes.rs index 9d97dfc..c1bccf3 100644 --- a/src/txn/db/open/modes.rs +++ b/src/txn/db/open/modes.rs @@ -236,6 +236,14 @@ impl Db { options: OpenOptions, mode: DbMode, ) -> Result { + // Refuse a clock-dependent policy on a build with no clock, before + // anything is opened or touched; see + // `PagedbError::RetainPolicyNeedsClock`. + check_policy_needs_clock( + &options.commit_history_retain, + crate::clock::clock_available(), + )?; + let capabilities = mode.open_capabilities(); let options = if capabilities.allows_observer_retry() { options @@ -454,3 +462,78 @@ fn map_lock_contention( error } } + +/// Refuse a retention policy that cannot work without a wall clock. +/// +/// `RetainPolicy::Age` decides what to prune by comparing each entry's recorded +/// timestamp against `now - duration`. On a build with no clock there is no +/// `now`: a stand-in zero makes every threshold zero, so no entry is ever older +/// than it, the prune walk ends at its first row, and the policy behaves as +/// `Unbounded` while still reporting itself as age-based — an unbounded history +/// nobody asked for, and nothing in the API says so. +/// +/// `Unbounded` prunes nothing by definition, `Count` prunes by ordinal, and +/// `Disabled` keeps no history index at all, so none of the three consults time +/// and none is refused. Taking `clock_available` as a parameter rather than +/// reading it here is what makes both branches testable on a target that has a +/// clock. +pub(crate) fn check_policy_needs_clock( + policy: &crate::options::RetainPolicy, + clock_available: bool, +) -> Result<()> { + if let crate::options::RetainPolicy::Age(_) = policy + && !clock_available + { + return Err(PagedbError::RetainPolicyNeedsClock { policy: "Age" }); + } + Ok(()) +} + +#[cfg(test)] +mod policy_clock_tests { + use super::check_policy_needs_clock; + use crate::options::RetainPolicy; + use crate::{PagedbError, Result}; + use std::time::Duration; + + /// The defect this guards: without a clock, age retention must be refused + /// rather than run against a zero threshold and silently prune nothing. + #[test] + fn age_is_refused_when_the_build_has_no_clock() { + let refused: Result<()> = + check_policy_needs_clock(&RetainPolicy::Age(Duration::from_secs(60)), false); + assert!( + matches!( + refused, + Err(PagedbError::RetainPolicyNeedsClock { policy: "Age" }) + ), + "age retention must be refused by name when no clock exists" + ); + } + + /// With a clock it is served, so the refusal cannot be a blanket one. + #[test] + fn age_is_served_when_the_build_has_a_clock() { + assert!( + check_policy_needs_clock(&RetainPolicy::Age(Duration::from_secs(60)), true).is_ok() + ); + } + + /// `Unbounded` never prunes, `Count` prunes by ordinal, and `Disabled` + /// keeps no history index, so none reads the clock and none may be caught + /// by this check. + #[test] + fn clock_free_policies_are_never_refused() { + for policy in [ + RetainPolicy::Unbounded, + RetainPolicy::Count(4), + RetainPolicy::Disabled, + ] { + assert!( + check_policy_needs_clock(&policy, false).is_ok(), + "consults no clock and must open without one" + ); + assert!(check_policy_needs_clock(&policy, true).is_ok()); + } + } +} diff --git a/src/txn/write/commit.rs b/src/txn/write/commit.rs index a96d193..0b8e19d 100644 --- a/src/txn/write/commit.rs +++ b/src/txn/write/commit.rs @@ -110,9 +110,9 @@ impl WriteTxn<'_, V> { // Commit-history entry (also materialized here). Its frees are never // reader-pinned, so they fold into the free-list like any other. - let unix_seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()); + // Ordering is by commit id, so a clockless build records 0; `Age` is + // refused at open there. + let unix_seconds = crate::clock::unix_seconds().unwrap_or(0); let history_meta = CommitHistoryMeta { active_root_page_id: new_root, catalog_root_page_id: new_catalog_root, diff --git a/wasm-smoke-no-clock/Cargo.toml b/wasm-smoke-no-clock/Cargo.toml new file mode 100644 index 0000000..4788815 --- /dev/null +++ b/wasm-smoke-no-clock/Cargo.toml @@ -0,0 +1,25 @@ +# wasm32 smoke tests for the OTHER wasm configuration: pagedb without `opfs`. +# +# Its sibling `wasm-smoke` depends on pagedb with `opfs`, which is the build +# where a clock exists, so it can never compile this configuration. Nothing else +# can either: `clock_available()` is decided by pagedb's own feature, and a +# feature cannot be turned off from a test. A second crate is the only way to +# build and run the no-clock configuration, and a crate of its own is needed for +# the same reason as its sibling — pagedb's dev-dependencies (tokio +# rt-multi-thread, tempfile) do not compile for wasm32, and Cargo builds every +# dev-dependency for a crate's test targets. +[package] +name = "pagedb-wasm-smoke-no-clock" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +path = "src/lib.rs" + +# Gated like the sibling crate so a native `cargo check --workspace` neither +# builds pagedb twice nor needs the wasm toolchain. +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +wasm-bindgen-test = "0.3" +tokio = { version = "1", features = ["rt", "macros", "sync", "io-util"] } +pagedb = { path = ".." } diff --git a/wasm-smoke-no-clock/src/lib.rs b/wasm-smoke-no-clock/src/lib.rs new file mode 100644 index 0000000..7ad7e40 --- /dev/null +++ b/wasm-smoke-no-clock/src/lib.rs @@ -0,0 +1 @@ +//! wasm32 no-clock smoke-test crate; see tests/. diff --git a/wasm-smoke-no-clock/tests/refusal.rs b/wasm-smoke-no-clock/tests/refusal.rs new file mode 100644 index 0000000..dce41b3 --- /dev/null +++ b/wasm-smoke-no-clock/tests/refusal.rs @@ -0,0 +1,148 @@ +//! The no-clock wasm32 build: what it must refuse, and what it must not. +//! +//! This crate depends on `pagedb` without the `opfs` feature (default features +//! only), so on `wasm32-unknown-unknown` `clock::clock_available()` is false and +//! `unix_seconds()` answers `None`. An age policy cannot be served there — +//! there is no `now` to compare a recorded timestamp against, so the threshold +//! would be a stand-in and the policy would prune nothing while still reporting +//! itself age-based. +//! +//! What that configuration owes an embedder is a typed refusal at open, before +//! anything is read or written, from every entry point; and clock-free policies +//! still working. Both are asserted here at runtime. This crate exists because +//! nothing else can make those assertions: the sibling `wasm-smoke` crate +//! depends on `pagedb` with `opfs` and can never compile this configuration, +//! and `policy_clock_tests` covers the decision function rather than the call +//! sites that feed it. +//! +//! Deliberately not asserted: that a commit on this build records a zero +//! timestamp. Nothing in the public API exposes a commit's recorded time, and +//! `RetainPolicy::Age` — the only reader of it — is refused here by design. +//! +//! Run with the CI job's `wasm-bindgen-test-runner` invocation. + +#![cfg(all(target_arch = "wasm32", target_os = "unknown"))] + +use pagedb::vfs::memory::MemVfs; +use pagedb::{Db, OpenOptions, PagedbError, RealmId, RetainPolicy}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_node_experimental); + +const PAGE: usize = 4096; +const KEK: [u8; 32] = [5u8; 32]; +const REALM: RealmId = RealmId::new([2u8; 16]); + +/// No Tokio time driver on purpose: an embedder driving these futures through +/// `wasm-bindgen-futures` has no Tokio runtime at all, so a test that needed a +/// timer would pass here and fail there. +fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .expect("current-thread runtime") + .block_on(future) +} + +fn age_policy() -> OpenOptions { + OpenOptions::default() + .with_commit_history_retain(RetainPolicy::Age(std::time::Duration::from_secs(60))) +} + +/// Whether this is the store missing rather than the policy being refused. +/// +/// The memory VFS holds no store in these tests, so an entry point that probed +/// before checking reports a missing file instead: `NotFound` from the probe, or +/// the backend's own `Io`/`NotFound` out of `Vfs::open`. Refusing first is the +/// property being pinned, so the two are named apart from a genuine mismatch. +fn probed_before_checking(err: &PagedbError) -> bool { + match err { + PagedbError::NotFound => true, + PagedbError::Io(io) => io.kind() == std::io::ErrorKind::NotFound, + _ => false, + } +} + +/// The refusal, from whichever entry point produced it. +fn assert_refused(err: Option, entry: &str) { + match err { + Some(PagedbError::RetainPolicyNeedsClock { policy: "Age" }) => {} + Some(other) if probed_before_checking(&other) => { + panic!("{entry} reached the store before checking the retention policy: {other:?}"); + } + other => panic!("{entry} must refuse age retention without a clock, got {other:?}"), + } +} + +/// Every opening entry point refuses an age policy, because none of them can +/// serve it: there is no `now` to compute a pruning threshold from. +#[wasm_bindgen_test] +fn age_retention_is_refused_by_every_open_entry_point() { + block_on(async { + assert_refused( + Db::open(MemVfs::new(), KEK, PAGE, REALM, age_policy()) + .await + .err(), + "Db::open", + ); + assert_refused( + Db::open_read_only(MemVfs::new(), KEK, PAGE, REALM, age_policy()) + .await + .err(), + "Db::open_read_only", + ); + assert_refused( + Db::open_observer(MemVfs::new(), KEK, PAGE, REALM, age_policy()) + .await + .err(), + "Db::open_observer", + ); + }); +} + +/// The rekey-resume entry point does not pass through the shared mode path, so +/// it carries the refusal itself. Without that, this is the one public way to +/// reach an age policy on this build — and it would fail later, silently, at +/// the first commit instead of at open. +#[wasm_bindgen_test] +fn age_retention_is_refused_by_the_rekey_resume_entry_point() { + block_on(async { + assert_refused( + Db::open_existing_with_counterpart_kek( + MemVfs::new(), + KEK, + KEK, + PAGE, + REALM, + age_policy(), + ) + .await + .err(), + "Db::open_existing_with_counterpart_kek", + ); + }); +} + +/// The refusal is not a blanket one: the policies that consult no clock keep +/// working on this build, commit included. +#[wasm_bindgen_test] +fn clock_free_policies_still_open_and_commit_without_a_clock() { + block_on(async { + for policy in [ + RetainPolicy::Count(4), + RetainPolicy::Unbounded, + RetainPolicy::Disabled, + ] { + let opts = OpenOptions::default().with_commit_history_retain(policy.clone()); + let db = Db::open(MemVfs::new(), KEK, PAGE, REALM, opts) + .await + .unwrap_or_else(|e| { + panic!("{policy:?} consults no clock and must open without one: {e}") + }); + let mut w = db.begin_write().await.expect("begin_write"); + w.put(b"k", b"v").await.expect("put"); + w.commit() + .await + .unwrap_or_else(|e| panic!("{policy:?} must commit without a clock: {e}")); + } + }); +} diff --git a/wasm-smoke/Cargo.toml b/wasm-smoke/Cargo.toml new file mode 100644 index 0000000..b6eb3e9 --- /dev/null +++ b/wasm-smoke/Cargo.toml @@ -0,0 +1,19 @@ +# wasm32 smoke tests for pagedb. +# +# Separate crate on purpose: the pagedb dev-dependencies (tokio +# rt-multi-thread, tempfile) do not compile for wasm32, and Cargo builds every +# dev-dependency for a crate's test targets. This crate depends on pagedb with +# the `opfs` feature only. +[package] +name = "pagedb-wasm-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +path = "src/lib.rs" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +wasm-bindgen-test = "0.3" +tokio = { version = "1", features = ["rt", "macros", "sync", "io-util", "time"] } +pagedb = { path = "..", features = ["opfs"] } diff --git a/wasm-smoke/src/lib.rs b/wasm-smoke/src/lib.rs new file mode 100644 index 0000000..a596e85 --- /dev/null +++ b/wasm-smoke/src/lib.rs @@ -0,0 +1 @@ +//! wasm32 smoke-test crate; see tests/. diff --git a/wasm-smoke/tests/commit_smoke.rs b/wasm-smoke/tests/commit_smoke.rs new file mode 100644 index 0000000..bee75a5 --- /dev/null +++ b/wasm-smoke/tests/commit_smoke.rs @@ -0,0 +1,170 @@ +//! wasm32 commit smoke tests, on the build an embedder actually ships. +//! +//! The invariants these protect, all on `wasm32-unknown-unknown`: +//! +//! - Nothing on the commit path may call `SystemTime::now()`, which panics with +//! "time not implemented on this platform". The clock is reached only through +//! `clock::unix_seconds()`. +//! - A corrupted page must be reported as `PagedbError::Corruption`, not turned +//! into a panic by the read loop's backoff. +//! +//! This crate depends on `pagedb` with the `opfs` feature, which is the build +//! an embedded consumer uses — so the clock exists here, `unix_seconds()` +//! answers `Some`, and the commit tests pin the succeeding path end to end: +//! open, commit, and commit under age retention, none of them touching std's +//! clock. The runtime is built without a Tokio time driver on purpose, so the +//! corrupted-page test exercises the retry loop's `yield_now` backoff for real. +//! +//! The **no-clock** configuration is a different build (`pagedb` without +//! `opfs`) and is not reachable from here: this crate cannot produce it, because +//! its own dependency on `pagedb` fixes the feature for every target in the +//! build. It has its own crate, `wasm-smoke-no-clock`, which depends on `pagedb` +//! without `opfs` and asserts the refusal at every entry point; +//! `txn::db::open::modes::policy_clock_tests` covers the decision function +//! itself, on any target. +//! +//! Not asserted anywhere: the pruned row count. Neither `Db` nor `DbStats` +//! exposes the commit history, and these tests see only the public API. +//! +//! Run with `wasm-pack test --node wasm-smoke` or the CI job's +//! `wasm-bindgen-test-runner` invocation. + +#![cfg(all(target_arch = "wasm32", target_os = "unknown"))] + +use pagedb::vfs::memory::MemVfs; +use pagedb::vfs::{OpenMode, Vfs, VfsFile}; +use pagedb::{Db, OpenOptions, PagedbError, RealmId, RetainPolicy}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_node_experimental); + +const PAGE: usize = 4096; +const KEK: [u8; 32] = [7u8; 32]; +const REALM: RealmId = RealmId::new([1u8; 16]); + +/// A current-thread runtime keeps the async plumbing identical to native. +/// The memory VFS never yields to the JS event loop, so `block_on` completes +/// without blocking a host callback. +/// +/// Deliberately built **without** `enable_time()`. An embedder driving these +/// futures through `wasm-bindgen-futures` has no Tokio runtime at all, so a +/// test that quietly depended on a time driver would pass here and panic +/// there. The page-read retry loop yields instead of sleeping for the same +/// reason. +fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .expect("current-thread runtime") + .block_on(future) +} + +async fn commit_once(policy: &RetainPolicy) -> pagedb::Result<()> { + let opts = OpenOptions::default().with_commit_history_retain(policy.clone()); + let db = Db::open(MemVfs::new(), KEK, PAGE, REALM, opts).await?; + let mut w = db.begin_write().await.expect("begin_write"); + w.put(b"k", b"v").await.expect("put"); + w.commit().await.expect("commit"); + Ok(()) +} + +/// Opening and committing must not reach the std clock on this target. +#[wasm_bindgen_test] +fn commit_does_not_read_the_std_clock() { + block_on(async { + commit_once(&RetainPolicy::Unbounded) + .await + .expect("Unbounded consults no clock, so it must open and commit"); + }); +} + +/// Every clock-free policy must clear a commit, not just the one a default +/// configuration happens to use. +#[wasm_bindgen_test] +fn commit_clears_every_clock_free_policy() { + block_on(async { + for policy in [RetainPolicy::Unbounded, RetainPolicy::Count(4)] { + commit_once(&policy) + .await + .unwrap_or_else(|e| panic!("a clock-free policy must commit: {e}")); + } + }); +} + +/// Age retention reads the clock on every commit — its pruning threshold is +/// `now - duration` — so it is the policy whose commitment to a wall clock has +/// to be met on the build an embedder ships, not just on the one the default +/// configuration happens to use. +#[wasm_bindgen_test] +fn commit_under_age_retention_does_not_read_the_std_clock() { + block_on(async { + commit_once(&RetainPolicy::Age(std::time::Duration::from_secs(60))) + .await + .expect("a build with a clock must serve age retention"); + }); +} + +/// Corrupted pages must be reported, not panicked on. +/// +/// This is the case the page-read retry loop exists for: a read-only handle +/// retries an AEAD failure `observer_retry_count` times before reporting +/// corruption, and the backoff between those attempts is the one platform +/// split in this change — `tokio::time::sleep` where a time driver exists, +/// `yield_now` on `wasm32-unknown-unknown`, where an embedder's executor has +/// none and sleeping would panic with "time not implemented on this platform". +/// A panic here would replace a reportable `Corruption` with a crash, so this +/// test is what makes that split load-bearing rather than cosmetic. +#[wasm_bindgen_test] +fn a_corrupted_page_is_reported_as_corruption_not_a_panic() { + block_on(async { + let vfs = MemVfs::new(); + let db = Db::open(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default()) + .await + .expect("open"); + let mut w = db.begin_write().await.expect("begin_write"); + w.put(b"k", b"v").await.expect("put"); + w.commit().await.expect("commit"); + drop(db); + + // Pages 0 and 1 hold the A/B header slots, whose MAC covers the slot; + // corrupting those would fail *open* with a header error instead of + // exercising a verified page read. Every page after them is flipped, so + // whichever page the read path reaches is corrupt. The flip lands + // mid-page: the page's first bytes carry its cleartext cipher id, and + // overwriting that reports `Unsupported` before the authenticated read + // ever fails. + let mut file = vfs + .open("/main.db", OpenMode::ReadWrite) + .await + .expect("raw open"); + let len = file.len().await.expect("len") as usize; + let mut byte = [0u8; 1]; + let mut offset = 2 * PAGE; + while offset + PAGE <= len { + let mid = (offset + PAGE / 2) as u64; + file.read_at(mid, &mut byte).await.expect("read"); + byte[0] ^= 0xFF; + file.write_at(mid, &byte).await.expect("write"); + offset += PAGE; + } + assert!( + offset > 2 * PAGE, + "the store must have pages past the two header slots" + ); + + // `Observer` is the mode whose retry budget turns this into four + // attempts on the corrupted page; `open_observer` on defaults is what a + // reader on a live store does. + let outcome: pagedb::Result<()> = async { + let db = Db::open_observer(vfs, KEK, PAGE, REALM, OpenOptions::default()).await?; + let txn = db.begin_read().await?; + txn.get(b"k").await.map(|_| ()) + } + .await; + + let err = outcome.expect_err("a corrupted page must not read cleanly"); + assert!( + matches!(err, PagedbError::Corruption(_)), + "a corrupted page must be reported as Corruption, got {err:?}" + ); + }); +}