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