Skip to content

Commit 532acf8

Browse files
committed
feat(depot-client): add vfs staging cache ttl
1 parent 8702157 commit 532acf8

6 files changed

Lines changed: 295 additions & 43 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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.
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+
- Treat page 1 as staging data after `xRead` while keeping parsed page-size and database-size metadata.
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 speculative read pages from the 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+
- Commit completion should stage dirty pages in a separate TTL cache so SQLite can reread its own writes without retaining them permanently.
37+
- 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.
38+
- Protected cache should no longer protect speculative pages forever. It should be removed or left unused in favor of the TTL cache.
39+
40+
## Configuration
41+
42+
- Add `RIVETKIT_SQLITE_OPT_VFS_STAGING_CACHE_TTL_MS`.
43+
- Default to a short TTL such as `30000` ms.
44+
- A value of `0` disables speculative retention while preserving lazy target fetches.
45+
- Keep `RIVETKIT_SQLITE_OPT_VFS_PAGE_CACHE_MODE=off` as the stronger kill switch for all non-page-1 VFS caching.
46+
- Do not use `RIVETKIT_SQLITE_OPT_VFS_PROTECTED_CACHE_PAGES` to pin VFS page bytes beyond `xRead`.
47+
48+
## Implementation Plan
49+
50+
1. Extend `SqliteOptimizationFlags` and `VfsConfig` with a bounded staging TTL field.
51+
2. Build `page_cache` with `time_to_live(Duration::from_millis(ttl_ms))` when TTL is nonzero.
52+
3. Split cache insertion semantics so `PageCacheInsertKind::Target` is not retained by default.
53+
4. Add an explicit `evict_pages_after_target_read` helper that removes every consumed page from both normal and protected speculative caches.
54+
5. Call that helper after `io_read` copies returned bytes into SQLite's buffer.
55+
6. Evict dirty page numbers from the staging cache after commit completion.
56+
7. Rework `protected_page_cache` so it cannot pin speculative pages forever.
57+
8. Keep `seed_main_page` behavior intact for parsed page 1 metadata.
58+
9. Update metrics naming only if needed. `page_cache_entries` can continue to report retained VFS entries.
59+
60+
## Expected Cache Matrix
61+
62+
| Page source | Retained after fetch | Evicted on target read | TTL fallback |
63+
| --- | --- | --- | --- |
64+
| Target `xRead` miss | No | Not needed | No |
65+
| Read-ahead prefetch | Yes | Yes | Yes |
66+
| Startup preload | Yes | Yes | Yes |
67+
| Page 1 | Yes during bootstrap or preload | Yes | Yes when retained |
68+
| Dirty write buffer | Existing behavior | Existing behavior | No |
69+
70+
## Tests
71+
72+
- Add a VFS test proving a target read miss does not increase retained VFS cache entries.
73+
- Add a VFS test proving prefetch pages are retained before use and removed after target read.
74+
- Add a VFS test proving startup preload pages are retained briefly and removed after target read.
75+
- Add a VFS test proving `VFS_STAGING_CACHE_TTL_MS=0` still lazily fetches pages.
76+
- Add a VFS test proving `VFS_PAGE_CACHE_MODE=off` still lazily fetches pages and does not retain non-page-1 pages.
77+
- If practical, use Tokio time pause/advance to verify TTL expiry deterministically instead of sleeping.
78+
79+
## Open Questions
80+
81+
- Should target retention remain available as an explicit benchmark mode, or should we remove target caching from the shipped matrix?
82+
- Should `VFS_PROTECTED_CACHE_PAGES` be deprecated now that VFS pages are staging-only?

docs-internal/engine/SQLITE_OPTIMIZATIONS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ 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 startup preload and read-ahead pages. Direct target pages fetched for `xRead` are not retained in VFS memory.
15+
- 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`.
16+
- 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.
17+
- 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`.
1518
- 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.
1619
- 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.
1720
- 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
}

0 commit comments

Comments
 (0)