1use crate::integrity::validate_internal_records;
14use crate::schema;
15use crate::{
16 SqliteReplicationLog, SqliteSyncCheckpointStore, SqliteSyncConfig, SqliteSyncError,
17 SqliteSyncOutbox, SqliteSyncResult, SqliteSyncTombstoneStore,
18};
19use appcore_contracts::ProviderId;
20use appcore_storage::{
21 StorageCapabilityDescriptorV1, StorageCapabilityProviderV1, StorageCapabilityV1,
22};
23use parking_lot::{Condvar, Mutex};
24use rusqlite::limits::Limit;
25use rusqlite::{Connection, OpenFlags};
26use std::fmt;
27use std::fs;
28use std::path::Path;
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct SqliteSyncHealth {
35 pub schema_version: u32,
37 pub page_count: u64,
39 pub max_page_count: u64,
41}
42
43struct SqliteSyncInner {
44 pool: Mutex<ConnectionPool>,
45 available: Condvar,
46 config: SqliteSyncConfig,
47}
48
49struct ConnectionPool {
50 idle: Vec<Connection>,
51 total: usize,
52}
53
54struct ConnectionGuard<'a> {
55 inner: &'a SqliteSyncInner,
56 connection: Option<Connection>,
57}
58
59#[derive(Clone)]
61pub struct SqliteSyncStore {
62 inner: Arc<SqliteSyncInner>,
63}
64
65impl SqliteSyncStore {
66 pub fn open(mut config: SqliteSyncConfig) -> SqliteSyncResult<Self> {
68 config.validate()?;
69 config.path = normalize_path(&config.path)?;
70 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
71 | OpenFlags::SQLITE_OPEN_CREATE
72 | OpenFlags::SQLITE_OPEN_FULL_MUTEX;
73 let mut connection =
74 Connection::open_with_flags(&config.path, flags).map_err(SqliteSyncError::database)?;
75 schema::validate_identity(&connection)?;
76 configure(&connection, &config)?;
77 schema::migrate(&mut connection)?;
78 integrity_check(&connection, &config)?;
79 Ok(Self {
80 inner: Arc::new(SqliteSyncInner {
81 pool: Mutex::new(ConnectionPool {
82 idle: vec![connection],
83 total: 1,
84 }),
85 available: Condvar::new(),
86 config,
87 }),
88 })
89 }
90
91 pub fn health(&self) -> SqliteSyncResult<SqliteSyncHealth> {
93 self.with_connection(|connection| {
94 integrity_check(connection, &self.inner.config)?;
95 let schema_version = connection
96 .pragma_query_value(None, "user_version", |row| row.get(0))
97 .map_err(SqliteSyncError::database)?;
98 let page_count: i64 = connection
99 .pragma_query_value(None, "page_count", |row| row.get(0))
100 .map_err(SqliteSyncError::database)?;
101 let max_page_count: i64 = connection
102 .pragma_query_value(None, "max_page_count", |row| row.get(0))
103 .map_err(SqliteSyncError::database)?;
104 Ok(SqliteSyncHealth {
105 schema_version,
106 page_count: u64::try_from(page_count)
107 .map_err(|_| SqliteSyncError::CorruptRecord("page count"))?,
108 max_page_count: u64::try_from(max_page_count)
109 .map_err(|_| SqliteSyncError::CorruptRecord("page limit"))?,
110 })
111 })
112 }
113
114 pub fn config(&self) -> &SqliteSyncConfig {
116 &self.inner.config
117 }
118
119 pub fn replication_log(&self) -> SqliteReplicationLog {
121 SqliteReplicationLog::new(self.clone())
122 }
123
124 pub fn checkpoint_store(&self) -> SqliteSyncCheckpointStore {
126 SqliteSyncCheckpointStore::new(self.clone())
127 }
128
129 pub fn outbox(&self) -> SqliteSyncOutbox {
131 SqliteSyncOutbox::new(self.clone())
132 }
133
134 pub fn tombstone_store(&self) -> SqliteSyncTombstoneStore {
136 SqliteSyncTombstoneStore::new(self.clone())
137 }
138
139 pub(crate) fn with_connection<T>(
140 &self,
141 action: impl FnOnce(&mut Connection) -> SqliteSyncResult<T>,
142 ) -> SqliteSyncResult<T> {
143 let mut connection = self.acquire_connection()?;
144 action(connection.connection_mut()?)
145 }
146
147 fn acquire_connection(&self) -> SqliteSyncResult<ConnectionGuard<'_>> {
148 let deadline = Instant::now() + Duration::from_millis(self.inner.config.busy_timeout_ms);
149 let mut pool = self.inner.pool.lock();
150 loop {
151 if let Some(connection) = pool.idle.pop() {
152 return Ok(ConnectionGuard {
153 inner: &self.inner,
154 connection: Some(connection),
155 });
156 }
157 if pool.total < self.inner.config.max_connections {
158 pool.total += 1;
159 drop(pool);
160 return match open_connection(&self.inner.config) {
161 Ok(connection) => Ok(ConnectionGuard {
162 inner: &self.inner,
163 connection: Some(connection),
164 }),
165 Err(error) => {
166 let mut pool = self.inner.pool.lock();
167 pool.total = pool.total.saturating_sub(1);
168 self.inner.available.notify_one();
169 Err(error)
170 }
171 };
172 }
173 let now = Instant::now();
174 if now >= deadline {
175 return Err(SqliteSyncError::CapacityExceeded("connection"));
176 }
177 self.inner.available.wait_for(&mut pool, deadline - now);
178 }
179 }
180}
181
182impl ConnectionGuard<'_> {
183 fn connection_mut(&mut self) -> SqliteSyncResult<&mut Connection> {
184 self.connection
185 .as_mut()
186 .ok_or(SqliteSyncError::DatabaseOperation)
187 }
188}
189
190impl Drop for ConnectionGuard<'_> {
191 fn drop(&mut self) {
192 if let Some(connection) = self.connection.take() {
193 self.inner.pool.lock().idle.push(connection);
194 self.inner.available.notify_one();
195 }
196 }
197}
198
199impl fmt::Debug for SqliteSyncStore {
200 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201 formatter
202 .debug_struct("SqliteSyncStore")
203 .field("config", &self.inner.config)
204 .finish_non_exhaustive()
205 }
206}
207
208impl StorageCapabilityProviderV1 for SqliteSyncStore {
209 fn storage_capabilities_v1(
210 &self,
211 ) -> Result<StorageCapabilityDescriptorV1, appcore_storage::StorageCapabilityError> {
212 sqlite_sync_capability_descriptor_v1()
213 }
214}
215
216pub fn sqlite_sync_capability_descriptor_v1(
218) -> Result<StorageCapabilityDescriptorV1, appcore_storage::StorageCapabilityError> {
219 let provider_id = ProviderId::new("sqlite-sync")
220 .map_err(|_| appcore_storage::StorageCapabilityError::InvalidDescriptor)?;
221 Ok(StorageCapabilityDescriptorV1::new(
222 provider_id,
223 [
224 StorageCapabilityV1::Transactions,
225 StorageCapabilityV1::Locking,
226 StorageCapabilityV1::Snapshot,
227 StorageCapabilityV1::OnlineBackup,
228 StorageCapabilityV1::MultiProcess,
229 ],
230 ))
231}
232
233fn configure(connection: &Connection, config: &SqliteSyncConfig) -> SqliteSyncResult<()> {
234 configure_memory(connection)?;
235 connection
236 .busy_timeout(Duration::from_millis(config.busy_timeout_ms))
237 .map_err(SqliteSyncError::database)?;
238 connection
239 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0))
240 .map_err(SqliteSyncError::database)
241 .and_then(|mode| {
242 if mode.eq_ignore_ascii_case("wal") {
243 Ok(())
244 } else {
245 Err(SqliteSyncError::DatabaseOperation)
246 }
247 })?;
248 connection
249 .pragma_update(None, "synchronous", "FULL")
250 .and_then(|_| connection.pragma_update(None, "foreign_keys", true))
251 .and_then(|_| connection.pragma_update(None, "trusted_schema", false))
252 .and_then(|_| connection.pragma_update(None, "wal_autocheckpoint", 1_000))
253 .map_err(SqliteSyncError::database)?;
254 let max_length = i32::try_from(config.max_outbox_record_bytes)
255 .map_err(|_| SqliteSyncError::InvalidConfiguration("outbox record bound overflow"))?;
256 connection
257 .set_limit(Limit::SQLITE_LIMIT_LENGTH, max_length)
258 .and_then(|_| connection.set_limit(Limit::SQLITE_LIMIT_ATTACHED, 0))
259 .and_then(|_| connection.set_limit(Limit::SQLITE_LIMIT_WORKER_THREADS, 0))
260 .map_err(SqliteSyncError::database)?;
261 let page_bytes: i64 = connection
262 .pragma_query_value(None, "page_size", |row| row.get(0))
263 .map_err(SqliteSyncError::database)?;
264 let page_bytes =
265 u64::try_from(page_bytes).map_err(|_| SqliteSyncError::CorruptRecord("page size"))?;
266 if page_bytes == 0 {
267 return Err(SqliteSyncError::CorruptRecord("page size"));
268 }
269 let max_pages = i64::try_from(config.max_database_bytes / page_bytes)
270 .map_err(|_| SqliteSyncError::InvalidConfiguration("database page bound overflow"))?;
271 connection
272 .pragma_update(None, "max_page_count", max_pages)
273 .map_err(SqliteSyncError::database)
274}
275
276fn open_connection(config: &SqliteSyncConfig) -> SqliteSyncResult<Connection> {
277 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
278 | OpenFlags::SQLITE_OPEN_CREATE
279 | OpenFlags::SQLITE_OPEN_FULL_MUTEX;
280 let connection =
281 Connection::open_with_flags(&config.path, flags).map_err(SqliteSyncError::database)?;
282 configure(&connection, config)?;
283 Ok(connection)
284}
285
286pub(crate) fn configure_memory(connection: &Connection) -> SqliteSyncResult<()> {
288 for (pragma, expected) in [
293 ("cache_size", -2_048_i64),
294 ("mmap_size", 0),
295 ("temp_store", 1),
296 ] {
297 connection
298 .pragma_update(None, pragma, expected)
299 .map_err(SqliteSyncError::database)?;
300 let actual: i64 = connection
301 .pragma_query_value(None, pragma, |row| row.get(0))
302 .map_err(SqliteSyncError::database)?;
303 if actual != expected {
304 return Err(SqliteSyncError::DatabaseOperation);
305 }
306 }
307 Ok(())
308}
309
310fn integrity_check(connection: &Connection, config: &SqliteSyncConfig) -> SqliteSyncResult<()> {
311 let result: String = connection
312 .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
313 .map_err(SqliteSyncError::database)?;
314 if result != "ok" {
315 return Err(SqliteSyncError::IntegrityFailed);
316 }
317 validate_internal_records(connection, config)
318}
319
320pub(crate) fn normalize_path(path: &Path) -> SqliteSyncResult<std::path::PathBuf> {
321 let file_name = path.file_name().ok_or(SqliteSyncError::UnsafePath)?;
322 match fs::symlink_metadata(path) {
323 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
324 return Err(SqliteSyncError::UnsafePath);
325 }
326 Ok(_) => {}
327 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
328 Err(_) => return Err(SqliteSyncError::UnsafePath),
329 }
330 let parent = path.parent().unwrap_or_else(|| Path::new("."));
331 fs::create_dir_all(parent).map_err(|_| SqliteSyncError::UnsafePath)?;
332 let canonical_parent = fs::canonicalize(parent).map_err(|_| SqliteSyncError::UnsafePath)?;
333 if !canonical_parent.is_dir() {
334 return Err(SqliteSyncError::UnsafePath);
335 }
336 Ok(canonical_parent.join(file_name))
337}
338
339#[cfg(test)]
340mod memory_tests {
341 use super::*;
342
343 #[test]
344 fn every_pooled_connection_and_reopen_apply_memory_policy() {
345 let directory = tempfile::tempdir().unwrap();
346 let config =
347 SqliteSyncConfig::new(directory.path().join("state.db")).with_max_connections(2);
348 for _ in 0..2 {
349 let store = SqliteSyncStore::open(config.clone()).unwrap();
350 let mut first = store.acquire_connection().unwrap();
351 let mut second = store.acquire_connection().unwrap();
352 for guard in [&mut first, &mut second] {
353 let connection = guard.connection_mut().unwrap();
354 let cache: i64 = connection
355 .pragma_query_value(None, "cache_size", |row| row.get(0))
356 .unwrap();
357 let mmap: i64 = connection
358 .pragma_query_value(None, "mmap_size", |row| row.get(0))
359 .unwrap();
360 assert_eq!(cache, -2_048);
361 assert_eq!(mmap, 0);
362 let temp: i64 = connection
363 .pragma_query_value(None, "temp_store", |row| row.get(0))
364 .unwrap();
365 assert_eq!(temp, 1);
366 }
367 }
368 }
369
370 #[test]
371 fn held_reader_allows_wal_growth_beyond_autocheckpoint_until_release() {
372 use appcore_sync::ReplicationLog;
373 let directory = tempfile::tempdir().unwrap();
374 let config =
375 SqliteSyncConfig::new(directory.path().join("state.db")).with_max_connections(2);
376 let store = SqliteSyncStore::open(config.clone()).unwrap();
377 let mut log = store.replication_log();
378 log.append(vec![0]).unwrap();
379 let mut held = store.acquire_connection().unwrap();
380 let reader = held.connection_mut().unwrap();
381 reader.execute_batch("BEGIN DEFERRED").unwrap();
382 let count: i64 = reader
383 .query_row("SELECT COUNT(*) FROM appcore_replication_log", [], |row| {
384 row.get(0)
385 })
386 .unwrap();
387 assert_eq!(count, 1);
388 for sequence in 1..=8 {
389 log.append_with_sequence(vec![sequence as u8; 1024 * 1024], sequence)
390 .unwrap();
391 }
392 let (frames, checkpointed) = store
393 .with_connection(|writer| {
394 let threshold: i64 = writer
395 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
396 .map_err(SqliteSyncError::database)?;
397 assert_eq!(threshold, 1_000);
398 writer
399 .query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| {
400 Ok((row.get::<_, i64>(1)?, row.get::<_, i64>(2)?))
401 })
402 .map_err(SqliteSyncError::database)
403 })
404 .unwrap();
405 assert!(frames > 1_000);
406 assert!(checkpointed < frames);
407 let count: i64 = reader
408 .query_row("SELECT COUNT(*) FROM appcore_replication_log", [], |row| {
409 row.get(0)
410 })
411 .unwrap();
412 assert_eq!(count, 1);
413 reader.execute_batch("ROLLBACK").unwrap();
414 drop(held);
415 store
416 .with_connection(|connection| {
417 let result: (i64, i64, i64) = connection
418 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
419 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
420 })
421 .map_err(SqliteSyncError::database)?;
422 assert_eq!(result, (0, 0, 0));
423 Ok(())
424 })
425 .unwrap();
426 assert_eq!(log.len().unwrap(), 9);
427 drop(log);
428 drop(store);
429 let reopened = SqliteSyncStore::open(config).unwrap();
430 assert_eq!(reopened.replication_log().len().unwrap(), 9);
431 }
432}