1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
//! Track C — C8c-2: a **coherent** read cache for a read-only connection.
//!
//! `WritePager`'s clean read cache used to be trusted only inside a *write*
//! transaction (under a `Reserved`+ lock, where this connection is the only
//! writer). A pure read-only connection over a read-write file holds no such
//! lock, so every page went straight to disk on every read — correct, but it
//! never reused a page.
//!
//! C8c-2 adds a coherent read cache for that path. Cached clean pages are keyed
//! by the database **change counter** (page 1, bytes 24–27), which SQLite bumps
//! on every commit. At each statement boundary the reader re-reads that counter
//! from disk (`revalidate_read_cache` / the `Connection`'s per-statement hook):
//! if it is unchanged the cache is reused; if a foreign in-process `Connection`
//! committed and bumped it, the cache is dropped so the next statement re-reads
//! fresh pages. This gives two guarantees, both asserted here:
//!
//! * **Coherency (correctness):** after another `Connection` commits, the
//! read-only side sees the NEW data on its very next statement — never a
//! stale cached page.
//! * **Cache actually used:** repeated reads of the same page *within* one
//! validated window are served from cache (a hit), not re-read from disk.
//!
//! Two `WritePager`s / `Connection`s over one shared `MemoryVfs` path model two
//! connections in one process (graphite locking is process-local), the same
//! pattern as `tests/pager_read_locks.rs` and `tests/c9a_connection_locks.rs`.
#![cfg(feature = "std")]
use graphitesql::pager::WritePager;
use graphitesql::vfs::memory::MemoryVfs;
use graphitesql::vfs::{File, OpenFlags, Vfs};
use graphitesql::{Connection, Value};
use std::boxed::Box;
/// A fresh `File` handle over the shared per-path `LockState` and byte store.
fn open_rw(vfs: &MemoryVfs, path: &str) -> Box<dyn File> {
vfs.open(path, OpenFlags::READ_WRITE).unwrap()
}
/// A brand-new 2-page database with a marker byte on page 2, committed.
fn seed_db(vfs: &MemoryVfs, path: &str) {
vfs.open(path, OpenFlags::READ_WRITE_CREATE).unwrap();
let mut wp = WritePager::create(open_rw(vfs, path), None, 4096).unwrap();
// Allocate a second page and stamp a marker so a reader has a non-page-1
// page to read repeatedly.
let pno = wp.allocate_page().unwrap();
assert_eq!(pno, 2, "expected the second page to be page 2");
let mut page2 = vec![0u8; 4096];
page2[0] = 0x11;
wp.write_page(2, page2).unwrap();
wp.commit().unwrap();
}
/// A read-only pager reuses a cached clean page **within a validated window**
/// instead of hitting disk again: the second read of the same page is a cache
/// hit, not a miss.
#[test]
fn read_only_pager_reuses_cached_page_within_a_statement() {
let vfs = MemoryVfs::new();
seed_db(&vfs, "db");
// A pure read-only pager: it never takes a write lock.
let ro = WritePager::open(vfs.open("db", OpenFlags::READ_ONLY).unwrap(), None).unwrap();
// Statement boundary: stamp the change-counter token so the cache is trusted.
ro.revalidate_read_cache();
let (h0, m0) = ro.read_cache_stats();
// First read: a miss (page read from disk and inserted into the cache).
assert_eq!(ro.read_page(2).unwrap()[0], 0x11);
// Second and third reads of the same page: served from cache (hits).
assert_eq!(ro.read_page(2).unwrap()[0], 0x11);
assert_eq!(ro.read_page(2).unwrap()[0], 0x11);
let (h1, m1) = ro.read_cache_stats();
assert_eq!(
m1 - m0,
1,
"exactly one disk miss for three reads of page 2"
);
assert_eq!(h1 - h0, 2, "the two repeat reads were cache hits");
assert!(ro.resident_clean_pages() >= 1, "page 2 is resident");
}
/// Coherency: after a foreign writer commits, the read-only pager sees the NEW
/// page bytes on its next validated statement — the stale cache is dropped.
#[test]
fn read_only_pager_sees_foreign_commit_after_revalidation() {
let vfs = MemoryVfs::new();
seed_db(&vfs, "db");
let ro = WritePager::open(vfs.open("db", OpenFlags::READ_ONLY).unwrap(), None).unwrap();
let mut w = WritePager::open(open_rw(&vfs, "db"), None).unwrap();
// Statement 1: read page 2, warming the cache with the original marker.
ro.revalidate_read_cache();
assert_eq!(ro.read_page(2).unwrap()[0], 0x11);
let cached = ro.read_page(2).unwrap()[0]; // cache hit, still 0x11
assert_eq!(cached, 0x11);
// The writer overwrites page 2's marker and commits (bumps the change
// counter on page 1).
let mut p2 = w.read_page(2).unwrap();
p2[0] = 0x22;
w.write_page(2, p2).unwrap();
w.commit().unwrap();
// WITHOUT revalidation the reader would still be holding the stale 0x11 in its
// cache. The statement-boundary hook re-reads the change counter, sees it moved,
// and drops the cache — so the next read reflects the committed 0x22.
ro.revalidate_read_cache();
assert_eq!(
ro.read_page(2).unwrap()[0],
0x22,
"read-only pager sees the foreign commit after revalidation (no stale cache)"
);
}
/// End-to-end at the `Connection` layer: a read-only connection returns correct
/// rows, and after another `Connection` commits a change it sees the NEW data on
/// its next statement — driven entirely by real SQL through the per-statement
/// revalidation hook, no direct pager calls.
#[test]
fn read_only_connection_is_coherent_with_a_foreign_writer() {
let vfs = MemoryVfs::new();
{
let mut c = Connection::create_vfs(&vfs, "db", 4096).unwrap();
c.execute("CREATE TABLE t(x)").unwrap();
c.execute("INSERT INTO t VALUES (1)").unwrap();
}
let ro = Connection::open_readonly_vfs(&vfs, "db").unwrap();
let mut w = Connection::open_vfs(&vfs, "db").unwrap();
// The read-only connection reads the committed row.
let before = ro.query("SELECT x FROM t").unwrap();
assert_eq!(before.rows, vec![vec![Value::Integer(1)]]);
// A foreign writer updates the row in place and commits.
w.execute("UPDATE t SET x = 99 WHERE x = 1").unwrap();
// The read-only connection's NEXT statement sees the new value — the change
// counter moved, so its per-statement hook dropped the stale cache.
let after = ro.query("SELECT x FROM t").unwrap();
assert_eq!(
after.rows,
vec![vec![Value::Integer(99)]],
"read-only connection sees the foreign commit, not a stale cached page"
);
// A second read with no intervening write returns the same value (and, under
// the hood, reuses the now-validated cache).
let again = ro.query("SELECT x FROM t").unwrap();
assert_eq!(again.rows, vec![vec![Value::Integer(99)]]);
}
/// Guard: repeatedly reading many distinct pages on the read-only path stays
/// bounded (the LRU eviction still applies) and correct.
#[test]
fn read_only_cache_stays_bounded() {
let vfs = MemoryVfs::new();
seed_db(&vfs, "db");
let ro = WritePager::open(vfs.open("db", OpenFlags::READ_ONLY).unwrap(), None).unwrap();
ro.set_cache_size(4); // tiny cache: 4 pages max
ro.revalidate_read_cache();
// Read the two pages that exist many times; resident set never exceeds cap.
for _ in 0..50 {
assert_eq!(ro.read_page(1).unwrap().len(), 4096);
assert_eq!(ro.read_page(2).unwrap()[0], 0x11);
assert!(
ro.resident_clean_pages() <= 4,
"resident set within capacity"
);
}
}