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 if let Some(queue_path) = config.separate_outbox_path.as_deref() {
71 let queue_path = normalize_path(queue_path)?;
72 if queue_path == config.path || queue_path.parent() != config.path.parent() {
73 return Err(SqliteSyncError::UnsafePath);
74 }
75 config.separate_outbox_path = Some(queue_path);
76 }
77 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
78 | OpenFlags::SQLITE_OPEN_CREATE
79 | OpenFlags::SQLITE_OPEN_FULL_MUTEX;
80 let mut connection =
81 Connection::open_with_flags(&config.path, flags).map_err(SqliteSyncError::database)?;
82 crate::separate_outbox::attach(&connection, &config)?;
83 schema::validate_identity(&connection)?;
84 configure(&connection, &config)?;
85 crate::separate_outbox::initialize(&mut connection, &config)?;
86 schema::migrate(&mut connection, &config)?;
87 crate::separate_outbox::relocate_existing_outbox(&mut connection, &config)?;
88 integrity_check(&connection, &config)?;
89 Ok(Self {
90 inner: Arc::new(SqliteSyncInner {
91 pool: Mutex::new(ConnectionPool {
92 idle: vec![connection],
93 total: 1,
94 }),
95 available: Condvar::new(),
96 config,
97 }),
98 })
99 }
100
101 pub fn health(&self) -> SqliteSyncResult<SqliteSyncHealth> {
103 self.with_connection(|connection| {
104 integrity_check(connection, &self.inner.config)?;
105 let schema_version = connection
106 .pragma_query_value(None, "user_version", |row| row.get(0))
107 .map_err(SqliteSyncError::database)?;
108 let page_count: i64 = connection
109 .pragma_query_value(None, "page_count", |row| row.get(0))
110 .map_err(SqliteSyncError::database)?;
111 let max_page_count: i64 = connection
112 .pragma_query_value(None, "max_page_count", |row| row.get(0))
113 .map_err(SqliteSyncError::database)?;
114 Ok(SqliteSyncHealth {
115 schema_version,
116 page_count: u64::try_from(page_count)
117 .map_err(|_| SqliteSyncError::CorruptRecord("page count"))?,
118 max_page_count: u64::try_from(max_page_count)
119 .map_err(|_| SqliteSyncError::CorruptRecord("page limit"))?,
120 })
121 })
122 }
123
124 pub fn config(&self) -> &SqliteSyncConfig {
126 &self.inner.config
127 }
128
129 pub fn replication_log(&self) -> SqliteReplicationLog {
131 SqliteReplicationLog::new(self.clone())
132 }
133
134 pub fn checkpoint_store(&self) -> SqliteSyncCheckpointStore {
136 SqliteSyncCheckpointStore::new(self.clone())
137 }
138
139 pub fn outbox(&self) -> SqliteSyncOutbox {
141 SqliteSyncOutbox::new(self.clone())
142 }
143
144 pub fn tombstone_store(&self) -> SqliteSyncTombstoneStore {
146 SqliteSyncTombstoneStore::new(self.clone())
147 }
148
149 pub(crate) fn with_connection<T>(
150 &self,
151 action: impl FnOnce(&mut Connection) -> SqliteSyncResult<T>,
152 ) -> SqliteSyncResult<T> {
153 let mut connection = self.acquire_connection()?;
154 action(connection.connection_mut()?)
155 }
156
157 fn acquire_connection(&self) -> SqliteSyncResult<ConnectionGuard<'_>> {
158 let deadline = Instant::now() + Duration::from_millis(self.inner.config.busy_timeout_ms);
159 let mut pool = self.inner.pool.lock();
160 loop {
161 if let Some(connection) = pool.idle.pop() {
162 return Ok(ConnectionGuard {
163 inner: &self.inner,
164 connection: Some(connection),
165 });
166 }
167 if pool.total < self.inner.config.max_connections {
168 pool.total += 1;
169 drop(pool);
170 return match open_connection(&self.inner.config) {
171 Ok(connection) => Ok(ConnectionGuard {
172 inner: &self.inner,
173 connection: Some(connection),
174 }),
175 Err(error) => {
176 let mut pool = self.inner.pool.lock();
177 pool.total = pool.total.saturating_sub(1);
178 self.inner.available.notify_one();
179 Err(error)
180 }
181 };
182 }
183 let now = Instant::now();
184 if now >= deadline {
185 return Err(SqliteSyncError::CapacityExceeded("connection"));
186 }
187 self.inner.available.wait_for(&mut pool, deadline - now);
188 }
189 }
190}
191
192impl ConnectionGuard<'_> {
193 fn connection_mut(&mut self) -> SqliteSyncResult<&mut Connection> {
194 self.connection
195 .as_mut()
196 .ok_or(SqliteSyncError::DatabaseOperation)
197 }
198}
199
200impl Drop for ConnectionGuard<'_> {
201 fn drop(&mut self) {
202 if let Some(connection) = self.connection.take() {
203 self.inner.pool.lock().idle.push(connection);
204 self.inner.available.notify_one();
205 }
206 }
207}
208
209impl fmt::Debug for SqliteSyncStore {
210 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
211 formatter
212 .debug_struct("SqliteSyncStore")
213 .field("config", &self.inner.config)
214 .finish_non_exhaustive()
215 }
216}
217
218impl StorageCapabilityProviderV1 for SqliteSyncStore {
219 fn storage_capabilities_v1(
220 &self,
221 ) -> Result<StorageCapabilityDescriptorV1, appcore_storage::StorageCapabilityError> {
222 sqlite_sync_capability_descriptor_v1()
223 }
224}
225
226pub fn sqlite_sync_capability_descriptor_v1(
228) -> Result<StorageCapabilityDescriptorV1, appcore_storage::StorageCapabilityError> {
229 let provider_id = ProviderId::new("sqlite-sync")
230 .map_err(|_| appcore_storage::StorageCapabilityError::InvalidDescriptor)?;
231 Ok(StorageCapabilityDescriptorV1::new(
232 provider_id,
233 [
234 StorageCapabilityV1::Transactions,
235 StorageCapabilityV1::Locking,
236 StorageCapabilityV1::Snapshot,
237 StorageCapabilityV1::OnlineBackup,
238 StorageCapabilityV1::MultiProcess,
239 ],
240 ))
241}
242
243fn configure(connection: &Connection, config: &SqliteSyncConfig) -> SqliteSyncResult<()> {
244 configure_memory(connection)?;
245 if config.separate_outbox_path.is_some() {
246 configure_attached_memory(connection, "sync_queue")?;
247 }
248 connection
249 .busy_timeout(Duration::from_millis(config.busy_timeout_ms))
250 .map_err(SqliteSyncError::database)?;
251 let main_journal_mode = if config.separate_outbox_path.is_some() {
252 "DELETE"
253 } else {
254 "WAL"
255 };
256 connection
257 .pragma_update_and_check(None, "journal_mode", main_journal_mode, |row| {
258 row.get::<_, String>(0)
259 })
260 .map_err(SqliteSyncError::database)
261 .and_then(|mode| {
262 if mode.eq_ignore_ascii_case(main_journal_mode) {
263 Ok(())
264 } else {
265 Err(SqliteSyncError::DatabaseOperation)
266 }
267 })?;
268 if config.separate_outbox_path.is_some() {
269 connection
270 .pragma_update_and_check(Some("sync_queue"), "journal_mode", "DELETE", |row| {
271 row.get::<_, String>(0)
272 })
273 .map_err(SqliteSyncError::database)
274 .and_then(|mode| {
275 if mode.eq_ignore_ascii_case("delete") {
276 Ok(())
277 } else {
278 Err(SqliteSyncError::DatabaseOperation)
279 }
280 })?;
281 }
282 connection
283 .pragma_update(None, "synchronous", "FULL")
284 .and_then(|_| connection.pragma_update(None, "foreign_keys", true))
285 .and_then(|_| connection.pragma_update(None, "trusted_schema", false))
286 .and_then(|_| {
287 if config.separate_outbox_path.is_some() {
288 connection.pragma_update(Some("sync_queue"), "synchronous", "FULL")
289 } else {
290 Ok(())
291 }
292 })
293 .and_then(|_| connection.pragma_update(None, "wal_autocheckpoint", 1_000))
294 .map_err(SqliteSyncError::database)?;
295 let max_length = i32::try_from(config.max_outbox_record_bytes)
296 .map_err(|_| SqliteSyncError::InvalidConfiguration("outbox record bound overflow"))?;
297 connection
298 .set_limit(Limit::SQLITE_LIMIT_LENGTH, max_length)
299 .and_then(|_| {
300 connection.set_limit(
301 Limit::SQLITE_LIMIT_ATTACHED,
302 i32::from(config.separate_outbox_path.is_some()),
303 )
304 })
305 .and_then(|_| connection.set_limit(Limit::SQLITE_LIMIT_WORKER_THREADS, 0))
306 .map_err(SqliteSyncError::database)?;
307 let page_bytes: i64 = connection
308 .pragma_query_value(None, "page_size", |row| row.get(0))
309 .map_err(SqliteSyncError::database)?;
310 let page_bytes =
311 u64::try_from(page_bytes).map_err(|_| SqliteSyncError::CorruptRecord("page size"))?;
312 if page_bytes == 0 {
313 return Err(SqliteSyncError::CorruptRecord("page size"));
314 }
315 let max_pages = i64::try_from(config.max_database_bytes / page_bytes)
316 .map_err(|_| SqliteSyncError::InvalidConfiguration("database page bound overflow"))?;
317 connection
318 .pragma_update(None, "max_page_count", max_pages)
319 .and_then(|_| {
320 if config.separate_outbox_path.is_some() {
321 connection.pragma_update(Some("sync_queue"), "max_page_count", max_pages)
322 } else {
323 Ok(())
324 }
325 })
326 .map_err(SqliteSyncError::database)
327}
328
329fn open_connection(config: &SqliteSyncConfig) -> SqliteSyncResult<Connection> {
330 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
331 | OpenFlags::SQLITE_OPEN_CREATE
332 | OpenFlags::SQLITE_OPEN_FULL_MUTEX;
333 let connection =
334 Connection::open_with_flags(&config.path, flags).map_err(SqliteSyncError::database)?;
335 crate::separate_outbox::attach(&connection, config)?;
336 configure(&connection, config)?;
337 Ok(connection)
338}
339
340fn configure_attached_memory(connection: &Connection, database: &str) -> SqliteSyncResult<()> {
341 for (pragma, expected) in [
342 ("cache_size", -2_048_i64),
343 ("mmap_size", 0),
344 ("temp_store", 1),
345 ] {
346 connection
347 .pragma_update(Some(database), pragma, expected)
348 .map_err(SqliteSyncError::database)?;
349 let actual: i64 = connection
350 .pragma_query_value(Some(database), pragma, |row| row.get(0))
351 .map_err(SqliteSyncError::database)?;
352 if actual != expected {
353 return Err(SqliteSyncError::DatabaseOperation);
354 }
355 }
356 Ok(())
357}
358
359pub(crate) fn configure_memory(connection: &Connection) -> SqliteSyncResult<()> {
361 for (pragma, expected) in [
366 ("cache_size", -2_048_i64),
367 ("mmap_size", 0),
368 ("temp_store", 1),
369 ] {
370 connection
371 .pragma_update(None, pragma, expected)
372 .map_err(SqliteSyncError::database)?;
373 let actual: i64 = connection
374 .pragma_query_value(None, pragma, |row| row.get(0))
375 .map_err(SqliteSyncError::database)?;
376 if actual != expected {
377 return Err(SqliteSyncError::DatabaseOperation);
378 }
379 }
380 Ok(())
381}
382
383fn integrity_check(connection: &Connection, config: &SqliteSyncConfig) -> SqliteSyncResult<()> {
384 let result: String = connection
385 .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
386 .map_err(SqliteSyncError::database)?;
387 if result != "ok" {
388 return Err(SqliteSyncError::IntegrityFailed);
389 }
390 if config.separate_outbox_path.is_some() {
391 let queue_result: String = connection
392 .query_row("PRAGMA sync_queue.quick_check(1)", [], |row| row.get(0))
393 .map_err(SqliteSyncError::database)?;
394 if queue_result != "ok" {
395 return Err(SqliteSyncError::IntegrityFailed);
396 }
397 }
398 validate_internal_records(connection, config)
399}
400
401pub(crate) fn normalize_path(path: &Path) -> SqliteSyncResult<std::path::PathBuf> {
402 let file_name = path.file_name().ok_or(SqliteSyncError::UnsafePath)?;
403 match fs::symlink_metadata(path) {
404 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
405 return Err(SqliteSyncError::UnsafePath);
406 }
407 Ok(_) => {}
408 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
409 Err(_) => return Err(SqliteSyncError::UnsafePath),
410 }
411 let parent = path.parent().unwrap_or_else(|| Path::new("."));
412 fs::create_dir_all(parent).map_err(|_| SqliteSyncError::UnsafePath)?;
413 let canonical_parent = fs::canonicalize(parent).map_err(|_| SqliteSyncError::UnsafePath)?;
414 if !canonical_parent.is_dir() {
415 return Err(SqliteSyncError::UnsafePath);
416 }
417 Ok(canonical_parent.join(file_name))
418}
419
420#[cfg(test)]
421mod memory_tests {
422 use super::*;
423
424 #[test]
425 fn every_pooled_connection_and_reopen_apply_memory_policy() {
426 let directory = tempfile::tempdir().unwrap();
427 let config =
428 SqliteSyncConfig::new(directory.path().join("state.db")).with_max_connections(2);
429 for _ in 0..2 {
430 let store = SqliteSyncStore::open(config.clone()).unwrap();
431 let mut first = store.acquire_connection().unwrap();
432 let mut second = store.acquire_connection().unwrap();
433 for guard in [&mut first, &mut second] {
434 let connection = guard.connection_mut().unwrap();
435 let cache: i64 = connection
436 .pragma_query_value(None, "cache_size", |row| row.get(0))
437 .unwrap();
438 let mmap: i64 = connection
439 .pragma_query_value(None, "mmap_size", |row| row.get(0))
440 .unwrap();
441 assert_eq!(cache, -2_048);
442 assert_eq!(mmap, 0);
443 let temp: i64 = connection
444 .pragma_query_value(None, "temp_store", |row| row.get(0))
445 .unwrap();
446 assert_eq!(temp, 1);
447 }
448 }
449 }
450
451 #[test]
452 fn held_reader_allows_wal_growth_beyond_autocheckpoint_until_release() {
453 use appcore_sync::ReplicationLog;
454 let directory = tempfile::tempdir().unwrap();
455 let config =
456 SqliteSyncConfig::new(directory.path().join("state.db")).with_max_connections(2);
457 let store = SqliteSyncStore::open(config.clone()).unwrap();
458 let mut log = store.replication_log();
459 log.append(vec![0]).unwrap();
460 let mut held = store.acquire_connection().unwrap();
461 let reader = held.connection_mut().unwrap();
462 reader.execute_batch("BEGIN DEFERRED").unwrap();
463 let count: i64 = reader
464 .query_row("SELECT COUNT(*) FROM appcore_replication_log", [], |row| {
465 row.get(0)
466 })
467 .unwrap();
468 assert_eq!(count, 1);
469 for sequence in 1..=8 {
470 log.append_with_sequence(vec![sequence as u8; 1024 * 1024], sequence)
471 .unwrap();
472 }
473 let (frames, checkpointed) = store
474 .with_connection(|writer| {
475 let threshold: i64 = writer
476 .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
477 .map_err(SqliteSyncError::database)?;
478 assert_eq!(threshold, 1_000);
479 writer
480 .query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| {
481 Ok((row.get::<_, i64>(1)?, row.get::<_, i64>(2)?))
482 })
483 .map_err(SqliteSyncError::database)
484 })
485 .unwrap();
486 assert!(frames > 1_000);
487 assert!(checkpointed < frames);
488 let count: i64 = reader
489 .query_row("SELECT COUNT(*) FROM appcore_replication_log", [], |row| {
490 row.get(0)
491 })
492 .unwrap();
493 assert_eq!(count, 1);
494 reader.execute_batch("ROLLBACK").unwrap();
495 drop(held);
496 store
497 .with_connection(|connection| {
498 let result: (i64, i64, i64) = connection
499 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
500 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
501 })
502 .map_err(SqliteSyncError::database)?;
503 assert_eq!(result, (0, 0, 0));
504 Ok(())
505 })
506 .unwrap();
507 assert_eq!(log.len().unwrap(), 9);
508 drop(log);
509 drop(store);
510 let reopened = SqliteSyncStore::open(config).unwrap();
511 assert_eq!(reopened.replication_log().len().unwrap(), 9);
512 }
513}