Skip to content

wasm: yield the retry backoff without a time driver - #44

Merged
farhan-syah merged 4 commits into
NodeDB-Lab:mainfrom
EnRaiha:fix/wasm-commit-clock
Oct 4, 2026
Merged

farhan-syah merged 4 commits into
NodeDB-Lab:mainfrom
EnRaiha:fix/wasm-commit-clock

Conversation

@EnRaiha

@EnRaiha EnRaiha commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Two runtime defects survive on main. The second is why the first stayed invisible.

The retry backoff sleeps where no time driver exists. Pager::read_page backs off between AEAD
retries with tokio::time::sleep (src/pager/core.rs). An embedder driving these futures through
wasm-bindgen-futures on wasm32-unknown-unknown has no Tokio time driver, so the sleep panics
inside std::time::Instant::now. That loop exists to absorb a torn read and then report
PagedbError::Corruption. The panic replaces the report with a crash.

No CI job ran pagedb code on wasm. The wasm job only compiles, in both configurations. A
wall-clock read in the txn layer passes a compile and panics at the first commit, which is how the
commit-path panic in #40 reached a consumer.

Change

The backoff yields on wasm32-unknown-unknown. Every other target keeps the 10 ms sleep. A
yield passes no wall time, so against a writer on another thread the retries are immediate and the
loop reports the corruption instead of absorbing the torn read.

A wasm-tests job runs the smoke crate under node.

  • wasm-smoke opens and commits on the build an embedder ships, commits under age retention, and
    reads a store with every page past the two header slots flipped, which reaches the retry loop. Its
    runtime is built without a time driver on purpose.

wasm-smoke is a separate crate because the pagedb dev-dependencies do not compile for wasm32, and
Cargo builds every dev-dependency of a crate's test targets.

A lib test pins that age retention prunes. Age decides eligibility from now - duration, so a
clock that answers 0 makes every threshold 0. The walk then ends at its first row, and Age behaves
as Unbounded while still reporting itself age-based. The integration test accepts either outcome,
so nothing caught that. The new test uses Age(ZERO), waits across one second boundary, and
asserts the older commit is gone.

Both commit-path call sites already reach the clock through clock::unix_seconds(), which
c5b5e0d put on main. What this branch adds for #40 is the runtime coverage that would have
caught the panic before a consumer did.

Changes since the last review

  • Rebased onto current main (c4bf37e).
  • Main's portable clock is kept. src/clock.rs is main's file, unmodified. The branch no longer
    adds clock_available, the RetainPolicyNeedsClock variant, its gate at the top of
    open_with_mode, or the wasm-smoke-no-clock crate. RetainPolicy::Age works on every wasm
    build, MemVfs consumers included.
  • src/txn/write/commit.rs and src/txn/db/catalog/history.rs keep main's clock call. The branch
    adds one test to history.rs and changes nothing else there.
  • The btree/tree/core.rs clock gating is gone. Main's clock answers on every target, so the
    allocation bound runs everywhere.
  • The taiki-e action pins stay at main's v2.87.18; the branch no longer carries the older pins.
  • Scope is main plus 8 files and 3 commits: the retry split, the node coverage, the prune test.
  • Held back for a follow-up PR: wiring main's tests/commit_history_wasm.rs into the same job. It
    runs green on this head (5 passed), and it would put the retention policies under the node runner
    alongside the smoke crate. It stays out of this review round.

Evidence

All on 4de5ea8, head of fix/wasm-commit-clock, rebased onto c4bf37e. rustc/cargo 1.96.1,
wasm-bindgen-test-runner 0.2.126 (the version the lockfile resolves), node 22.

Check Command Result
full suite cargo nextest run --all-features 858 passed, 10 skipped, exit 0
format cargo fmt --all -- --check exit 0
lints cargo clippy -p pagedb --all-targets --all-features -- -A unknown_lints -D warnings exit 0
wasm smoke, node CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p pagedb-wasm-smoke --target wasm32-unknown-unknown --test commit_smoke 4 passed, exit 0
wasm retention, node (local only, not in the job) same runner, cargo test -p pagedb --target wasm32-unknown-unknown --test commit_history_wasm 5 passed, exit 0
both wasm configurations compile cargo check -p pagedb --target wasm32-unknown-unknown --lib, then --features opfs exit 0 each

-A unknown_lints is for this host's clippy 1.96.1, which rejects 13 pre-existing
clippy::unused_async_trait_impl attributes in files this branch does not modify (src/vfs/*,
src/pager/core.rs, src/txn/db/open/capability_tests.rs). The count is 13 on main and 13 here.
Without the flag clippy fails on those, on main as well.

Red/green by mutation, each arm restored afterwards:

Arm Mutation Result
pre-fix retry backoff wasm arm back to tokio::time::sleep a_corrupted_page_is_reported_as_corruption_not_a_panic fails, panicking in std::time::Instant::now by way of tokio::time::instant::variant::now
the prune assertion clock::unix_seconds() returns 0 age_retention_prunes_entries_older_than_its_threshold fails at src/txn/db/catalog/history.rs:499

CI: no check has run on this PR yet, and the run needs a maintainer to approve it. The local runs
above stand in until then, and the job itself is what closes the gap after this merge.

Limits

  • The corrupted-page test reaches the retry loop only through Db::open_observer, the mode whose
    retry budget is non-zero. With every page past the two header slots flipped, whichever page the
    read path reaches is corrupt.
  • A yield passes no wall time. Against a writer on another thread the retries are immediate, so the
    loop reports the corruption rather than absorbing the torn read. The opfs build could use a JS
    timer, since js-sys and wasm-bindgen-futures are dependencies there. The job does not
    distinguish the two builds in that loop.
  • The pruned row count is not asserted through the public API. Neither Db nor DbStats exposes a
    commit's recorded time or the history index, so the assertion is that the older commit is gone
    (CommitGone).
  • tests/commit_history_wasm.rs is main's file, run here unchanged.

Fixes #40.

Copilot AI lite review requested due to automatic review settings September 27, 2026 13:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@farhan-syah farhan-syah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes. One blocker, and it comes from #45 merging underneath this branch. Everything else is ready.

Verified on this branch merged into current main (629aee0)

Check Result
cargo clippy --all-targets --all-features -- -D warnings Clean
cargo nextest run --all-features 845 passed, 10 skipped
pagedb-wasm-smoke under wasm-bindgen-test-runner 0.2.126 4 passed
pagedb-wasm-smoke-no-clock 3 passed
  • The corrupted-page smoke test opens with Db::open_observer, so it reaches the retry loop's yield_now arm.
  • The #41 blockers are fixed: Age is refused at open without a clock, and the retry loop no longer calls tokio::time::sleep on wasm.
  • The unused root dev-dependency and the CHANGELOG.md hunk are gone.

Blocker: rebase onto main and drop the gates #45 made redundant. See the inline comment on src/txn/db/open/existing.rs.

Also needed before merge

  • Update the description. Its claim that the counterpart-key open skips open_with_mode is stale, as is "both option→config sites". Its evidence table was gathered on 23f2f1f, so re-run it on the rebased head.
  • No CI has run on this PR, so the new wasm-tests job has never run on GitHub. Push the rebased branch so the checks run.

Comment thread src/txn/db/open/existing.rs Outdated
Comment on lines +124 to +147
// 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
}
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocker. #45 changed the ground this hunk stands on.

  • Stale claim. Db::open_existing_with_counterpart_kek now opens through open_with_mode. The claim in lines 124-129 is false on main, and both additions rest on it.
  • Duplicate retry gate. main already sets observer_retry_count from capabilities.allows_observer_retry() where it builds PagerConfig. Merged, this file gates the retry count twice.
  • Duplicate clock check. Every public open, including the counterpart-key open and a fresh bootstrap, reaches check_policy_needs_clock at the top of open_with_mode. A fresh bootstrap never reaches this function, so the check in open_with_mode is the one that must stay.

Correct end state: delete lines 124-147 and keep the single check in open_with_mode. age_retention_is_refused_by_the_rekey_resume_entry_point still passes that way, because the check in open_with_mode runs before the store probe.

@EnRaiha
EnRaiha force-pushed the fix/wasm-commit-clock branch from ec148ad to 3206d51 Compare October 3, 2026 05:55

@farhan-syah farhan-syah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes: preserve the portable clock already on current main when rebasing.

The previous review's duplicated gates are removed, and the description reflects the shared open path. The observer retry change passes its WASM smoke test.

Current main is c4bf37e. Commit c5b5e0d already provides WASM timestamps through web-time without requiring opfs. This branch conflicts in src/clock.rs, src/txn/db/catalog/history.rs, and src/txn/write/commit.rs. See the inline finding for the required behavior after rebasing.

Checks on 3206d51:

Check Result
cargo nextest run --all-features 845 passed, 10 skipped
OPFS WASM smoke 4 passed
No-clock WASM smoke 3 passed
cargo fmt --all -- --check Passed
WASM library check, default and opfs Both passed
cargo clippy -p pagedb --all-targets --all-features -- -D warnings Four pre-existing errors under Rust 1.99.0, reproduced on base 629aee0

Clippy reports deprecated fetch_update at tests/durability/unpublished_commit.rs:60 and clippy::assert_is_empty at src/btree/tree/core.rs:568, src/recovery/provenance/probe.rs:330, and src/vfs/native.rs:302. No lint suppression was added.

These checks cover the PR head, not a resolved merge with current main. GitHub reports CONFLICTING and exposes no status checks for this head.

Comment thread src/clock.rs Outdated
/// 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")))]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P1] Preserve main's WASM clock without requiring opfs

Current main already reads WASM time through web-time, introduced by c5b5e0d. RetainPolicy::Age therefore works without opfs, including MemVfs consumers. This arm returns None for that same build, and the new open gate rejects those consumers with RetainPolicyNeedsClock. Resolving the clock conflict in favor of this implementation removes existing support and breaks main's age-history WASM tests. Rebase onto current main and retain its portable clock. Remove the obsolete clockless refusal and its tests. Keep the observer retry change and runtime coverage.

`tokio::time::sleep` panics on `wasm32-unknown-unknown`, where the executor an
embedder drives these futures on has no Tokio time driver. The backoff sits in
the retry loop that reports `PagedbError::Corruption`, so the panic replaced a
reportable error with a crash. That target yields now. Every other target keeps
the 10 ms backoff, so a torn read still gets it.

A yield passes no wall time, so against a writer on another thread the attempts
are immediate and the loop reports the corruption instead of absorbing the torn
read.
The `wasm` job only compiles. A wall-clock read in the txn layer compiles fine
on wasm32 and panics at the first commit ("time not implemented on this
platform"), and the pager's retry backoff had no runtime test on any target.

`wasm-smoke` is a separate crate because the pagedb dev-dependencies do not
compile for wasm32, and Cargo builds every dev-dependency of a crate's test
targets. It opens and commits on the build an embedder ships, commits under age
retention, and reads a store with every page past the two header slots flipped.
Its runtime is built without a time driver on purpose, so that last test reaches
the retry loop's backoff for real.
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 age-based.
Nothing in the API reports that absence.

`Age(ZERO)` prunes everything older than the current second, so the wait only
has to cross one second boundary, and a commit never prunes the row it inserts.
@EnRaiha
EnRaiha force-pushed the fix/wasm-commit-clock branch from 3206d51 to 4de5ea8 Compare October 4, 2026 08:26
@EnRaiha EnRaiha changed the title wasm: read the clock through one helper and refuse age retention without it wasm: yield the retry backoff without a time driver Oct 4, 2026

@farhan-syah farhan-syah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved. The previous blocker is resolved, and this re-review finds no new correctness defects.

Previous request Result
Rebase onto current main Resolved: based on c4bf37e, mergeable
Preserve main's portable WASM clock Resolved: src/clock.rs is unchanged from main
Remove obsolete clockless refusal and tests Resolved: clock_available, RetainPolicyNeedsClock, the open gate, and wasm-smoke-no-clock are removed
Keep observer retry change and runtime coverage Resolved: retry yields on wasm32-unknown-unknown, and the corruption smoke test passes

Checks rerun on 4de5ea8:

Check Result
cargo nextest run --all-features 858 passed, 10 skipped
Node WASM smoke 4 passed
Node WASM commit-history tests, without opfs 5 passed
cargo fmt --all -- --check Passed
WASM library checks, default and opfs Both passed
cargo clippy -p pagedb --all-targets --all-features -- -A unknown_lints -D warnings Four pre-existing errors under Rust 1.99.0

Clippy reports deprecated fetch_update at tests/durability/unpublished_commit.rs:60 and clippy::assert_is_empty at src/btree/tree/core.rs:568, src/recovery/provenance/probe.rs:330, and src/vfs/native.rs:302. These match the errors reproduced on the base during the previous review. All four source files are unchanged by this PR. The contributor's Rust 1.96.1 lint result does not establish a clean run under Rust 1.99.0.

The immediate WASM retries pass no wall time. The PR states that limitation explicitly. Wiring main's commit_history_wasm suite into CI remains a follow-up, as stated in the description.

GitHub exposes no status checks for this head. This approval records code review and local checks. CI still requires maintainer approval.

@farhan-syah
farhan-syah merged commit 03753c5 into NodeDB-Lab:main Oct 4, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wasm: every commit reads SystemTime::now() for the history entry \u2014 the first write panics

3 participants