Skip to content

Commit 17e8f52

Browse files
committed
feat(depot-client): add vfs staging cache ttl
1 parent e5462a4 commit 17e8f52

5 files changed

Lines changed: 180 additions & 11 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# SQLite VFS Staging Cache TTL Plan
2+
3+
Date: 2026-05-03
4+
5+
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 unless a small explicit exception applies.
6+
7+
## Goals
8+
9+
- Avoid retaining pages in VFS memory after SQLite has already received them through `xRead`.
10+
- Keep startup preload and read-ahead useful by retaining speculative pages briefly.
11+
- Evict speculative pages on first successful target read so TTL is only the fallback for unused preloads.
12+
- Keep lazy loading correct when all cache and preload features are disabled.
13+
- Preserve page 1 handling unless we explicitly decide to make it configurable later.
14+
15+
## Non-Goals
16+
17+
- Do not change the remote `get_pages` protocol.
18+
- Do not change SQLite pager settings.
19+
- Do not add read pools back.
20+
- Do not implement persisted preload hints in this branch.
21+
22+
## Current Behavior
23+
24+
- `resolve_pages` classifies fetched pages as `Target` when SQLite requested them and `Prefetch` when they were predicted.
25+
- `fetch_initial_pages_for_registration` seeds startup pages as `Startup`.
26+
- `should_cache_page` allows target, prefetch, and startup caching based on `SqliteVfsPageCacheMode`.
27+
- Page 1 is always cacheable.
28+
- Early protected pages live in `protected_page_cache`, which is an `scc::HashMap` with no TTL.
29+
30+
## Proposed Behavior
31+
32+
- Target pages should not be inserted into the VFS page cache by default.
33+
- Target reads should remove the read pages from the speculative cache after bytes are copied to the caller.
34+
- Prefetch pages should be inserted into a TTL cache.
35+
- Startup preload pages should be inserted into the same TTL cache.
36+
- Page 1 should remain retained for now because it carries page size and database size metadata and is already special-cased throughout the VFS.
37+
- Protected cache should no longer protect arbitrary early speculative pages forever. It should either be limited to page 1 or removed in favor of the TTL cache.
38+
39+
## Configuration
40+
41+
- Add `RIVETKIT_SQLITE_OPT_VFS_STAGING_CACHE_TTL_MS`.
42+
- Default to a short TTL such as `30000` ms.
43+
- A value of `0` disables speculative retention while preserving lazy target fetches.
44+
- Keep `RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MODE=off` as the stronger kill switch for all non-page-1 VFS caching.
45+
- Consider narrowing `RIVETKIT_SQLITE_OPT_VFS_PROTECTED_CACHE_PAGES` so it does not pin preload/read-ahead pages indefinitely.
46+
47+
## Implementation Plan
48+
49+
1. Extend `SqliteOptimizationFlags` and `VfsConfig` with a bounded staging TTL field.
50+
2. Build `page_cache` with `time_to_live(Duration::from_millis(ttl_ms))` when TTL is nonzero.
51+
3. Split cache insertion semantics so `PageCacheInsertKind::Target` is not retained by default.
52+
4. Add an explicit `evict_pages_after_target_read` helper that removes read pages from both normal and protected speculative caches.
53+
5. Call that helper after `io_read` copies returned bytes into SQLite's buffer.
54+
6. Rework `protected_page_cache` so it cannot pin non-page-1 speculative pages forever.
55+
7. Keep `seed_main_page` behavior intact for page 1 metadata.
56+
8. Update metrics naming only if needed. `page_cache_entries` can continue to report retained VFS entries.
57+
58+
## Expected Cache Matrix
59+
60+
| Page source | Retained after fetch | Evicted on target read | TTL fallback |
61+
| --- | --- | --- | --- |
62+
| Target `xRead` miss | No | Not needed | No |
63+
| Read-ahead prefetch | Yes | Yes | Yes |
64+
| Startup preload | Yes | Yes | Yes |
65+
| Page 1 | Yes | No | No |
66+
| Dirty write buffer | Existing behavior | Existing behavior | No |
67+
68+
## Tests
69+
70+
- Add a VFS test proving a target read miss does not increase retained VFS cache entries.
71+
- Add a VFS test proving prefetch pages are retained before use and removed after target read.
72+
- Add a VFS test proving startup preload pages are retained briefly and removed after target read.
73+
- Add a VFS test proving `VFS_STAGING_CACHE_TTL_MS=0` still lazily fetches pages.
74+
- Add a VFS test proving `VFS_PAGE_CACHE_MODE=off` still lazily fetches pages and does not retain non-page-1 pages.
75+
- If practical, use Tokio time pause/advance to verify TTL expiry deterministically instead of sleeping.
76+
77+
## Open Questions
78+
79+
- Should page 1 stay permanently retained, or should strict low-memory mode make even page 1 reloadable?
80+
- Should target retention remain available as an explicit benchmark mode, or should we remove target caching from the shipped matrix?
81+
- Should `VFS_PROTECTED_CACHE_PAGES` be deprecated in favor of page-1-only retention?

docs-internal/engine/SQLITE_OPTIMIZATIONS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Range page-read protocol details live in `.agent/specs/sqlite-range-page-read-pr
1111
## Existing Optimizations
1212

1313
- 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.
14-
- 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.
14+
- The VFS keeps a short-lived staging cache for speculative startup preload and read-ahead pages. Direct target pages fetched for `xRead` are not retained after SQLite receives them; speculative entries are invalidated on first target read and also expire through `RIVETKIT_SQLITE_OPT_VFS_STAGING_CACHE_TTL_MS`.
15+
- VFS staging cache behavior is selected with `RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MODE=off|target|startup|prefetch|all`, with capacity and page-1 protected-cache behavior configured separately.
1516
- 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.
1617
- 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.
1718
- Actor Prometheus metrics expose VFS read counters, fetched bytes, cache hits/misses, and `get_pages` duration at `/gateway/<actor_id>/metrics`.

engine/packages/depot-client/src/optimization_flags.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub const VFS_PAGE_CACHE_MODE_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MO
2222
pub const VFS_PAGE_CACHE_CAPACITY_PAGES_ENV: &str =
2323
"RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_CAPACITY_PAGES";
2424
pub const VFS_PROTECTED_CACHE_PAGES_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_PROTECTED_CACHE_PAGES";
25+
pub const VFS_STAGING_CACHE_TTL_MS_ENV: &str = "RIVETKIT_SQLITE_OPT_VFS_STAGING_CACHE_TTL_MS";
2526

2627
pub const DEFAULT_STARTUP_PRELOAD_MAX_BYTES: usize = 1024 * 1024;
2728
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;
3132
pub const MAX_VFS_PAGE_CACHE_CAPACITY_PAGES: u64 = 500_000;
3233
pub const DEFAULT_VFS_PROTECTED_CACHE_PAGES: usize = 512;
3334
pub const MAX_VFS_PROTECTED_CACHE_PAGES: usize = 8_192;
35+
pub const DEFAULT_VFS_STAGING_CACHE_TTL_MS: u64 = 30_000;
36+
pub const MAX_VFS_STAGING_CACHE_TTL_MS: u64 = 300_000;
3437

3538
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3639
pub enum SqliteReadAheadMode {
@@ -102,6 +105,7 @@ pub struct SqliteOptimizationFlags {
102105
pub vfs_page_cache_mode: SqliteVfsPageCacheMode,
103106
pub vfs_page_cache_capacity_pages: u64,
104107
pub vfs_protected_cache_pages: usize,
108+
pub vfs_staging_cache_ttl_ms: u64,
105109
}
106110

107111
impl Default for SqliteOptimizationFlags {
@@ -128,6 +132,7 @@ impl Default for SqliteOptimizationFlags {
128132
vfs_page_cache_mode: SqliteVfsPageCacheMode::All,
129133
vfs_page_cache_capacity_pages: DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES,
130134
vfs_protected_cache_pages: DEFAULT_VFS_PROTECTED_CACHE_PAGES,
135+
vfs_staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS,
131136
}
132137
}
133138
}
@@ -196,6 +201,11 @@ impl SqliteOptimizationFlags {
196201
DEFAULT_VFS_PROTECTED_CACHE_PAGES,
197202
MAX_VFS_PROTECTED_CACHE_PAGES,
198203
),
204+
vfs_staging_cache_ttl_ms: u64_bounded_by_default(
205+
read_env(VFS_STAGING_CACHE_TTL_MS_ENV).as_deref(),
206+
DEFAULT_VFS_STAGING_CACHE_TTL_MS,
207+
MAX_VFS_STAGING_CACHE_TTL_MS,
208+
),
199209
}
200210
}
201211
}
@@ -307,6 +317,7 @@ mod tests {
307317
VFS_PAGE_CACHE_MODE_ENV => Some("off".to_string()),
308318
VFS_PAGE_CACHE_CAPACITY_PAGES_ENV => Some("0".to_string()),
309319
VFS_PROTECTED_CACHE_PAGES_ENV => Some("0".to_string()),
320+
VFS_STAGING_CACHE_TTL_MS_ENV => Some("0".to_string()),
310321
_ => None,
311322
});
312323

@@ -327,6 +338,7 @@ mod tests {
327338
assert_eq!(flags.vfs_page_cache_mode, SqliteVfsPageCacheMode::Off);
328339
assert_eq!(flags.vfs_page_cache_capacity_pages, 0);
329340
assert_eq!(flags.vfs_protected_cache_pages, 0);
341+
assert_eq!(flags.vfs_staging_cache_ttl_ms, 0);
330342
}
331343

332344
#[test]
@@ -336,6 +348,7 @@ mod tests {
336348
STARTUP_PRELOAD_FIRST_PAGE_COUNT_ENV => Some("nope".to_string()),
337349
VFS_PAGE_CACHE_CAPACITY_PAGES_ENV => Some("invalid".to_string()),
338350
VFS_PROTECTED_CACHE_PAGES_ENV => Some("invalid".to_string()),
351+
VFS_STAGING_CACHE_TTL_MS_ENV => Some("invalid".to_string()),
339352
_ => None,
340353
});
341354
assert_eq!(
@@ -354,6 +367,10 @@ mod tests {
354367
invalid.vfs_protected_cache_pages,
355368
DEFAULT_VFS_PROTECTED_CACHE_PAGES
356369
);
370+
assert_eq!(
371+
invalid.vfs_staging_cache_ttl_ms,
372+
DEFAULT_VFS_STAGING_CACHE_TTL_MS
373+
);
357374

358375
let clamped = SqliteOptimizationFlags::from_env_reader(|key| match key {
359376
STARTUP_PRELOAD_MAX_BYTES_ENV => Some((MAX_STARTUP_PRELOAD_MAX_BYTES + 1).to_string()),
@@ -364,6 +381,7 @@ mod tests {
364381
Some((MAX_VFS_PAGE_CACHE_CAPACITY_PAGES + 1).to_string())
365382
}
366383
VFS_PROTECTED_CACHE_PAGES_ENV => Some((MAX_VFS_PROTECTED_CACHE_PAGES + 1).to_string()),
384+
VFS_STAGING_CACHE_TTL_MS_ENV => Some((MAX_VFS_STAGING_CACHE_TTL_MS + 1).to_string()),
367385
_ => None,
368386
});
369387
assert_eq!(
@@ -382,5 +400,9 @@ mod tests {
382400
clamped.vfs_protected_cache_pages,
383401
MAX_VFS_PROTECTED_CACHE_PAGES
384402
);
403+
assert_eq!(
404+
clamped.vfs_staging_cache_ttl_ms,
405+
MAX_VFS_STAGING_CACHE_TTL_MS
406+
);
385407
}
386408
}

engine/packages/depot-client/src/vfs.rs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ pub struct VfsConfig {
134134
pub cache_capacity_pages: u64,
135135
pub protected_cache_pages: usize,
136136
pub page_cache_mode: SqliteVfsPageCacheMode,
137+
pub staging_cache_ttl_ms: u64,
137138
pub prefetch_depth: usize,
138139
pub adaptive_prefetch_depth: usize,
139140
pub max_prefetch_bytes: usize,
@@ -169,11 +170,16 @@ impl VfsConfig {
169170
0
170171
},
171172
protected_cache_pages: if caches_pages {
172-
flags.vfs_protected_cache_pages
173+
usize::from(flags.vfs_protected_cache_pages > 0)
173174
} else {
174175
0
175176
},
176177
page_cache_mode: flags.vfs_page_cache_mode,
178+
staging_cache_ttl_ms: if caches_pages {
179+
flags.vfs_staging_cache_ttl_ms
180+
} else {
181+
0
182+
},
177183
prefetch_depth: if flags.read_ahead {
178184
DEFAULT_PREFETCH_DEPTH
179185
} else {
@@ -790,9 +796,12 @@ fn push_coalesced_range(ranges: &mut VecDeque<VfsPreloadHintRange>, range: VfsPr
790796

791797
impl VfsState {
792798
fn new(config: &VfsConfig) -> Self {
793-
let page_cache = Cache::builder()
794-
.max_capacity(config.cache_capacity_pages)
795-
.build();
799+
let mut page_cache_builder = Cache::builder().max_capacity(config.cache_capacity_pages);
800+
if config.staging_cache_ttl_ms > 0 {
801+
page_cache_builder =
802+
page_cache_builder.time_to_live(Duration::from_millis(config.staging_cache_ttl_ms));
803+
}
804+
let page_cache = page_cache_builder.build();
796805
let mut state = Self {
797806
db_size_pages: 1,
798807
page_size: DEFAULT_PAGE_SIZE,
@@ -841,6 +850,13 @@ impl VfsState {
841850
.or_else(|| self.page_cache.get(&pgno))
842851
}
843852

853+
fn evict_target_read_pages(&self, pgnos: &[u32]) {
854+
for pgno in pgnos.iter().copied().filter(|pgno| *pgno != 1) {
855+
self.page_cache.invalidate(&pgno);
856+
self.protected_page_cache.remove_sync(&pgno);
857+
}
858+
}
859+
844860
fn seed_page(&mut self, config: &VfsConfig, kind: PageCacheInsertKind, pgno: u32, page: Vec<u8>) {
845861
if pgno == 1 {
846862
self.seed_main_page(config, kind, page);
@@ -876,7 +892,7 @@ fn cache_page(
876892
if !should_cache_page(config, kind, pgno) {
877893
return;
878894
}
879-
if pgno <= config.protected_cache_pages as u32 {
895+
if pgno == 1 && config.protected_cache_pages > 0 {
880896
let _ = protected_page_cache.upsert_sync(pgno, bytes);
881897
} else {
882898
page_cache.insert(pgno, bytes);
@@ -887,8 +903,11 @@ fn should_cache_page(config: &VfsConfig, kind: PageCacheInsertKind, pgno: u32) -
887903
if pgno == 1 {
888904
return true;
889905
}
906+
if config.staging_cache_ttl_ms == 0 {
907+
return false;
908+
}
890909
match kind {
891-
PageCacheInsertKind::Target => config.page_cache_mode.caches_target_pages(),
910+
PageCacheInsertKind::Target => false,
892911
PageCacheInsertKind::Prefetch => config.page_cache_mode.caches_prefetched_pages(),
893912
PageCacheInsertKind::Startup => config.page_cache_mode.caches_startup_preloaded_pages(),
894913
}
@@ -2151,7 +2170,7 @@ unsafe extern "C" fn io_read(
21512170
}
21522171

21532172
buf.fill(0);
2154-
for pgno in requested_pages {
2173+
for pgno in requested_pages.iter().copied() {
21552174
let Some(Some(bytes)) = resolved.get(&pgno) else {
21562175
continue;
21572176
};
@@ -2167,6 +2186,7 @@ unsafe extern "C" fn io_read(
21672186
buf[dest_offset..dest_offset + copy_len]
21682187
.copy_from_slice(&bytes[page_offset..page_offset + copy_len]);
21692188
}
2189+
ctx.state.read().evict_target_read_pages(&requested_pages);
21702190

21712191
if i_offset as usize + i_amt as usize > file_size {
21722192
return SQLITE_IOERR_SHORT_READ;

engine/packages/depot-client/tests/inline/vfs.rs

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ use tokio::sync::OnceCell;
2121

2222
use crate::optimization_flags::{
2323
DEFAULT_STARTUP_PRELOAD_MAX_BYTES, DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES,
24-
DEFAULT_VFS_PROTECTED_CACHE_PAGES, SqliteOptimizationFlags, SqliteReadAheadMode,
25-
SqliteVfsPageCacheMode,
24+
DEFAULT_VFS_PROTECTED_CACHE_PAGES, DEFAULT_VFS_STAGING_CACHE_TTL_MS,
25+
SqliteOptimizationFlags, SqliteReadAheadMode, SqliteVfsPageCacheMode,
2626
};
2727
use crate::query::{BindParam, ColumnValue};
2828
use crate::vfs::SqliteVfsMetrics;
@@ -55,6 +55,7 @@ fn vfs_config_wires_optimization_flags() {
5555
vfs_page_cache_mode: SqliteVfsPageCacheMode::Startup,
5656
vfs_page_cache_capacity_pages: DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES / 2,
5757
vfs_protected_cache_pages: DEFAULT_VFS_PROTECTED_CACHE_PAGES / 2,
58+
vfs_staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS / 2,
5859
};
5960

6061
let config = VfsConfig::from_optimization_flags(flags);
@@ -65,7 +66,11 @@ fn vfs_config_wires_optimization_flags() {
6566
);
6667
assert_eq!(
6768
config.protected_cache_pages,
68-
DEFAULT_VFS_PROTECTED_CACHE_PAGES / 2
69+
1
70+
);
71+
assert_eq!(
72+
config.staging_cache_ttl_ms,
73+
DEFAULT_VFS_STAGING_CACHE_TTL_MS / 2
6974
);
7075
assert_eq!(config.prefetch_depth, 16);
7176
assert!(!config.adaptive_read_ahead);
@@ -141,6 +146,46 @@ fn startup_initial_pages_do_not_require_preload_hints_on_open() {
141146
assert_eq!(loaded_pgnos, vec![1, 2, 3, 4]);
142147
}
143148

149+
#[test]
150+
fn vfs_staging_cache_retains_only_speculative_pages() {
151+
let config = VfsConfig {
152+
page_cache_mode: SqliteVfsPageCacheMode::All,
153+
staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS,
154+
..VfsConfig::default()
155+
};
156+
let mut state = VfsState::new(&config);
157+
158+
state.cache_page(&config, PageCacheInsertKind::Target, 2, vec![2; DEFAULT_PAGE_SIZE]);
159+
assert!(state.cached_page(&config, 2).is_none());
160+
161+
state.cache_page(&config, PageCacheInsertKind::Prefetch, 3, vec![3; DEFAULT_PAGE_SIZE]);
162+
state.cache_page(&config, PageCacheInsertKind::Startup, 4, vec![4; DEFAULT_PAGE_SIZE]);
163+
assert!(state.cached_page(&config, 3).is_some());
164+
assert!(state.cached_page(&config, 4).is_some());
165+
assert!(state.protected_page_cache.read_sync(&3, |_, _| ()).is_none());
166+
167+
state.evict_target_read_pages(&[1, 3, 4]);
168+
assert!(state.cached_page(&config, 1).is_some());
169+
assert!(state.cached_page(&config, 3).is_none());
170+
assert!(state.cached_page(&config, 4).is_none());
171+
}
172+
173+
#[test]
174+
fn vfs_staging_cache_ttl_zero_disables_speculative_retention() {
175+
let config = VfsConfig {
176+
page_cache_mode: SqliteVfsPageCacheMode::All,
177+
staging_cache_ttl_ms: 0,
178+
..VfsConfig::default()
179+
};
180+
let mut state = VfsState::new(&config);
181+
182+
state.cache_page(&config, PageCacheInsertKind::Prefetch, 2, vec![2; DEFAULT_PAGE_SIZE]);
183+
state.cache_page(&config, PageCacheInsertKind::Startup, 3, vec![3; DEFAULT_PAGE_SIZE]);
184+
assert!(state.cached_page(&config, 1).is_some());
185+
assert!(state.cached_page(&config, 2).is_none());
186+
assert!(state.cached_page(&config, 3).is_none());
187+
}
188+
144189
fn next_test_name(prefix: &str) -> String {
145190
let id = TEST_ID.fetch_add(1, Ordering::Relaxed);
146191
format!("{prefix}-{id}")

0 commit comments

Comments
 (0)