diff --git a/.agent/specs/sqlite-vfs-staging-cache-ttl.md b/.agent/specs/sqlite-vfs-staging-cache-ttl.md new file mode 100644 index 0000000000..6316bbcde0 --- /dev/null +++ b/.agent/specs/sqlite-vfs-staging-cache-ttl.md @@ -0,0 +1,82 @@ +# SQLite VFS Staging Cache TTL Plan + +Date: 2026-05-03 + +This plan changes the SQLite VFS page cache from a broad second-level pager cache into a short-lived staging cache for speculative pages. Demand pages fetched for `xRead` should be handed to SQLite and then forgotten by the VFS. + +## Goals + +- Avoid retaining pages in VFS memory after SQLite has already received them through `xRead`. +- Keep startup preload and read-ahead useful by retaining speculative pages briefly. +- Evict speculative pages on first successful target read so TTL is only the fallback for unused preloads. +- Keep lazy loading correct when all cache and preload features are disabled. +- Treat page 1 as staging data after `xRead` while keeping parsed page-size and database-size metadata. + +## Non-Goals + +- Do not change the remote `get_pages` protocol. +- Do not change SQLite pager settings. +- Do not add read pools back. +- Do not implement persisted preload hints in this branch. + +## Current Behavior + +- `resolve_pages` classifies fetched pages as `Target` when SQLite requested them and `Prefetch` when they were predicted. +- `fetch_initial_pages_for_registration` seeds startup pages as `Startup`. +- `should_cache_page` allows target, prefetch, and startup caching based on `SqliteVfsPageCacheMode`. +- Page 1 is always cacheable. +- Early protected pages live in `protected_page_cache`, which is an `scc::HashMap` with no TTL. + +## Proposed Behavior + +- Target pages should not be inserted into the VFS page cache by default. +- Target reads should remove speculative read pages from the cache after bytes are copied to the caller. +- Prefetch pages should be inserted into a TTL cache. +- Startup preload pages should be inserted into the same TTL cache. +- Commit completion should stage dirty pages in a separate TTL cache so SQLite can reread its own writes without retaining them permanently. +- Page 1 should follow the same staging rule as other pages after `xRead`. The VFS keeps parsed page-size and database-size metadata, and it can synthesize the empty page-1 header again before the first commit when depot has no database yet. +- Protected cache should no longer protect speculative pages forever. It should be removed or left unused in favor of the TTL cache. + +## Configuration + +- Add `RIVETKIT_SQLITE_OPT_VFS_STAGING_CACHE_TTL_MS`. +- Default to a short TTL such as `30000` ms. +- A value of `0` disables speculative retention while preserving lazy target fetches. +- Keep `RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MODE=off` as the stronger kill switch for all non-page-1 VFS caching. +- Do not use `RIVETKIT_SQLITE_OPT_VFS_PROTECTED_CACHE_PAGES` to pin VFS page bytes beyond `xRead`. + +## Implementation Plan + +1. Extend `SqliteOptimizationFlags` and `VfsConfig` with a bounded staging TTL field. +2. Build `page_cache` with `time_to_live(Duration::from_millis(ttl_ms))` when TTL is nonzero. +3. Split cache insertion semantics so `PageCacheInsertKind::Target` is not retained by default. +4. Add an explicit `evict_pages_after_target_read` helper that removes every consumed page from both normal and protected speculative caches. +5. Call that helper after `io_read` copies returned bytes into SQLite's buffer. +6. Evict dirty page numbers from the staging cache after commit completion. +7. Rework `protected_page_cache` so it cannot pin speculative pages forever. +8. Keep `seed_main_page` behavior intact for parsed page 1 metadata. +9. Update metrics naming only if needed. `page_cache_entries` can continue to report retained VFS entries. + +## Expected Cache Matrix + +| Page source | Retained after fetch | Evicted on target read | TTL fallback | +| --- | --- | --- | --- | +| Target `xRead` miss | No | Not needed | No | +| Read-ahead prefetch | Yes | Yes | Yes | +| Startup preload | Yes | Yes | Yes | +| Page 1 | Yes during bootstrap or preload | Yes | Yes when retained | +| Dirty write buffer | Existing behavior | Existing behavior | No | + +## Tests + +- Add a VFS test proving a target read miss does not increase retained VFS cache entries. +- Add a VFS test proving prefetch pages are retained before use and removed after target read. +- Add a VFS test proving startup preload pages are retained briefly and removed after target read. +- Add a VFS test proving `VFS_STAGING_CACHE_TTL_MS=0` still lazily fetches pages. +- Add a VFS test proving `VFS_PAGE_CACHE_MODE=off` still lazily fetches pages and does not retain non-page-1 pages. +- If practical, use Tokio time pause/advance to verify TTL expiry deterministically instead of sleeping. + +## Open Questions + +- Should target retention remain available as an explicit benchmark mode, or should we remove target caching from the shipped matrix? +- Should `VFS_PROTECTED_CACHE_PAGES` be deprecated now that VFS pages are staging-only? diff --git a/docs-internal/engine/SQLITE_OPTIMIZATIONS.md b/docs-internal/engine/SQLITE_OPTIMIZATIONS.md index f79fabb8da..145d08e4c7 100644 --- a/docs-internal/engine/SQLITE_OPTIMIZATIONS.md +++ b/docs-internal/engine/SQLITE_OPTIMIZATIONS.md @@ -11,7 +11,10 @@ Range page-read protocol details live in `.agent/specs/sqlite-range-page-read-pr ## Existing Optimizations - Actor startup can preload SQLite VFS pages through `OpenConfig.preload_pgnos`, `OpenConfig.preload_ranges`, and persisted `/PRELOAD_HINTS`; first pages, hint mechanisms, and the preload byte budget are configured through central SQLite optimization flags. -- The VFS keeps an in-memory page cache seeded from `sqlite_startup_data.preloaded_pages`; cache behavior is selected with `RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MODE=off|target|startup|prefetch|all`, with capacity and protected-cache budget configured separately. +- The VFS keeps a short-lived staging cache for startup preload and read-ahead pages. Direct target pages fetched for `xRead` are not retained in VFS memory. +- Any speculative page consumed by `xRead`, including page 1, is evicted from the VFS staging cache after SQLite receives it. Before the first commit, a lazy page-1 read for a missing database synthesizes the empty SQLite header again instead of retaining page bytes. Staged pages that SQLite never reads expire through `RIVETKIT_SQLITE_OPT_VFS_STAGING_CACHE_TTL_MS`. +- Commit completion stages dirty pages in a separate TTL cache so SQLite can reread its own writes without turning the VFS into a permanent second pager. +- VFS staging cache behavior is selected with `RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MODE=off|target|startup|prefetch|all`, with capacity configured separately. The protected-cache budget no longer pins VFS page bytes beyond `xRead`. - The VFS has speculative read-ahead selected with `RIVETKIT_SQLITE_OPT_READ_AHEAD_MODE=off|bounded|adaptive`; the default bounded budget is 64 pages, which reduced the cold-read benchmark from 1,249 to 368 VFS `get_pages` calls. - The VFS tracks bounded recent page hints as hot pages plus coalesced scan ranges; `NativeDatabase::snapshot_preload_hints()` exposes the in-memory plan for future flush wiring. - Actor Prometheus metrics expose VFS read counters, fetched bytes, cache hits/misses, and `get_pages` duration at `/gateway//metrics`. diff --git a/engine/packages/depot-client/src/optimization_flags.rs b/engine/packages/depot-client/src/optimization_flags.rs index eb068a17bf..c93398e61f 100644 --- a/engine/packages/depot-client/src/optimization_flags.rs +++ b/engine/packages/depot-client/src/optimization_flags.rs @@ -22,6 +22,7 @@ pub const VFS_PAGE_CACHE_MODE_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MO pub const VFS_PAGE_CACHE_CAPACITY_PAGES_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_CAPACITY_PAGES"; pub const VFS_PROTECTED_CACHE_PAGES_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PROTECTED_CACHE_PAGES"; +pub const VFS_STAGING_CACHE_TTL_MS_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_STAGING_CACHE_TTL_MS"; pub const DEFAULT_STARTUP_PRELOAD_MAX_BYTES: usize = 1024 * 1024; pub const MAX_STARTUP_PRELOAD_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -31,6 +32,8 @@ pub const DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES: u64 = 50_000; pub const MAX_VFS_PAGE_CACHE_CAPACITY_PAGES: u64 = 500_000; pub const DEFAULT_VFS_PROTECTED_CACHE_PAGES: usize = 512; pub const MAX_VFS_PROTECTED_CACHE_PAGES: usize = 8_192; +pub const DEFAULT_VFS_STAGING_CACHE_TTL_MS: u64 = 30_000; +pub const MAX_VFS_STAGING_CACHE_TTL_MS: u64 = 300_000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SqliteReadAheadMode { @@ -102,6 +105,7 @@ pub struct SqliteOptimizationFlags { pub vfs_page_cache_mode: SqliteVfsPageCacheMode, pub vfs_page_cache_capacity_pages: u64, pub vfs_protected_cache_pages: usize, + pub vfs_staging_cache_ttl_ms: u64, } impl Default for SqliteOptimizationFlags { @@ -128,6 +132,7 @@ impl Default for SqliteOptimizationFlags { vfs_page_cache_mode: SqliteVfsPageCacheMode::All, vfs_page_cache_capacity_pages: DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES, vfs_protected_cache_pages: DEFAULT_VFS_PROTECTED_CACHE_PAGES, + vfs_staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS, } } } @@ -196,6 +201,11 @@ impl SqliteOptimizationFlags { DEFAULT_VFS_PROTECTED_CACHE_PAGES, MAX_VFS_PROTECTED_CACHE_PAGES, ), + vfs_staging_cache_ttl_ms: u64_bounded_by_default( + read_env(VFS_STAGING_CACHE_TTL_MS_ENV).as_deref(), + DEFAULT_VFS_STAGING_CACHE_TTL_MS, + MAX_VFS_STAGING_CACHE_TTL_MS, + ), } } } @@ -307,6 +317,7 @@ mod tests { VFS_PAGE_CACHE_MODE_ENV => Some("off".to_string()), VFS_PAGE_CACHE_CAPACITY_PAGES_ENV => Some("0".to_string()), VFS_PROTECTED_CACHE_PAGES_ENV => Some("0".to_string()), + VFS_STAGING_CACHE_TTL_MS_ENV => Some("0".to_string()), _ => None, }); @@ -327,6 +338,7 @@ mod tests { assert_eq!(flags.vfs_page_cache_mode, SqliteVfsPageCacheMode::Off); assert_eq!(flags.vfs_page_cache_capacity_pages, 0); assert_eq!(flags.vfs_protected_cache_pages, 0); + assert_eq!(flags.vfs_staging_cache_ttl_ms, 0); } #[test] @@ -336,6 +348,7 @@ mod tests { STARTUP_PRELOAD_FIRST_PAGE_COUNT_ENV => Some("nope".to_string()), VFS_PAGE_CACHE_CAPACITY_PAGES_ENV => Some("invalid".to_string()), VFS_PROTECTED_CACHE_PAGES_ENV => Some("invalid".to_string()), + VFS_STAGING_CACHE_TTL_MS_ENV => Some("invalid".to_string()), _ => None, }); assert_eq!( @@ -354,6 +367,10 @@ mod tests { invalid.vfs_protected_cache_pages, DEFAULT_VFS_PROTECTED_CACHE_PAGES ); + assert_eq!( + invalid.vfs_staging_cache_ttl_ms, + DEFAULT_VFS_STAGING_CACHE_TTL_MS + ); let clamped = SqliteOptimizationFlags::from_env_reader(|key| match key { STARTUP_PRELOAD_MAX_BYTES_ENV => Some((MAX_STARTUP_PRELOAD_MAX_BYTES + 1).to_string()), @@ -364,6 +381,7 @@ mod tests { Some((MAX_VFS_PAGE_CACHE_CAPACITY_PAGES + 1).to_string()) } VFS_PROTECTED_CACHE_PAGES_ENV => Some((MAX_VFS_PROTECTED_CACHE_PAGES + 1).to_string()), + VFS_STAGING_CACHE_TTL_MS_ENV => Some((MAX_VFS_STAGING_CACHE_TTL_MS + 1).to_string()), _ => None, }); assert_eq!( @@ -382,5 +400,9 @@ mod tests { clamped.vfs_protected_cache_pages, MAX_VFS_PROTECTED_CACHE_PAGES ); + assert_eq!( + clamped.vfs_staging_cache_ttl_ms, + MAX_VFS_STAGING_CACHE_TTL_MS + ); } } diff --git a/engine/packages/depot-client/src/vfs.rs b/engine/packages/depot-client/src/vfs.rs index b8ddc6b409..287ee73638 100644 --- a/engine/packages/depot-client/src/vfs.rs +++ b/engine/packages/depot-client/src/vfs.rs @@ -134,6 +134,7 @@ pub struct VfsConfig { pub cache_capacity_pages: u64, pub protected_cache_pages: usize, pub page_cache_mode: SqliteVfsPageCacheMode, + pub staging_cache_ttl_ms: u64, pub prefetch_depth: usize, pub adaptive_prefetch_depth: usize, pub max_prefetch_bytes: usize, @@ -168,12 +169,13 @@ impl VfsConfig { } else { 0 }, - protected_cache_pages: if caches_pages { - flags.vfs_protected_cache_pages + protected_cache_pages: 0, + page_cache_mode: flags.vfs_page_cache_mode, + staging_cache_ttl_ms: if caches_pages { + flags.vfs_staging_cache_ttl_ms } else { 0 }, - page_cache_mode: flags.vfs_page_cache_mode, prefetch_depth: if flags.read_ahead { DEFAULT_PREFETCH_DEPTH } else { @@ -344,6 +346,7 @@ struct VfsState { db_size_pages: u32, page_size: usize, page_cache: Cache>, + committed_page_cache: Cache>, protected_page_cache: Arc>>, write_buffer: WriteBuffer, predictor: PrefetchPredictor, @@ -790,13 +793,13 @@ fn push_coalesced_range(ranges: &mut VecDeque, range: VfsPr impl VfsState { fn new(config: &VfsConfig) -> Self { - let page_cache = Cache::builder() - .max_capacity(config.cache_capacity_pages) - .build(); + let page_cache = build_page_cache(config); + let committed_page_cache = build_page_cache(config); let mut state = Self { db_size_pages: 1, page_size: DEFAULT_PAGE_SIZE, page_cache, + committed_page_cache, protected_page_cache: Arc::new(SccHashMap::new()), write_buffer: WriteBuffer::default(), predictor: PrefetchPredictor::default(), @@ -807,7 +810,7 @@ impl VfsState { ), dead: false, }; - state.cache_page(config, PageCacheInsertKind::Target, 1, empty_db_page()); + state.cache_page(config, PageCacheInsertKind::Startup, 1, empty_db_page()); state } @@ -836,11 +839,26 @@ impl VfsState { return None; } self - .protected_page_cache - .read_sync(&pgno, |_, bytes| bytes.clone()) + .committed_page_cache + .get(&pgno) + .or_else(|| self.protected_page_cache.read_sync(&pgno, |_, bytes| bytes.clone())) .or_else(|| self.page_cache.get(&pgno)) } + fn cache_committed_page(&mut self, config: &VfsConfig, pgno: u32, bytes: Vec) { + if config.staging_cache_ttl_ms == 0 || !config.page_cache_mode.caches_any_pages() { + return; + } + self.committed_page_cache.insert(pgno, bytes); + } + + fn evict_target_read_pages(&self, pgnos: &[u32]) { + for pgno in pgnos.iter().copied() { + self.page_cache.invalidate(&pgno); + self.protected_page_cache.remove_sync(&pgno); + } + } + fn seed_page(&mut self, config: &VfsConfig, kind: PageCacheInsertKind, pgno: u32, page: Vec) { if pgno == 1 { self.seed_main_page(config, kind, page); @@ -861,14 +879,24 @@ impl VfsState { fn invalidate_page_cache(&mut self) { self.page_cache.invalidate_all(); + self.committed_page_cache.invalidate_all(); self.protected_page_cache.clear_sync(); } } +fn build_page_cache(config: &VfsConfig) -> Cache> { + let mut page_cache_builder = Cache::builder().max_capacity(config.cache_capacity_pages); + if config.staging_cache_ttl_ms > 0 { + page_cache_builder = + page_cache_builder.time_to_live(Duration::from_millis(config.staging_cache_ttl_ms)); + } + page_cache_builder.build() +} + fn cache_page( config: &VfsConfig, page_cache: &Cache>, - protected_page_cache: &SccHashMap>, + _protected_page_cache: &SccHashMap>, kind: PageCacheInsertKind, pgno: u32, bytes: Vec, @@ -876,21 +904,20 @@ fn cache_page( if !should_cache_page(config, kind, pgno) { return; } - if pgno <= config.protected_cache_pages as u32 { - let _ = protected_page_cache.upsert_sync(pgno, bytes); - } else { - page_cache.insert(pgno, bytes); - } + page_cache.insert(pgno, bytes); } fn should_cache_page(config: &VfsConfig, kind: PageCacheInsertKind, pgno: u32) -> bool { - if pgno == 1 { - return true; - } match kind { - PageCacheInsertKind::Target => config.page_cache_mode.caches_target_pages(), - PageCacheInsertKind::Prefetch => config.page_cache_mode.caches_prefetched_pages(), - PageCacheInsertKind::Startup => config.page_cache_mode.caches_startup_preloaded_pages(), + PageCacheInsertKind::Target => false, + PageCacheInsertKind::Prefetch => { + config.staging_cache_ttl_ms > 0 && config.page_cache_mode.caches_prefetched_pages() + } + PageCacheInsertKind::Startup => { + pgno == 1 + || (config.staging_cache_ttl_ms > 0 + && config.page_cache_mode.caches_startup_preloaded_pages()) + } } } @@ -999,6 +1026,7 @@ impl VfsContext { page_cache_entries: state .page_cache .entry_count() + .saturating_add(state.committed_page_cache.entry_count()) .saturating_add(state.protected_page_cache.len() as u64), page_cache_capacity_pages: self.config.cache_capacity_pages, write_buffer_dirty_pages: state.write_buffer.dirty.len() as u64, @@ -1298,7 +1326,16 @@ impl VfsContext { } } } - if let Some(bytes) = &fetched.bytes { + let bytes = if fetched.bytes.is_none() + && self.commit_total.load(Relaxed) == 0 + && missing_pages.contains(&fetched.pgno) + && fetched.pgno == 1 + { + Some(empty_db_page()) + } else { + fetched.bytes + }; + if let Some(bytes) = &bytes { let kind = if missing_pages.contains(&fetched.pgno) { PageCacheInsertKind::Target } else { @@ -1313,7 +1350,7 @@ impl VfsContext { bytes.clone(), ); } - resolved.insert(fetched.pgno, fetched.bytes); + resolved.insert(fetched.pgno, bytes); } #[cfg(debug_assertions)] { @@ -1350,6 +1387,20 @@ impl VfsContext { Ok(resolved) } protocol::SqliteGetPagesResponse::SqliteErrorResponse(error) => { + if self.commit_total.load(Relaxed) == 0 + && missing.contains(&1) + && is_initial_main_page_missing(&error.message) + { + for pgno in missing { + let bytes = if pgno == 1 { + Some(empty_db_page()) + } else { + None + }; + resolved.entry(pgno).or_insert(bytes); + } + return Ok(resolved); + } Err(GetPagesError::Other(error.message)) } } @@ -1450,12 +1501,7 @@ impl VfsContext { let mut state = self.state.write(); state.db_size_pages = request.new_db_size_pages; for dirty_page in &request.dirty_pages { - state.cache_page( - &self.config, - PageCacheInsertKind::Target, - dirty_page.pgno, - dirty_page.bytes.clone(), - ); + state.cache_committed_page(&self.config, dirty_page.pgno, dirty_page.bytes.clone()); } state.write_buffer.dirty.clear(); let state_update_ns = state_update_start.elapsed().as_nanos() as u64; @@ -1563,12 +1609,7 @@ impl VfsContext { let mut state = self.state.write(); state.db_size_pages = request.new_db_size_pages; for dirty_page in &request.dirty_pages { - state.cache_page( - &self.config, - PageCacheInsertKind::Target, - dirty_page.pgno, - dirty_page.bytes.clone(), - ); + state.cache_committed_page(&self.config, dirty_page.pgno, dirty_page.bytes.clone()); } state.write_buffer.dirty.clear(); state.write_buffer.in_atomic_write = false; @@ -2121,6 +2162,12 @@ unsafe extern "C" fn io_read( let resolved = match ctx.resolve_pages(&requested_pages, true) { Ok(pages) => pages, Err(GetPagesError::Other(message)) => { + tracing::error!( + actor_id = %ctx.actor_id, + requested_pages = ?requested_pages, + error = %message, + "sqlite xRead failed to resolve pages" + ); ctx.mark_dead(message); return SQLITE_IOERR_READ; } @@ -2151,7 +2198,7 @@ unsafe extern "C" fn io_read( } buf.fill(0); - for pgno in requested_pages { + for pgno in requested_pages.iter().copied() { let Some(Some(bytes)) = resolved.get(&pgno) else { continue; }; @@ -2167,6 +2214,7 @@ unsafe extern "C" fn io_read( buf[dest_offset..dest_offset + copy_len] .copy_from_slice(&bytes[page_offset..page_offset + copy_len]); } + ctx.state.read().evict_target_read_pages(&requested_pages); if i_offset as usize + i_amt as usize > file_size { return SQLITE_IOERR_SHORT_READ; diff --git a/engine/packages/depot-client/tests/inline/fault/scenario.rs b/engine/packages/depot-client/tests/inline/fault/scenario.rs index 251b134147..08b470eeb5 100644 --- a/engine/packages/depot-client/tests/inline/fault/scenario.rs +++ b/engine/packages/depot-client/tests/inline/fault/scenario.rs @@ -642,15 +642,16 @@ impl FaultScenarioCtx { pub(crate) async fn seed_page_as_cold_ref_for_harness_test(&self, pgno: u32) -> Result<()> { let dirty_pages = self.with_database_blocking(|db| { - let state = db._vfs.ctx().state.read(); + let ctx = db._vfs.ctx(); + let state = ctx.state.read(); (1..=state.db_size_pages) .filter(|candidate_pgno| { *candidate_pgno / depot::keys::SHARD_SIZE == pgno / depot::keys::SHARD_SIZE }) .map(|candidate_pgno| { - let bytes = state.page_cache.get(&candidate_pgno).with_context(|| { + let bytes = state.cached_page(&ctx.config, candidate_pgno).with_context(|| { format!( - "page {candidate_pgno} should be present in strict VFS cache before cold-ref seed" + "page {candidate_pgno} should be present in VFS cache before cold-ref seed" ) })?; Ok(DirtyPage { diff --git a/engine/packages/depot-client/tests/inline/vfs.rs b/engine/packages/depot-client/tests/inline/vfs.rs index 1cea01ec79..bacb876dc0 100644 --- a/engine/packages/depot-client/tests/inline/vfs.rs +++ b/engine/packages/depot-client/tests/inline/vfs.rs @@ -21,8 +21,8 @@ use tokio::sync::OnceCell; use crate::optimization_flags::{ DEFAULT_STARTUP_PRELOAD_MAX_BYTES, DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES, - DEFAULT_VFS_PROTECTED_CACHE_PAGES, SqliteOptimizationFlags, SqliteReadAheadMode, - SqliteVfsPageCacheMode, + DEFAULT_VFS_PROTECTED_CACHE_PAGES, DEFAULT_VFS_STAGING_CACHE_TTL_MS, + SqliteOptimizationFlags, SqliteReadAheadMode, SqliteVfsPageCacheMode, }; use crate::query::{BindParam, ColumnValue}; use crate::vfs::SqliteVfsMetrics; @@ -55,6 +55,7 @@ fn vfs_config_wires_optimization_flags() { vfs_page_cache_mode: SqliteVfsPageCacheMode::Startup, vfs_page_cache_capacity_pages: DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES / 2, vfs_protected_cache_pages: DEFAULT_VFS_PROTECTED_CACHE_PAGES / 2, + vfs_staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS / 2, }; let config = VfsConfig::from_optimization_flags(flags); @@ -65,7 +66,11 @@ fn vfs_config_wires_optimization_flags() { ); assert_eq!( config.protected_cache_pages, - DEFAULT_VFS_PROTECTED_CACHE_PAGES / 2 + 0 + ); + assert_eq!( + config.staging_cache_ttl_ms, + DEFAULT_VFS_STAGING_CACHE_TTL_MS / 2 ); assert_eq!(config.prefetch_depth, 16); assert!(!config.adaptive_read_ahead); @@ -115,6 +120,29 @@ impl SqliteTransport for RecordingInitialPagesTransport { } } +struct MissingDbTransport; + +#[async_trait] +impl SqliteTransport for MissingDbTransport { + async fn get_pages( + &self, + _request: protocol::SqliteGetPagesRequest, + ) -> anyhow::Result { + Ok(protocol::SqliteGetPagesResponse::SqliteErrorResponse( + protocol::SqliteErrorResponse { + message: "sqlite database was not found in this bucket branch".to_string(), + }, + )) + } + + async fn commit( + &self, + _request: protocol::SqliteCommitRequest, + ) -> anyhow::Result { + anyhow::bail!("missing-db transport test does not commit") + } +} + #[test] fn startup_initial_pages_do_not_require_preload_hints_on_open() { let runtime = direct_runtime(); @@ -141,6 +169,74 @@ fn startup_initial_pages_do_not_require_preload_hints_on_open() { assert_eq!(loaded_pgnos, vec![1, 2, 3, 4]); } +#[test] +fn vfs_staging_cache_retains_only_speculative_pages() { + let config = VfsConfig { + page_cache_mode: SqliteVfsPageCacheMode::All, + staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS, + ..VfsConfig::default() + }; + let mut state = VfsState::new(&config); + + state.cache_page(&config, PageCacheInsertKind::Target, 2, vec![2; DEFAULT_PAGE_SIZE]); + assert!(state.cached_page(&config, 2).is_none()); + + state.cache_page(&config, PageCacheInsertKind::Prefetch, 3, vec![3; DEFAULT_PAGE_SIZE]); + state.cache_page(&config, PageCacheInsertKind::Startup, 4, vec![4; DEFAULT_PAGE_SIZE]); + assert!(state.cached_page(&config, 3).is_some()); + assert!(state.cached_page(&config, 4).is_some()); + assert!(state.protected_page_cache.read_sync(&3, |_, _| ()).is_none()); + + state.evict_target_read_pages(&[1, 3, 4]); + assert!(state.cached_page(&config, 1).is_none()); + assert!(state.cached_page(&config, 3).is_none()); + assert!(state.cached_page(&config, 4).is_none()); +} + +#[test] +fn vfs_staging_cache_ttl_zero_disables_speculative_retention() { + let config = VfsConfig { + page_cache_mode: SqliteVfsPageCacheMode::All, + staging_cache_ttl_ms: 0, + ..VfsConfig::default() + }; + let mut state = VfsState::new(&config); + + state.cache_page(&config, PageCacheInsertKind::Prefetch, 2, vec![2; DEFAULT_PAGE_SIZE]); + state.cache_page(&config, PageCacheInsertKind::Startup, 3, vec![3; DEFAULT_PAGE_SIZE]); + assert!(state.cached_page(&config, 1).is_some()); + assert!(state.cached_page(&config, 2).is_none()); + assert!(state.cached_page(&config, 3).is_none()); + + state.evict_target_read_pages(&[1]); + assert!(state.cached_page(&config, 1).is_none()); +} + +#[test] +fn evicted_empty_page_one_can_be_synthesized_before_first_commit() { + let runtime = direct_runtime(); + let config = VfsConfig::default(); + let ctx = VfsContext::new( + next_test_name("missing-db-actor"), + runtime.handle().clone(), + Arc::new(MissingDbTransport), + config.clone(), + unsafe { std::mem::zeroed() }, + Vec::new(), + None, + ) + .expect("vfs context should build"); + + ctx.state.read().evict_target_read_pages(&[1]); + assert!(ctx.state.read().cached_page(&config, 1).is_none()); + + let resolved = ctx + .resolve_pages(&[1], true) + .expect("missing empty database should synthesize page 1"); + assert_eq!(resolved.get(&1), Some(&Some(empty_db_page()))); + assert!(ctx.state.read().cached_page(&config, 1).is_none()); +} + fn next_test_name(prefix: &str) -> String { let id = TEST_ID.fetch_add(1, Ordering::Relaxed); format!("{prefix}-{id}")