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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
//! Track C — C9a: reader `SHARED`-lock sharing.
//!
//! SQLite's locking model lets *many* readers hold `SHARED` at once while a
//! writer is excluded for as long as any reader holds it. graphitesql models
//! this process-locally in [`graphitesql::vfs::LockState`], shared per path by
//! every open handle within the process (the `StdVfs` per-path registry and the
//! `MemoryVfs` per-file `Rc<RefCell<LockState>>`).
//!
//! These tests drive the public VFS API the way a pager would — multiple
//! `File` handles over the *same* path — and assert the four C9a properties:
//! (a) two (or more) readers both hold `SHARED` simultaneously and read
//! correctly through their independent handles;
//! (b) a writer attempting to upgrade to `EXCLUSIVE` while a reader still
//! holds `SHARED` is rejected `Busy`;
//! (c) once the reader drains, the writer's upgrade succeeds;
//! (d) `RESERVED` can be taken while a reader holds `SHARED` (a writer
//! announces intent while readers continue).
//!
//! The same `LockState` backs both VFSs, so the std-file path and the in-memory
//! path are exercised identically through their respective handle types.
#![cfg(feature = "std")]
use graphitesql::vfs::memory::MemoryVfs;
use graphitesql::vfs::std_file::StdVfs;
use graphitesql::vfs::{File, LockLevel, OpenFlags, Vfs};
use graphitesql::{Connection, Error, Value};
/// Per-PID scratch dir so parallel test binaries never collide or delete each
/// other's files.
fn temp_path(name: &str) -> String {
let mut p = std::env::temp_dir();
p.push(format!("gsql-c9a-{}", std::process::id()));
std::fs::create_dir_all(&p).expect("create scratch dir");
p.push(name);
p.to_string_lossy().into_owned()
}
/// Drive the four C9a properties over three independent handles to one path.
/// `open` yields a fresh `File` over the shared (per-path) `LockState`.
fn assert_reader_sharing(mut open: impl FnMut() -> Box<dyn File>) {
// Seed some bytes so the readers have something to read back.
{
let mut w = open();
w.lock(LockLevel::Shared).unwrap();
w.lock(LockLevel::Reserved).unwrap();
w.lock(LockLevel::Exclusive).unwrap();
w.write_all_at(b"graphite", 0).unwrap();
w.unlock(LockLevel::Unlocked).unwrap();
}
let mut r1 = open();
let mut r2 = open();
let mut writer = open();
// (a) Two readers both take SHARED at the same time and read correctly.
r1.lock(LockLevel::Shared).unwrap();
r2.lock(LockLevel::Shared).unwrap();
let mut b1 = [0u8; 8];
let mut b2 = [0u8; 8];
r1.read_exact_at(&mut b1, 0).unwrap();
r2.read_exact_at(&mut b2, 0).unwrap();
assert_eq!(&b1, b"graphite");
assert_eq!(&b2, b"graphite");
// (d) A writer announces intent with RESERVED while both readers still hold
// SHARED — readers are not disturbed.
writer.lock(LockLevel::Shared).unwrap();
writer.lock(LockLevel::Reserved).unwrap();
// Readers can still read after RESERVED is taken.
r1.read_exact_at(&mut b1, 0).unwrap();
assert_eq!(&b1, b"graphite");
// (b) The writer cannot go EXCLUSIVE while *other* readers hold SHARED.
assert!(
matches!(writer.lock(LockLevel::Exclusive), Err(Error::Busy)),
"EXCLUSIVE must be refused while readers hold SHARED",
);
// Drop one reader — the other still holds SHARED, so EXCLUSIVE is still out.
r1.unlock(LockLevel::Unlocked).unwrap();
assert!(
matches!(writer.lock(LockLevel::Exclusive), Err(Error::Busy)),
"EXCLUSIVE must stay refused while any other reader holds SHARED",
);
// (c) Once the last foreign reader drains, the writer's upgrade succeeds.
r2.unlock(LockLevel::Unlocked).unwrap();
writer.lock(LockLevel::Exclusive).unwrap();
writer.write_all_at(b"SQLITE!!", 0).unwrap();
writer.unlock(LockLevel::Unlocked).unwrap();
// The exclusive write landed and is visible to a fresh reader.
let mut fresh = open();
fresh.lock(LockLevel::Shared).unwrap();
let mut b3 = [0u8; 8];
fresh.read_exact_at(&mut b3, 0).unwrap();
assert_eq!(&b3, b"SQLITE!!");
}
#[test]
fn memory_vfs_readers_share_shared_lock() {
let vfs = MemoryVfs::new();
// Pre-create the file so READ_WRITE (no create) handles can open it.
vfs.open("db", OpenFlags::READ_WRITE_CREATE).unwrap();
assert_reader_sharing(|| vfs.open("db", OpenFlags::READ_WRITE).unwrap());
}
#[test]
fn std_vfs_readers_share_shared_lock() {
let vfs = StdVfs::new();
let path = temp_path("readers.db");
let _ = vfs.delete(&path);
// Create the backing file once; subsequent handles open it READ_WRITE.
vfs.open(&path, OpenFlags::READ_WRITE_CREATE).unwrap();
assert_reader_sharing(|| vfs.open(&path, OpenFlags::READ_WRITE).unwrap());
let _ = vfs.delete(&path);
}
/// More than two readers coexist: SHARED is a counted lock, not a single owner.
#[test]
fn many_readers_coexist_and_block_exclusive() {
let vfs = MemoryVfs::new();
vfs.open("db", OpenFlags::READ_WRITE_CREATE).unwrap();
let mut readers: Vec<Box<dyn File>> = (0..5)
.map(|_| vfs.open("db", OpenFlags::READ_WRITE).unwrap())
.collect();
for r in &mut readers {
r.lock(LockLevel::Shared).unwrap();
}
// A would-be writer can take SHARED+RESERVED alongside the five readers, but
// EXCLUSIVE is refused until every one of them drains.
let mut writer = vfs.open("db", OpenFlags::READ_WRITE).unwrap();
writer.lock(LockLevel::Shared).unwrap();
writer.lock(LockLevel::Reserved).unwrap();
for r in readers.iter_mut() {
assert!(
matches!(writer.lock(LockLevel::Exclusive), Err(Error::Busy)),
"EXCLUSIVE must wait for all readers to drain",
);
r.unlock(LockLevel::Unlocked).unwrap();
}
// All readers gone (only the writer's own SHARED remains) → upgrade wins.
writer.lock(LockLevel::Exclusive).unwrap();
}
/// A second writer-intent (`RESERVED`) is refused while one is held, even though
/// readers may still come and go — mirrors SQLite's single write-intent rule.
#[test]
fn reserved_is_exclusive_among_writers_but_readers_continue() {
let vfs = MemoryVfs::new();
vfs.open("db", OpenFlags::READ_WRITE_CREATE).unwrap();
let mut a = vfs.open("db", OpenFlags::READ_WRITE).unwrap();
let mut b = vfs.open("db", OpenFlags::READ_WRITE).unwrap();
let mut reader = vfs.open("db", OpenFlags::READ_WRITE).unwrap();
a.lock(LockLevel::Shared).unwrap();
a.lock(LockLevel::Reserved).unwrap();
// A brand-new reader is still admitted while RESERVED is held.
reader.lock(LockLevel::Shared).unwrap();
// But a second RESERVED is refused.
b.lock(LockLevel::Shared).unwrap();
assert!(
matches!(b.lock(LockLevel::Reserved), Err(Error::Busy)),
"only one RESERVED at a time",
);
// After A releases, B may take RESERVED.
a.unlock(LockLevel::Unlocked).unwrap();
b.lock(LockLevel::Reserved).unwrap();
}
// ---------------------------------------------------------------------------
// End-to-end through the public `Connection` API.
//
// graphitesql's pager only grabs `SHARED` transiently on the path to the write
// lock; pure reads are served without holding a persistent read lock (a foreign
// writer is excluded by the `RESERVED`/`EXCLUSIVE` handshake instead). So at the
// `Connection` layer the observable C9a guarantee is: *concurrent readers never
// block each other or a single writer's reads*, while writers still serialize.
// These tests pin that behaviour down so a future change to the read-lock policy
// can't silently start BUSY-ing concurrent readers.
// ---------------------------------------------------------------------------
/// Two connections, both with an open transaction over the same file, read the
/// same committed rows correctly and concurrently — neither blocks the other.
#[test]
fn two_connections_read_concurrently_without_blocking() {
let vfs = MemoryVfs::new();
let mut a = Connection::create_vfs(&vfs, "db", 4096).unwrap();
a.execute("CREATE TABLE t(x)").unwrap();
a.execute("INSERT INTO t VALUES (1), (2), (3)").unwrap();
let mut b = Connection::open_vfs(&vfs, "db").unwrap();
// Both open read transactions and interleave their reads.
a.execute("BEGIN").unwrap();
b.execute("BEGIN").unwrap();
let qa = a.query("SELECT x FROM t ORDER BY x").unwrap().rows;
let qb = b.query("SELECT x FROM t ORDER BY x").unwrap().rows;
let expect = vec![
vec![Value::Integer(1)],
vec![Value::Integer(2)],
vec![Value::Integer(3)],
];
assert_eq!(qa, expect, "reader A sees all committed rows");
assert_eq!(qb, expect, "reader B sees all committed rows concurrently");
// A second read on each connection, still inside the open txns, still works.
assert_eq!(
a.query("SELECT count(*) FROM t").unwrap().rows[0][0],
Value::Integer(3)
);
assert_eq!(
b.query("SELECT count(*) FROM t").unwrap().rows[0][0],
Value::Integer(3)
);
a.execute("COMMIT").unwrap();
b.execute("COMMIT").unwrap();
}
/// A reader keeps reading while another connection holds the write-intent
/// (`RESERVED`) lock — readers are not excluded by `RESERVED`, only a second
/// *writer* is. (The writer-vs-writer BUSY is covered by `concurrency.rs`.)
#[test]
fn reader_unaffected_by_a_concurrent_writers_reserved_lock() {
let vfs = MemoryVfs::new();
let mut a = Connection::create_vfs(&vfs, "db", 4096).unwrap();
a.execute("CREATE TABLE t(x)").unwrap();
a.execute("INSERT INTO t VALUES (10)").unwrap();
let reader = Connection::open_vfs(&vfs, "db").unwrap();
// A enters a write transaction → holds RESERVED.
a.execute("BEGIN").unwrap();
a.execute("INSERT INTO t VALUES (20)").unwrap();
// The reader, on its own connection, still reads the committed snapshot
// (the uncommitted row 20 is not yet flushed) without any BUSY.
let rows = reader.query("SELECT x FROM t ORDER BY x").unwrap().rows;
assert_eq!(rows, vec![vec![Value::Integer(10)]]);
// After A commits and releases, the reader sees both rows.
a.execute("COMMIT").unwrap();
let rows = reader.query("SELECT x FROM t ORDER BY x").unwrap().rows;
assert_eq!(
rows,
vec![vec![Value::Integer(10)], vec![Value::Integer(20)]]
);
}