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
99 changes: 99 additions & 0 deletions .openspec/adr/0035-wal-resume-hint-cache-supersedes-0026.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 0035: `Pager`-cached WAL resume hint supersedes ADR-0026's per-flush rescan

Date: 2026-08-29

Status: Accepted

Supersedes: ADR-0026 ("Alternatives rejected" — caching a `WalWriter`
handle/resume state across flushes)

## Context

#640 (follow-up from #635's profiling) measured `WalWriter::open_existing`'s
full read-and-rescan of `-wal` on every commit at ~6-7ms even against a
near-empty, freshly-created WAL file — a meaningful fraction of the commit
path's total cost, and exactly the scenario ADR-0026's own "Consequences"
section predicted as the trigger for revisiting the rescan-per-flush
trade-off: *"will need revisiting — most likely by tracking the resume
offset/checksum on `Pager` across calls, invalidated on mode switches — if a
long-lived WAL under sustained write load makes per-commit rescanning
measurably slow."*

ADR-0026 rejected caching a `WalWriter` handle on `Pager` "for now", citing
two problems that needed solving first: invalidating the cache on
`switch_wal_to_journal`/`switch_journal_to_wal` (which delete or recreate
`-wal`), and tolerating a torn/short file from a `Drop`-order crash or a
concurrent external writer between flushes.

## Decision

**Cache a small `WalResumeHint` (header, append offset, running checksum,
and the file size that state is only valid against) on `Pager`, not a whole
`WalWriter` handle.** `flush_wal_locked` hands its cached hint into
`WalWriter::open_existing`, which now takes an `Option<&WalResumeHint>`:

- If the `-wal` file's current size matches `hint.expected_size` *and* a
cheap 32-byte re-read of the header matches `hint.header` byte-for-byte,
the hint is trusted and the full read-and-rescan is skipped entirely —
the writer resumes directly from the cached offset/running checksum.
- Any mismatch (a concurrent writer or checkpoint changed the file, a mode
switch replaced it, a crash left it torn) falls back to exactly the
full read + `last_valid_frame_state` rescan ADR-0026 already established,
so correctness never depends on the cache being right — only commit
latency does.

`Pager::wal_resume: Option<wal::WalResumeHint>` is populated after every
successful `WalWriter::sync`, mirroring the `wal_shm` handle cache (#437)
already on `Pager`: lazily populated, and reset to `None` at the same three
sites `wal_shm` is reset at — `switch_wal_to_journal`,
`switch_journal_to_wal`, and `recreate_wal_locked` (the #422 "`-wal`
vanished out from under this connection" recovery path) — since each of
those deletes or recreates the underlying file, making any cached hint
stale by construction.

This resolves ADR-0026's rejected-alternative concerns without needing a
`WalWriter` handle's own lifetime managed across calls: the hint is `Copy`
data, not a live file handle, so there is no fd to keep valid across a mode
switch, and the size+header check — not trust in the cache's own
invalidation completeness — is what makes a torn file or concurrent writer
safe to resume past.

## Alternatives rejected

- **Cache the whole `WalWriter` (ADR-0026's original rejected option)**:
still avoids managing an fd's lifetime across mode switches more cleanly
than a plain data hint would — a cached handle would need its own
re-validation logic duplicating what the hint's size/header check already
does, for no benefit over caching just the state the handle would
otherwise re-derive from a rescan anyway.
- **Trust the file size alone, skip the header re-read**: rejected — sizes
can coincide across a generation change this cache wasn't told about
(e.g. an external checkpoint truncates `-wal` back to header-only, then a
fresh writer's own frames happen to grow it back to the same total length
the old generation had), which would resume against the wrong
salts/checksum chain and silently corrupt the file. The extra 32-byte
read is O(1), not O(WAL size), so it costs nothing measurable while
closing that gap.
- **Track `mxFrame` from `-shm` instead of a `Pager`-local hint**: rejected
— `mxFrame` publication is a best-effort, non-atomic `pwrite` (ADR-0026's
own accepted residual risk), and a torn read of it would be a worse
invalidation signal than the on-disk `-wal` file's own actual size, which
is exactly what a writer is about to append onto regardless.

## Consequences

- A commit against an already-warm cache costs one `size()` stat plus one
32-byte header read instead of reading and rescanning the entire `-wal`
file — the dominant cost #640 measured is gone for the common case of
consecutive commits from the same long-lived `Pager`.
- The full rescan path (and its cost) is unchanged and still exercised
automatically whenever the hint can't be trusted, so a first commit after
`Pager::open`, a mode switch, or a concurrent writer's interleaved commit
pays exactly what ADR-0026 always charged — no new failure mode, only a
narrower set of calls that pay it.
- `Pager` now carries one more `Copy` field (`wal_resume`) alongside
`wal_shm`, with the same three invalidation sites — a future change to
either cache's invalidation logic should double-check the other still
agrees, since they're deliberately kept in lockstep rather than merged
into one struct (the `-shm` handle is a live resource with its own
lifetime; the resume hint is plain data with none).
1 change: 1 addition & 0 deletions .openspec/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ Specs record what the system must do; ADRs record **why it is shaped this way**
| [0032](0032-hash-group-by-second-strategy.md) | Hash `GROUP BY` is a second strategy with its own opcode family, and still emits groups in key order | 2026-08-27 |
| [0033](0033-constant-propagation-and-or-to-in-extend-fast-paths-in-place.md) | Constant propagation and OR-to-IN extend existing equality fast paths in place; only genuine range seeks wait for a new opcode | 2026-08-28 |
| [0034](0034-index-range-seeks.md) | Real-index range seeks for `BETWEEN`/`IN`/`LIKE`-prefix (`SeekIndexGE`/`IdxCompareGT`) | 2026-08-28 |
| [0035](0035-wal-resume-hint-cache-supersedes-0026.md) | `Pager`-cached WAL resume hint supersedes ADR-0026's per-flush rescan | 2026-08-29 |
183 changes: 167 additions & 16 deletions src/pager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,22 @@ pub struct Pager {
/// the underlying `-shm` file is deleted (`switch_wal_to_journal`) or
/// freshly recreated (`switch_journal_to_wal`) there.
wal_shm: Option<AnyWalShm>,
/// A cached [`wal::WalResumeHint`] (ADR-0027) letting
/// [`Pager::flush_wal_locked`] skip `WalWriter::open_existing`'s
/// read-and-rescan of the whole `-wal` file when it's still valid —
/// populated after every successful `WalWriter::sync` in this
/// connection's lifetime, `None` for a connection that hasn't
/// committed in WAL mode yet. Reset to `None` at the same three
/// sites `wal_shm` above is: `switch_wal_to_journal`,
/// `switch_journal_to_wal`, `recreate_wal_locked` — each deletes or
/// recreates the underlying `-wal` file, so a hint captured against
/// the old file must never be handed to a writer opened against the
/// new one. `open_existing` itself also falls back to a full rescan
/// whenever the file's actual size doesn't match the hint (a
/// concurrent external writer, or a torn file from a crash), so this
/// cache never needs to be "perfectly" invalidated — only cheaply
/// invalidated at the points where staleness is certain.
wal_resume: Option<wal::WalResumeHint>,
source: WritablePageSource,
/// Committed WAL overlay pages, shared as `Rc<[u8]>` so a read hit
/// hands out a refcount bump instead of copying `page_size` bytes
Expand Down Expand Up @@ -425,6 +441,7 @@ impl Pager {
tx_lock_level: crate::vfs::LockLevel::Shared,
wal_lock,
wal_shm: None,
wal_resume: None,
source,
wal_pages,
dirty: HashMap::new(),
Expand Down Expand Up @@ -666,20 +683,22 @@ impl Pager {
// checkpoint backfills them.
let post_page_count = read_be_u32(&self.read_page(1)?, PAGE_COUNT_OFFSET)?;

let mut writer =
match wal::WalWriter::open_existing(&self.vfs, &wal_path, self.page_size) {
Ok(writer) => writer,
// The `-wal` this `Pager` believed was live has vanished —
// e.g. a concurrent `sqlite3` connection auto-checkpointed
// and deleted `-wal`/`-shm` on close (#422). `journal_mode`
// still says `Wal` (this closure only ever runs from that
// branch), so recover exactly as `switch_journal_to_wal`
// creates one from scratch, rather than failing the commit.
Err(wal::WalError::Vfs(VfsError::NotFound { .. })) => {
self.recreate_wal_locked()?
}
Err(source) => return Err(to_pager_error(source)),
};
let mut writer = match wal::WalWriter::open_existing(
&self.vfs,
&wal_path,
self.page_size,
self.wal_resume.as_ref(),
) {
Ok(writer) => writer,
// The `-wal` this `Pager` believed was live has vanished —
// e.g. a concurrent `sqlite3` connection auto-checkpointed
// and deleted `-wal`/`-shm` on close (#422). `journal_mode`
// still says `Wal` (this closure only ever runs from that
// branch), so recover exactly as `switch_journal_to_wal`
// creates one from scratch, rather than failing the commit.
Err(wal::WalError::Vfs(VfsError::NotFound { .. })) => self.recreate_wal_locked()?,
Err(source) => return Err(to_pager_error(source)),
};

let last_index = page_nums.len().saturating_sub(1);
for (index, &page_num) in page_nums.iter().enumerate() {
Expand All @@ -695,6 +714,7 @@ impl Pager {
}
}
writer.sync().map_err(to_pager_error)?;
self.wal_resume = Some(writer.resume_hint());

let new_mx_frame = writer.frame_count();
match &self.wal_shm {
Expand Down Expand Up @@ -811,6 +831,13 @@ impl Pager {
shm_file.sync()?;

self.wal_shm = self.vfs.open_wal_shm(&self.db_path)?;
// The resume hint (ADR-0027), if any, described the now-deleted
// generation of `-wal`; `flush_wal_locked` overwrites this with
// the fresh writer's own hint once its commit succeeds, but
// clear it here too so a failure before that point never leaves
// a stale hint pointing at a generation this `Pager` just
// discarded.
self.wal_resume = None;
Ok(writer)
}

Expand Down Expand Up @@ -846,8 +873,12 @@ impl Pager {

// Drop any handle cached (#437) against a now-stale `-shm`
// generation — `flush_wal_locked` reopens fresh against the file
// just created above on its next call.
// just created above on its next call. Likewise drop any cached
// resume hint (ADR-0027): it was captured against whatever
// generation of `-wal` existed before this call, which no longer
// exists.
self.wal_shm = None;
self.wal_resume = None;
Ok(())
}

Expand Down Expand Up @@ -899,8 +930,11 @@ impl Pager {
// The cached handle (#437), if any, points at the `-shm` file
// just deleted above — drop it so a future switch back to WAL
// reopens fresh rather than reusing a stale fd to a since-
// deleted (or reused-inode) file.
// deleted (or reused-inode) file. The cached resume hint
// (ADR-0027) is stale for the same reason: the `-wal` file it
// describes no longer exists.
self.wal_shm = None;
self.wal_resume = None;
Ok(())
}

Expand Down Expand Up @@ -1808,6 +1842,123 @@ mod tests {
assert_eq!(pages.get(&2), Some(&vec![9u8; 512]));
}

/// ADR-0027: two consecutive commits from the same `Pager` must both
/// be correct once the second one resumes from the cached
/// `wal_resume` hint instead of rescanning the whole `-wal` file —
/// the checksum chain `wal::committed_pages` verifies on read would
/// break immediately if the cached offset/running-checksum state
/// were wrong.
#[test]
fn flush_wal_mode_second_commit_resumes_correctly_from_cached_hint() {
let mut vfs = MemoryVfs::new();
let mut contents = vec![1u8; 512];
write_be_u32(&mut contents, PAGE_COUNT_OFFSET, 2).unwrap();
contents.extend(vec![2u8; 512]);
vfs.insert("/test.db", contents);
let mut pager = Pager::open(&vfs, Path::new("/test.db"), 512).unwrap();
pager.set_journal_mode(JournalMode::Wal).unwrap();

pager.get_page_mut(2).unwrap().fill(9u8);
pager.flush().unwrap();
assert!(pager.wal_resume.is_some());

pager.get_page_mut(2).unwrap().fill(11u8);
pager.flush().unwrap();

let wal_file = vfs.open_read(Path::new("/test.db-wal")).unwrap();
let size = wal_file.size().unwrap();
let mut wal_bytes = vec![0u8; size as usize];
wal_file.read_at(&mut wal_bytes, 0).unwrap();
let header = wal::WalHeader::parse(&wal_bytes).unwrap();
let (pages, db_size) = wal::committed_pages(&header, &wal_bytes);
assert_eq!(db_size, 2);
assert_eq!(pages.get(&2), Some(&vec![11u8; 512]));
assert_eq!(pager.read_page(2).unwrap(), Rc::from(vec![11u8; 512]));
}

/// ADR-0027: a mode round trip (WAL -> Legacy -> WAL) must invalidate
/// the cached `wal_resume` hint, since `switch_wal_to_journal`
/// deletes the old `-wal` file and `switch_journal_to_wal` creates an
/// unrelated one with fresh salts — resuming against the stale hint
/// would append onto (or validate against) a generation that no
/// longer exists.
#[test]
fn flush_wal_mode_after_mode_round_trip_does_not_reuse_stale_hint() {
let mut vfs = MemoryVfs::new();
let mut contents = vec![1u8; 512];
write_be_u32(&mut contents, PAGE_COUNT_OFFSET, 2).unwrap();
contents.extend(vec![2u8; 512]);
vfs.insert("/test.db", contents);
let mut pager = Pager::open(&vfs, Path::new("/test.db"), 512).unwrap();
pager.set_journal_mode(JournalMode::Wal).unwrap();

pager.get_page_mut(2).unwrap().fill(9u8);
pager.flush().unwrap();
assert!(pager.wal_resume.is_some());

pager.set_journal_mode(JournalMode::Legacy).unwrap();
assert!(pager.wal_resume.is_none());
pager.set_journal_mode(JournalMode::Wal).unwrap();
assert!(pager.wal_resume.is_none());

pager.get_page_mut(2).unwrap().fill(11u8);
pager.flush().unwrap();

let wal_file = vfs.open_read(Path::new("/test.db-wal")).unwrap();
let size = wal_file.size().unwrap();
let mut wal_bytes = vec![0u8; size as usize];
wal_file.read_at(&mut wal_bytes, 0).unwrap();
let header = wal::WalHeader::parse(&wal_bytes).unwrap();
let (pages, db_size) = wal::committed_pages(&header, &wal_bytes);
assert_eq!(db_size, 2);
assert_eq!(pages.get(&2), Some(&vec![11u8; 512]));
}

/// ADR-0027: a concurrent writer appending frames to `-wal` between
/// two commits from this `Pager` must be detected by the resume
/// hint's size check and force a full rescan, rather than resuming
/// the checksum chain from a stale cached offset and corrupting the
/// file — the same hazard ADR-0026 accepted the full-rescan cost to
/// avoid in the first place.
#[test]
fn flush_wal_mode_falls_back_to_rescan_when_wal_grew_from_elsewhere() {
let mut vfs = MemoryVfs::new();
let mut contents = vec![1u8; 512];
write_be_u32(&mut contents, PAGE_COUNT_OFFSET, 3).unwrap();
contents.extend(vec![2u8; 512]);
contents.extend(vec![3u8; 512]);
vfs.insert("/test.db", contents);

let mut writer_a = Pager::open(&vfs, Path::new("/test.db"), 512).unwrap();
writer_a.set_journal_mode(JournalMode::Wal).unwrap();
writer_a.get_page_mut(2).unwrap().fill(9u8);
writer_a.flush().unwrap();
assert!(writer_a.wal_resume.is_some());

// A second connection, sharing the same underlying `-wal` file,
// commits a frame `writer_a` never learns about through its own
// cache.
let mut writer_b = Pager::open(&vfs, Path::new("/test.db"), 512).unwrap();
writer_b.get_page_mut(3).unwrap().fill(7u8);
writer_b.flush().unwrap();

// `writer_a`'s cached hint still reflects the file as it was
// after its own commit, not `writer_b`'s — the size check inside
// `WalWriter::open_existing` must catch the mismatch and rescan.
writer_a.get_page_mut(2).unwrap().fill(11u8);
writer_a.flush().unwrap();

let wal_file = vfs.open_read(Path::new("/test.db-wal")).unwrap();
let size = wal_file.size().unwrap();
let mut wal_bytes = vec![0u8; size as usize];
wal_file.read_at(&mut wal_bytes, 0).unwrap();
let header = wal::WalHeader::parse(&wal_bytes).unwrap();
let (pages, db_size) = wal::committed_pages(&header, &wal_bytes);
assert_eq!(db_size, 3);
assert_eq!(pages.get(&2), Some(&vec![11u8; 512]));
assert_eq!(pages.get(&3), Some(&vec![7u8; 512]));
}

/// #389's "readers don't block writers, writers don't block readers,
/// reader sees a consistent snapshot" invariant: a `Pager` opened
/// before a commit keeps its pre-commit view even after a second
Expand Down
Loading
Loading