1use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::{Arc, mpsc};
28use std::thread;
29use std::time::{Duration, Instant};
30
31use notify::{RecursiveMode, Watcher as _};
32
33pub const SQLITE_WATCH_SWEEP: Duration = Duration::from_millis(5_000);
35const SQLITE_WATCH_COALESCE: Duration = Duration::from_millis(10);
38const SQLITE_WATCH_RECOVERY_BACKOFF_MIN: Duration = Duration::from_millis(100);
39const SQLITE_WATCH_RECOVERY_BACKOFF_MAX: Duration = Duration::from_millis(30_000);
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum SqliteWatchTick {
44 Changed,
46 Sweep,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum SqliteWatchControl {
53 Handled,
55 Failed,
59 Stop,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65#[must_use]
66pub enum SqliteWatchJoin {
67 Joined,
70 JoinedAfterPanic,
73 UnjoinedOnWorkerThread,
79}
80
81#[derive(Debug, Clone)]
84pub struct SqliteWatchStopHandle {
85 control: Arc<WatchControl>,
86}
87
88impl SqliteWatchStopHandle {
89 pub fn request_stop(&self) {
92 self.control.request_stop();
93 }
94}
95
96#[derive(Debug)]
97struct WatchControl {
98 stop_requested: Arc<AtomicBool>,
99 wake: mpsc::Sender<()>,
100}
101
102impl WatchControl {
103 fn request_stop(&self) {
104 self.stop_requested.store(true, Ordering::Release);
105 let _ = self.wake.send(());
108 }
109}
110
111enum WorkerCustody {
113 Running(thread::JoinHandle<()>),
114 Joined(SqliteWatchJoin),
115}
116
117pub struct SqliteChangeWatch {
124 control: Arc<WatchControl>,
125 watcher: Option<notify::RecommendedWatcher>,
126 worker: WorkerCustody,
127}
128
129impl std::fmt::Debug for SqliteChangeWatch {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.debug_struct("SqliteChangeWatch").finish_non_exhaustive()
132 }
133}
134
135impl SqliteChangeWatch {
136 pub fn stop_handle(&self) -> SqliteWatchStopHandle {
139 SqliteWatchStopHandle {
140 control: Arc::clone(&self.control),
141 }
142 }
143
144 pub fn request_stop(&self) {
146 self.control.request_stop();
147 }
148
149 pub fn on_worker_thread(&self) -> bool {
152 self.worker_thread_id() == Some(thread::current().id())
153 }
154
155 pub fn worker_thread_id(&self) -> Option<thread::ThreadId> {
157 match &self.worker {
158 WorkerCustody::Running(worker) => Some(worker.thread().id()),
159 WorkerCustody::Joined(_) => None,
160 }
161 }
162
163 pub fn is_finished(&self) -> bool {
166 match &self.worker {
167 WorkerCustody::Running(worker) => worker.is_finished(),
168 WorkerCustody::Joined(_) => true,
169 }
170 }
171
172 pub fn stop_and_join(&mut self) -> SqliteWatchJoin {
181 self.control.request_stop();
182 drop(self.watcher.take());
183 if self.on_worker_thread() {
184 return SqliteWatchJoin::UnjoinedOnWorkerThread;
185 }
186 let outcome = match std::mem::replace(
187 &mut self.worker,
188 WorkerCustody::Joined(SqliteWatchJoin::Joined),
189 ) {
190 WorkerCustody::Running(worker) => match worker.join() {
191 Ok(()) => SqliteWatchJoin::Joined,
192 Err(_) => SqliteWatchJoin::JoinedAfterPanic,
193 },
194 WorkerCustody::Joined(outcome) => outcome,
195 };
196 self.worker = WorkerCustody::Joined(outcome);
197 outcome
198 }
199}
200
201impl Drop for SqliteChangeWatch {
202 fn drop(&mut self) {
203 self.control.request_stop();
206 }
207}
208
209fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
210 let mut value = path.as_os_str().to_os_string();
211 value.push(suffix);
212 PathBuf::from(value)
213}
214
215pub fn sqlite_watch_paths(path: &Path) -> Vec<PathBuf> {
217 vec![
218 path.to_path_buf(),
219 sidecar_path(path, "-wal"),
220 sidecar_path(path, "-shm"),
221 ]
222}
223
224fn sqlite_watch_targets(db_path: &Path) -> Result<(PathBuf, Vec<PathBuf>), String> {
225 let file_name = db_path
226 .file_name()
227 .ok_or_else(|| format!("{} has no database filename", db_path.display()))?;
228 let parent = db_path
229 .parent()
230 .filter(|parent| !parent.as_os_str().is_empty())
231 .unwrap_or_else(|| Path::new("."));
232 let parent = parent.canonicalize().map_err(|error| {
233 format!(
234 "cannot resolve {} for sqlite watch: {error}",
235 parent.display()
236 )
237 })?;
238 let watched_paths = sqlite_watch_paths(&parent.join(file_name));
242 Ok((parent, watched_paths))
243}
244
245pub fn sqlite_watch_event_relevant(
247 event: ¬ify::Event,
248 parent: &Path,
249 watched_paths: &[PathBuf],
250) -> bool {
251 if matches!(event.kind, notify::EventKind::Access(_)) {
252 return false;
253 }
254 if event.paths.is_empty() {
255 return true;
256 }
257 event.paths.iter().any(|path| {
258 path == parent
259 || watched_paths.iter().any(|watched| {
260 path == watched
261 || (path.parent() == watched.parent()
262 && path.file_name() == watched.file_name())
263 })
264 })
265}
266
267pub fn start_sqlite_change_watch(
272 db_path: &Path,
273 thread_name: &str,
274 sweep: Duration,
275 mut on_tick: impl FnMut(SqliteWatchTick) -> SqliteWatchControl + Send + 'static,
276) -> Result<SqliteChangeWatch, String> {
277 let (parent, watched_paths) = sqlite_watch_targets(db_path)?;
278 let (wake_tx, wake_rx) = mpsc::channel::<()>();
279 let stop_requested = Arc::new(AtomicBool::new(false));
280 let worker_stop_requested = Arc::clone(&stop_requested);
281
282 let callback_parent = parent.clone();
283 let notification_wake = wake_tx.clone();
284 let mut watcher = notify::recommended_watcher(move |result: notify::Result<notify::Event>| {
285 match result {
286 Ok(event) if sqlite_watch_event_relevant(&event, &callback_parent, &watched_paths) => {
287 let _ = notification_wake.send(());
288 }
289 Ok(_) => {}
290 Err(error) => {
291 tracing::warn!(error = %error, "sqlite change watch reported an error");
293 }
294 }
295 })
296 .map_err(|error| format!("cannot create the sqlite change watcher: {error}"))?;
297 watcher
298 .watch(&parent, RecursiveMode::NonRecursive)
299 .map_err(|error| format!("cannot watch {}: {error}", parent.display()))?;
300
301 let worker = thread::Builder::new()
302 .name(thread_name.to_string())
303 .spawn(move || {
304 let mut recovery_backoff = SQLITE_WATCH_RECOVERY_BACKOFF_MIN;
305 let mut recovery_deadline: Option<Instant> = None;
306 loop {
307 let wait = recovery_deadline
308 .map(|deadline| deadline.saturating_duration_since(Instant::now()))
309 .unwrap_or(sweep);
310 let tick = match wake_rx.recv_timeout(wait) {
311 Ok(()) => SqliteWatchTick::Changed,
312 Err(mpsc::RecvTimeoutError::Timeout) => SqliteWatchTick::Sweep,
313 Err(mpsc::RecvTimeoutError::Disconnected) => break,
315 };
316 if worker_stop_requested.load(Ordering::Acquire) {
317 break;
318 }
319 if tick == SqliteWatchTick::Changed {
320 thread::sleep(SQLITE_WATCH_COALESCE);
321 }
322 while wake_rx.try_recv().is_ok() {}
323 if worker_stop_requested.load(Ordering::Acquire) {
326 break;
327 }
328 if recovery_deadline.is_some_and(|deadline| Instant::now() < deadline) {
329 continue;
332 }
333 match on_tick(tick) {
334 SqliteWatchControl::Handled => {
335 recovery_deadline = None;
336 recovery_backoff = SQLITE_WATCH_RECOVERY_BACKOFF_MIN;
337 }
338 SqliteWatchControl::Failed => {
339 recovery_deadline = Some(Instant::now() + recovery_backoff);
340 recovery_backoff = recovery_backoff
341 .saturating_mul(2)
342 .min(SQLITE_WATCH_RECOVERY_BACKOFF_MAX);
343 }
344 SqliteWatchControl::Stop => break,
345 }
346 }
347 })
348 .map_err(|error| format!("cannot start the sqlite change watch thread: {error}"))?;
349
350 Ok(SqliteChangeWatch {
351 control: Arc::new(WatchControl {
352 stop_requested,
353 wake: wake_tx,
354 }),
355 watcher: Some(watcher),
356 worker: WorkerCustody::Running(worker),
357 })
358}
359
360#[cfg(test)]
361#[allow(clippy::expect_used, clippy::unwrap_used)]
362mod tests {
363 use super::*;
364 use std::sync::atomic::AtomicUsize;
365
366 fn assert_database_write_ticks_changed(db: &Path) {
367 std::fs::write(db, b"").unwrap();
368 let (tick_tx, tick_rx) = mpsc::channel();
369 let _watch = start_sqlite_change_watch(db, "test-watch", SQLITE_WATCH_SWEEP, move |tick| {
370 let _ = tick_tx.send(tick);
371 SqliteWatchControl::Handled
372 })
373 .expect("watch starts");
374 std::fs::write(sidecar_path(db, "-wal"), b"frame").unwrap();
375 let tick = tick_rx
376 .recv_timeout(Duration::from_secs(10))
377 .expect("the write is observed within the hang guard");
378 assert_eq!(tick, SqliteWatchTick::Changed);
379 }
380
381 #[test]
382 fn a_write_to_the_database_ticks_changed() {
383 let dir = tempfile::tempdir().unwrap();
384 assert_database_write_ticks_changed(&dir.path().join("store.sqlite3"));
385 }
386
387 #[cfg(unix)]
388 #[test]
389 fn symlinked_directory_targets_match_canonical_events_for_missing_files() {
390 let dir = tempfile::tempdir().unwrap();
391 let actual = dir.path().join("actual");
392 let alias = dir.path().join("alias");
393 std::fs::create_dir(&actual).unwrap();
394 std::os::unix::fs::symlink(&actual, &alias).unwrap();
395 let db = alias.join("store.sqlite3");
396 let (parent, watched_paths) = sqlite_watch_targets(&db).unwrap();
397 let canonical_parent = actual.canonicalize().unwrap();
398 assert_eq!(parent, canonical_parent);
399 assert_eq!(
400 watched_paths,
401 sqlite_watch_paths(&canonical_parent.join("store.sqlite3"))
402 );
403 assert!(!db.exists());
404 for path in &watched_paths {
405 assert!(!path.exists());
406 for kind in [
407 notify::EventKind::Create(notify::event::CreateKind::File),
408 notify::EventKind::Remove(notify::event::RemoveKind::File),
409 ] {
410 let event = notify::Event::new(kind).add_path(path.clone());
411 assert!(sqlite_watch_event_relevant(&event, &parent, &watched_paths));
412 }
413 }
414 for path in [
415 canonical_parent.join("other.sqlite3-wal"),
416 canonical_parent.join("other").join("store.sqlite3-wal"),
417 ] {
418 let event = notify::Event::new(notify::EventKind::Any).add_path(path);
419 assert!(!sqlite_watch_event_relevant(
420 &event,
421 &parent,
422 &watched_paths
423 ));
424 }
425 let access = notify::Event::new(notify::EventKind::Access(notify::event::AccessKind::Any))
426 .add_path(watched_paths[0].clone());
427 assert!(!sqlite_watch_event_relevant(
428 &access,
429 &parent,
430 &watched_paths
431 ));
432 }
433
434 #[cfg(unix)]
435 #[test]
436 fn a_write_through_a_symlinked_directory_ticks_changed() {
437 let dir = tempfile::tempdir().unwrap();
438 let actual = dir.path().join("actual");
439 let alias = dir.path().join("alias");
440 std::fs::create_dir(&actual).unwrap();
441 std::os::unix::fs::symlink(&actual, &alias).unwrap();
442 assert_database_write_ticks_changed(&alias.join("store.sqlite3"));
443 }
444
445 #[test]
446 fn the_sweep_ticks_without_a_notification() {
447 let dir = tempfile::tempdir().unwrap();
448 let db = dir.path().join("store.sqlite3");
449 let (tick_tx, tick_rx) = mpsc::channel();
450 let _watch =
451 start_sqlite_change_watch(&db, "test-sweep", Duration::from_millis(50), move |tick| {
452 let _ = tick_tx.send(tick);
453 SqliteWatchControl::Handled
454 })
455 .expect("watch starts");
456 let tick = tick_rx
457 .recv_timeout(Duration::from_secs(10))
458 .expect("the sweep ticks within the hang guard");
459 assert_eq!(tick, SqliteWatchTick::Sweep);
460 }
461
462 #[test]
463 fn stop_ends_the_worker() {
464 let dir = tempfile::tempdir().unwrap();
465 let db = dir.path().join("store.sqlite3");
466 let (tick_tx, tick_rx) = mpsc::channel();
467 let _watch =
468 start_sqlite_change_watch(&db, "test-stop", Duration::from_millis(20), move |tick| {
469 let _ = tick_tx.send(tick);
470 SqliteWatchControl::Stop
471 })
472 .expect("watch starts");
473 tick_rx
474 .recv_timeout(Duration::from_secs(10))
475 .expect("one tick");
476 assert!(
477 tick_rx.recv_timeout(Duration::from_millis(500)).is_err(),
478 "no tick after Stop: the worker ended (its sender is gone)"
479 );
480 }
481
482 fn wal_database(dir: &Path) -> PathBuf {
484 let db = dir.join("store.sqlite3");
485 let conn = crate::open(&db, crate::ConnectionProfile::PRIMARY).unwrap();
486 conn.execute_batch("CREATE TABLE t (v INTEGER); INSERT INTO t (v) VALUES (1);")
487 .unwrap();
488 db
489 }
490
491 struct TickProbe {
494 ticks: Arc<AtomicUsize>,
495 returned: mpsc::Sender<()>,
496 }
497
498 impl TickProbe {
499 fn tick(&self) {
500 self.ticks.fetch_add(1, Ordering::SeqCst);
501 }
502 }
503
504 impl Drop for TickProbe {
505 fn drop(&mut self) {
506 let _ = self.returned.send(());
507 }
508 }
509
510 #[test]
511 fn stop_and_join_waits_for_the_tick_in_flight() {
512 let dir = tempfile::tempdir().unwrap();
513 let db = wal_database(dir.path());
514 let (entered_tx, entered_rx) = mpsc::channel();
515 let (release_tx, release_rx) = mpsc::channel::<()>();
516 let finished = Arc::new(AtomicBool::new(false));
517 let tick_finished = Arc::clone(&finished);
518 let tick_db = db.clone();
519 let mut watch = start_sqlite_change_watch(
520 &db,
521 "test-held-tick",
522 Duration::from_millis(20),
523 move |_tick| {
524 if tick_finished.load(Ordering::SeqCst) {
525 return SqliteWatchControl::Handled;
526 }
527 let conn = crate::open(&tick_db, crate::ConnectionProfile::ReadOnly).unwrap();
530 let _ = entered_tx.send(());
531 let _ = release_rx.recv();
532 let value: i64 = conn
533 .query_row("SELECT v FROM t", [], |row| row.get(0))
534 .unwrap();
535 assert_eq!(value, 1);
536 drop(conn);
537 tick_finished.store(true, Ordering::SeqCst);
538 SqliteWatchControl::Handled
539 },
540 )
541 .expect("watch starts");
542 entered_rx
543 .recv_timeout(Duration::from_secs(10))
544 .expect("a tick is held within the hang guard");
545
546 let (stopping_tx, stopping_rx) = mpsc::channel();
547 let stopper_finished = Arc::clone(&finished);
548 let stopper = thread::spawn(move || {
549 let _ = stopping_tx.send(());
550 let outcome = watch.stop_and_join();
551 (outcome, stopper_finished.load(Ordering::SeqCst))
552 });
553 stopping_rx.recv().unwrap();
554 release_tx.send(()).unwrap();
555 let (outcome, finished_at_return) = stopper.join().unwrap();
556
557 assert!(
558 finished_at_return,
559 "stop_and_join returned while a tick still held its connection"
560 );
561 assert_eq!(outcome, SqliteWatchJoin::Joined);
562 std::fs::remove_dir_all(dir.path()).unwrap();
565 assert!(!dir.path().exists());
566 }
567
568 #[test]
569 fn stop_and_join_on_the_worker_thread_is_unjoined_and_keeps_custody() {
570 let dir = tempfile::tempdir().unwrap();
571 let db = dir.path().join("store.sqlite3");
572 let custody: Arc<std::sync::Mutex<Option<SqliteChangeWatch>>> = Arc::default();
573 let tick_custody = Arc::clone(&custody);
574 let (outcome_tx, outcome_rx) = mpsc::channel();
575 let watch = start_sqlite_change_watch(
576 &db,
577 "test-self-stop",
578 Duration::from_millis(20),
579 move |_| {
580 let outcome = tick_custody
581 .lock()
582 .unwrap()
583 .as_mut()
584 .map(SqliteChangeWatch::stop_and_join);
585 if let Some(outcome) = outcome {
586 let _ = outcome_tx.send(outcome);
587 }
588 SqliteWatchControl::Handled
589 },
590 )
591 .expect("watch starts");
592 *custody.lock().unwrap() = Some(watch);
593
594 let on_worker = outcome_rx
595 .recv_timeout(Duration::from_secs(10))
596 .expect("a tick stops its own watch within the hang guard");
597 assert_eq!(on_worker, SqliteWatchJoin::UnjoinedOnWorkerThread);
598
599 let mut watch = custody
602 .lock()
603 .unwrap()
604 .take()
605 .expect("the watch stays in custody");
606 assert!(!watch.on_worker_thread());
607 assert_eq!(watch.stop_and_join(), SqliteWatchJoin::Joined);
608 assert!(watch.is_finished());
609 assert_eq!(
610 watch.stop_and_join(),
611 SqliteWatchJoin::Joined,
612 "a repeated stop reports the recorded join"
613 );
614 }
615
616 #[test]
617 fn dropping_the_watch_requests_the_stop_without_blocking() {
618 let dir = tempfile::tempdir().unwrap();
619 let db = dir.path().join("store.sqlite3");
620 let (entered_tx, entered_rx) = mpsc::channel();
621 let (release_tx, release_rx) = mpsc::channel::<()>();
622 let (returned_tx, returned_rx) = mpsc::channel();
623 let ticks = Arc::new(AtomicUsize::new(0));
624 let probe = TickProbe {
625 ticks: Arc::clone(&ticks),
626 returned: returned_tx,
627 };
628 let watch =
629 start_sqlite_change_watch(&db, "test-drop", Duration::from_millis(20), move |_| {
630 probe.tick();
631 let _ = entered_tx.send(());
632 let _ = release_rx.recv();
633 SqliteWatchControl::Handled
634 })
635 .expect("watch starts");
636 entered_rx
637 .recv_timeout(Duration::from_secs(10))
638 .expect("a tick is held within the hang guard");
639
640 drop(watch);
643 release_tx.send(()).unwrap();
644 returned_rx
645 .recv_timeout(Duration::from_secs(10))
646 .expect("the worker returns after the held tick");
647 assert_eq!(
648 ticks.load(Ordering::SeqCst),
649 1,
650 "no tick starts after the stop request"
651 );
652 }
653}