Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,41 @@ 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 a check cannot see.
# This job opens, commits, and reads a store under node on
# `wasm32-unknown-unknown`: the target's own clock, and the pager's retry
# backoff, which has no Tokio time driver there.
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

# ─────────────────────────────────────────────────────────────────────────
features:
name: Feature matrix (${{ matrix.flags }})
Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["benchmarks/engine-comparison"]
members = ["benchmarks/engine-comparison", "wasm-smoke"]
default-members = ["."]
resolver = "3"

Expand Down
2 changes: 1 addition & 1 deletion src/btree/tree/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ mod tests {
let mut tree = fresh_tree(None).await;
tree.free_page(9);
assert_eq!(tree.allocate_page(), 9);
assert!(tree.drain_freed().is_empty());
assert_eq!(tree.drain_freed(), [] as [u64; 0]);
}

/// Eligibility is settled at the moment of the free and does not change
Expand Down
13 changes: 12 additions & 1 deletion src/pager/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,7 +1028,8 @@ impl<V: Vfs> Pager<V> {
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.
Expand All @@ -1044,7 +1045,17 @@ impl<V: Vfs> Pager<V> {
let mut last_envelope: Option<crate::diag::PageEnvelope> = 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. That panic would
// replace the corruption this loop exists to report with a
// crash. 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];
{
Expand Down
2 changes: 1 addition & 1 deletion src/recovery/provenance/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ mod tests {
let provenance = db.page_provenance(page_id).await.unwrap();
assert_eq!(provenance.standing, PageStanding::Free);
assert_eq!(provenance.freed_by_commit, Some(commit_id));
assert!(provenance.reachable_from.is_empty());
assert_eq!(provenance.reachable_from, [] as [&str; 0]);
assert!(!provenance.is_double_owned());
}

Expand Down
49 changes: 49 additions & 0 deletions src/txn/db/catalog/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,53 @@ mod tests {
assert!(matches!(err, PagedbError::Corruption(_)));
}
}

/// Age retention must prune, which it can only do against a real clock.
///
/// The threshold is `now - duration`, so a clock that answers `0` makes
/// every threshold `0`: no recorded timestamp is older than it, the walk
/// ends at its first row, and `Age` behaves as `Unbounded` while still
/// reporting itself as age-based. Nothing in the API reports that absence,
/// the store simply keeps everything.
///
/// `Age(ZERO)` prunes every entry older than the current second, so the wait
/// only has to cross one second boundary, and a commit never 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:?}"
);
}
}
2 changes: 1 addition & 1 deletion src/vfs/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ mod tests {
assert_eq!(vfs.list_dir("/seg").await.unwrap(), vec!["renamed"]);
vfs.sync_dir("/seg").await.unwrap();
vfs.remove("/seg/renamed").await.unwrap();
assert!(vfs.list_dir("/seg").await.unwrap().is_empty());
assert_eq!(vfs.list_dir("/seg").await.unwrap(), [] as [String; 0]);

std::fs::remove_dir_all(&dir).ok();
}
Expand Down
15 changes: 5 additions & 10 deletions tests/durability/unpublished_commit.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! A failed post-header segment reconciliation poisons only the active handle.

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};

use pagedb::vfs::memory::{MemFile, MemLockHandle, MemVfs};
use pagedb::vfs::{OpenMode, Vfs};
Expand All @@ -16,7 +16,7 @@ struct RenameFaultVfs {
inner: MemVfs,
fail_renames: Arc<AtomicBool>,
fail_sync_dirs: Arc<AtomicBool>,
failures_remaining: Arc<AtomicUsize>,
fail_rename_once: Arc<AtomicBool>,
}

impl RenameFaultVfs {
Expand All @@ -25,7 +25,7 @@ impl RenameFaultVfs {
inner: MemVfs::new(),
fail_renames: Arc::new(AtomicBool::new(false)),
fail_sync_dirs: Arc::new(AtomicBool::new(false)),
failures_remaining: Arc::new(AtomicUsize::new(0)),
fail_rename_once: Arc::new(AtomicBool::new(false)),
}
}

Expand All @@ -34,7 +34,7 @@ impl RenameFaultVfs {
}

fn fail_next_rename(&self) {
self.failures_remaining.store(1, Ordering::SeqCst);
self.fail_rename_once.store(true, Ordering::SeqCst);
}

fn fail_sync_dirs(&self, fail: bool) {
Expand All @@ -55,12 +55,7 @@ impl Vfs for RenameFaultVfs {
}

async fn rename(&self, from: &str, to: &str) -> pagedb::Result<()> {
let fail_once = self
.failures_remaining
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
remaining.checked_sub(1)
})
.is_ok();
let fail_once = self.fail_rename_once.swap(false, Ordering::SeqCst);
if self.fail_renames.load(Ordering::SeqCst) || fail_once {
return Err(PagedbError::Io(std::io::Error::other(
"injected segment reconciliation failure",
Expand Down
19 changes: 19 additions & 0 deletions wasm-smoke/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
1 change: 1 addition & 0 deletions wasm-smoke/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
//! wasm32 smoke-test crate; see tests/.
Loading
Loading