use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use notify::{RecursiveMode, Watcher as _};
pub const SQLITE_WATCH_SWEEP: Duration = Duration::from_millis(5_000);
const SQLITE_WATCH_COALESCE: Duration = Duration::from_millis(10);
const SQLITE_WATCH_RECOVERY_BACKOFF_MIN: Duration = Duration::from_millis(100);
const SQLITE_WATCH_RECOVERY_BACKOFF_MAX: Duration = Duration::from_millis(30_000);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteWatchTick {
Changed,
Sweep,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteWatchControl {
Handled,
Failed,
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub enum SqliteWatchJoin {
Joined,
JoinedAfterPanic,
UnjoinedOnWorkerThread,
}
#[derive(Debug, Clone)]
pub struct SqliteWatchStopHandle {
control: Arc<WatchControl>,
}
impl SqliteWatchStopHandle {
pub fn request_stop(&self) {
self.control.request_stop();
}
}
#[derive(Debug)]
struct WatchControl {
stop_requested: Arc<AtomicBool>,
wake: mpsc::Sender<()>,
}
impl WatchControl {
fn request_stop(&self) {
self.stop_requested.store(true, Ordering::Release);
let _ = self.wake.send(());
}
}
enum WorkerCustody {
Running(thread::JoinHandle<()>),
Joined(SqliteWatchJoin),
}
pub struct SqliteChangeWatch {
control: Arc<WatchControl>,
watcher: Option<notify::RecommendedWatcher>,
worker: WorkerCustody,
}
impl std::fmt::Debug for SqliteChangeWatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SqliteChangeWatch").finish_non_exhaustive()
}
}
impl SqliteChangeWatch {
pub fn stop_handle(&self) -> SqliteWatchStopHandle {
SqliteWatchStopHandle {
control: Arc::clone(&self.control),
}
}
pub fn request_stop(&self) {
self.control.request_stop();
}
pub fn on_worker_thread(&self) -> bool {
self.worker_thread_id() == Some(thread::current().id())
}
pub fn worker_thread_id(&self) -> Option<thread::ThreadId> {
match &self.worker {
WorkerCustody::Running(worker) => Some(worker.thread().id()),
WorkerCustody::Joined(_) => None,
}
}
pub fn is_finished(&self) -> bool {
match &self.worker {
WorkerCustody::Running(worker) => worker.is_finished(),
WorkerCustody::Joined(_) => true,
}
}
pub fn stop_and_join(&mut self) -> SqliteWatchJoin {
self.control.request_stop();
drop(self.watcher.take());
if self.on_worker_thread() {
return SqliteWatchJoin::UnjoinedOnWorkerThread;
}
let outcome = match std::mem::replace(
&mut self.worker,
WorkerCustody::Joined(SqliteWatchJoin::Joined),
) {
WorkerCustody::Running(worker) => match worker.join() {
Ok(()) => SqliteWatchJoin::Joined,
Err(_) => SqliteWatchJoin::JoinedAfterPanic,
},
WorkerCustody::Joined(outcome) => outcome,
};
self.worker = WorkerCustody::Joined(outcome);
outcome
}
}
impl Drop for SqliteChangeWatch {
fn drop(&mut self) {
self.control.request_stop();
}
}
fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
let mut value = path.as_os_str().to_os_string();
value.push(suffix);
PathBuf::from(value)
}
pub fn sqlite_watch_paths(path: &Path) -> Vec<PathBuf> {
vec![
path.to_path_buf(),
sidecar_path(path, "-wal"),
sidecar_path(path, "-shm"),
]
}
fn sqlite_watch_targets(db_path: &Path) -> Result<(PathBuf, Vec<PathBuf>), String> {
let file_name = db_path
.file_name()
.ok_or_else(|| format!("{} has no database filename", db_path.display()))?;
let parent = db_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let parent = parent.canonicalize().map_err(|error| {
format!(
"cannot resolve {} for sqlite watch: {error}",
parent.display()
)
})?;
let watched_paths = sqlite_watch_paths(&parent.join(file_name));
Ok((parent, watched_paths))
}
pub fn sqlite_watch_event_relevant(
event: ¬ify::Event,
parent: &Path,
watched_paths: &[PathBuf],
) -> bool {
if matches!(event.kind, notify::EventKind::Access(_)) {
return false;
}
if event.paths.is_empty() {
return true;
}
event.paths.iter().any(|path| {
path == parent
|| watched_paths.iter().any(|watched| {
path == watched
|| (path.parent() == watched.parent()
&& path.file_name() == watched.file_name())
})
})
}
pub fn start_sqlite_change_watch(
db_path: &Path,
thread_name: &str,
sweep: Duration,
mut on_tick: impl FnMut(SqliteWatchTick) -> SqliteWatchControl + Send + 'static,
) -> Result<SqliteChangeWatch, String> {
let (parent, watched_paths) = sqlite_watch_targets(db_path)?;
let (wake_tx, wake_rx) = mpsc::channel::<()>();
let stop_requested = Arc::new(AtomicBool::new(false));
let worker_stop_requested = Arc::clone(&stop_requested);
let callback_parent = parent.clone();
let notification_wake = wake_tx.clone();
let mut watcher = notify::recommended_watcher(move |result: notify::Result<notify::Event>| {
match result {
Ok(event) if sqlite_watch_event_relevant(&event, &callback_parent, &watched_paths) => {
let _ = notification_wake.send(());
}
Ok(_) => {}
Err(error) => {
tracing::warn!(error = %error, "sqlite change watch reported an error");
}
}
})
.map_err(|error| format!("cannot create the sqlite change watcher: {error}"))?;
watcher
.watch(&parent, RecursiveMode::NonRecursive)
.map_err(|error| format!("cannot watch {}: {error}", parent.display()))?;
let worker = thread::Builder::new()
.name(thread_name.to_string())
.spawn(move || {
let mut recovery_backoff = SQLITE_WATCH_RECOVERY_BACKOFF_MIN;
let mut recovery_deadline: Option<Instant> = None;
loop {
let wait = recovery_deadline
.map(|deadline| deadline.saturating_duration_since(Instant::now()))
.unwrap_or(sweep);
let tick = match wake_rx.recv_timeout(wait) {
Ok(()) => SqliteWatchTick::Changed,
Err(mpsc::RecvTimeoutError::Timeout) => SqliteWatchTick::Sweep,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
if worker_stop_requested.load(Ordering::Acquire) {
break;
}
if tick == SqliteWatchTick::Changed {
thread::sleep(SQLITE_WATCH_COALESCE);
}
while wake_rx.try_recv().is_ok() {}
if worker_stop_requested.load(Ordering::Acquire) {
break;
}
if recovery_deadline.is_some_and(|deadline| Instant::now() < deadline) {
continue;
}
match on_tick(tick) {
SqliteWatchControl::Handled => {
recovery_deadline = None;
recovery_backoff = SQLITE_WATCH_RECOVERY_BACKOFF_MIN;
}
SqliteWatchControl::Failed => {
recovery_deadline = Some(Instant::now() + recovery_backoff);
recovery_backoff = recovery_backoff
.saturating_mul(2)
.min(SQLITE_WATCH_RECOVERY_BACKOFF_MAX);
}
SqliteWatchControl::Stop => break,
}
}
})
.map_err(|error| format!("cannot start the sqlite change watch thread: {error}"))?;
Ok(SqliteChangeWatch {
control: Arc::new(WatchControl {
stop_requested,
wake: wake_tx,
}),
watcher: Some(watcher),
worker: WorkerCustody::Running(worker),
})
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
fn assert_database_write_ticks_changed(db: &Path) {
std::fs::write(db, b"").unwrap();
let (tick_tx, tick_rx) = mpsc::channel();
let _watch = start_sqlite_change_watch(db, "test-watch", SQLITE_WATCH_SWEEP, move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Handled
})
.expect("watch starts");
std::fs::write(sidecar_path(db, "-wal"), b"frame").unwrap();
let tick = tick_rx
.recv_timeout(Duration::from_secs(10))
.expect("the write is observed within the hang guard");
assert_eq!(tick, SqliteWatchTick::Changed);
}
#[test]
fn a_write_to_the_database_ticks_changed() {
let dir = tempfile::tempdir().unwrap();
assert_database_write_ticks_changed(&dir.path().join("store.sqlite3"));
}
#[cfg(unix)]
#[test]
fn symlinked_directory_targets_match_canonical_events_for_missing_files() {
let dir = tempfile::tempdir().unwrap();
let actual = dir.path().join("actual");
let alias = dir.path().join("alias");
std::fs::create_dir(&actual).unwrap();
std::os::unix::fs::symlink(&actual, &alias).unwrap();
let db = alias.join("store.sqlite3");
let (parent, watched_paths) = sqlite_watch_targets(&db).unwrap();
let canonical_parent = actual.canonicalize().unwrap();
assert_eq!(parent, canonical_parent);
assert_eq!(
watched_paths,
sqlite_watch_paths(&canonical_parent.join("store.sqlite3"))
);
assert!(!db.exists());
for path in &watched_paths {
assert!(!path.exists());
for kind in [
notify::EventKind::Create(notify::event::CreateKind::File),
notify::EventKind::Remove(notify::event::RemoveKind::File),
] {
let event = notify::Event::new(kind).add_path(path.clone());
assert!(sqlite_watch_event_relevant(&event, &parent, &watched_paths));
}
}
for path in [
canonical_parent.join("other.sqlite3-wal"),
canonical_parent.join("other").join("store.sqlite3-wal"),
] {
let event = notify::Event::new(notify::EventKind::Any).add_path(path);
assert!(!sqlite_watch_event_relevant(
&event,
&parent,
&watched_paths
));
}
let access = notify::Event::new(notify::EventKind::Access(notify::event::AccessKind::Any))
.add_path(watched_paths[0].clone());
assert!(!sqlite_watch_event_relevant(
&access,
&parent,
&watched_paths
));
}
#[cfg(unix)]
#[test]
fn a_write_through_a_symlinked_directory_ticks_changed() {
let dir = tempfile::tempdir().unwrap();
let actual = dir.path().join("actual");
let alias = dir.path().join("alias");
std::fs::create_dir(&actual).unwrap();
std::os::unix::fs::symlink(&actual, &alias).unwrap();
assert_database_write_ticks_changed(&alias.join("store.sqlite3"));
}
#[test]
fn the_sweep_ticks_without_a_notification() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let (tick_tx, tick_rx) = mpsc::channel();
let _watch =
start_sqlite_change_watch(&db, "test-sweep", Duration::from_millis(50), move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Handled
})
.expect("watch starts");
let tick = tick_rx
.recv_timeout(Duration::from_secs(10))
.expect("the sweep ticks within the hang guard");
assert_eq!(tick, SqliteWatchTick::Sweep);
}
#[test]
fn stop_ends_the_worker() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let (tick_tx, tick_rx) = mpsc::channel();
let _watch =
start_sqlite_change_watch(&db, "test-stop", Duration::from_millis(20), move |tick| {
let _ = tick_tx.send(tick);
SqliteWatchControl::Stop
})
.expect("watch starts");
tick_rx
.recv_timeout(Duration::from_secs(10))
.expect("one tick");
assert!(
tick_rx.recv_timeout(Duration::from_millis(500)).is_err(),
"no tick after Stop: the worker ended (its sender is gone)"
);
}
fn wal_database(dir: &Path) -> PathBuf {
let db = dir.join("store.sqlite3");
let conn = crate::open(&db, crate::ConnectionProfile::PRIMARY).unwrap();
conn.execute_batch("CREATE TABLE t (v INTEGER); INSERT INTO t (v) VALUES (1);")
.unwrap();
db
}
struct TickProbe {
ticks: Arc<AtomicUsize>,
returned: mpsc::Sender<()>,
}
impl TickProbe {
fn tick(&self) {
self.ticks.fetch_add(1, Ordering::SeqCst);
}
}
impl Drop for TickProbe {
fn drop(&mut self) {
let _ = self.returned.send(());
}
}
#[test]
fn stop_and_join_waits_for_the_tick_in_flight() {
let dir = tempfile::tempdir().unwrap();
let db = wal_database(dir.path());
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel::<()>();
let finished = Arc::new(AtomicBool::new(false));
let tick_finished = Arc::clone(&finished);
let tick_db = db.clone();
let mut watch = start_sqlite_change_watch(
&db,
"test-held-tick",
Duration::from_millis(20),
move |_tick| {
if tick_finished.load(Ordering::SeqCst) {
return SqliteWatchControl::Handled;
}
let conn = crate::open(&tick_db, crate::ConnectionProfile::ReadOnly).unwrap();
let _ = entered_tx.send(());
let _ = release_rx.recv();
let value: i64 = conn
.query_row("SELECT v FROM t", [], |row| row.get(0))
.unwrap();
assert_eq!(value, 1);
drop(conn);
tick_finished.store(true, Ordering::SeqCst);
SqliteWatchControl::Handled
},
)
.expect("watch starts");
entered_rx
.recv_timeout(Duration::from_secs(10))
.expect("a tick is held within the hang guard");
let (stopping_tx, stopping_rx) = mpsc::channel();
let stopper_finished = Arc::clone(&finished);
let stopper = thread::spawn(move || {
let _ = stopping_tx.send(());
let outcome = watch.stop_and_join();
(outcome, stopper_finished.load(Ordering::SeqCst))
});
stopping_rx.recv().unwrap();
release_tx.send(()).unwrap();
let (outcome, finished_at_return) = stopper.join().unwrap();
assert!(
finished_at_return,
"stop_and_join returned while a tick still held its connection"
);
assert_eq!(outcome, SqliteWatchJoin::Joined);
std::fs::remove_dir_all(dir.path()).unwrap();
assert!(!dir.path().exists());
}
#[test]
fn stop_and_join_on_the_worker_thread_is_unjoined_and_keeps_custody() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let custody: Arc<std::sync::Mutex<Option<SqliteChangeWatch>>> = Arc::default();
let tick_custody = Arc::clone(&custody);
let (outcome_tx, outcome_rx) = mpsc::channel();
let watch = start_sqlite_change_watch(
&db,
"test-self-stop",
Duration::from_millis(20),
move |_| {
let outcome = tick_custody
.lock()
.unwrap()
.as_mut()
.map(SqliteChangeWatch::stop_and_join);
if let Some(outcome) = outcome {
let _ = outcome_tx.send(outcome);
}
SqliteWatchControl::Handled
},
)
.expect("watch starts");
*custody.lock().unwrap() = Some(watch);
let on_worker = outcome_rx
.recv_timeout(Duration::from_secs(10))
.expect("a tick stops its own watch within the hang guard");
assert_eq!(on_worker, SqliteWatchJoin::UnjoinedOnWorkerThread);
let mut watch = custody
.lock()
.unwrap()
.take()
.expect("the watch stays in custody");
assert!(!watch.on_worker_thread());
assert_eq!(watch.stop_and_join(), SqliteWatchJoin::Joined);
assert!(watch.is_finished());
assert_eq!(
watch.stop_and_join(),
SqliteWatchJoin::Joined,
"a repeated stop reports the recorded join"
);
}
#[test]
fn dropping_the_watch_requests_the_stop_without_blocking() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("store.sqlite3");
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel::<()>();
let (returned_tx, returned_rx) = mpsc::channel();
let ticks = Arc::new(AtomicUsize::new(0));
let probe = TickProbe {
ticks: Arc::clone(&ticks),
returned: returned_tx,
};
let watch =
start_sqlite_change_watch(&db, "test-drop", Duration::from_millis(20), move |_| {
probe.tick();
let _ = entered_tx.send(());
let _ = release_rx.recv();
SqliteWatchControl::Handled
})
.expect("watch starts");
entered_rx
.recv_timeout(Duration::from_secs(10))
.expect("a tick is held within the hang guard");
drop(watch);
release_tx.send(()).unwrap();
returned_rx
.recv_timeout(Duration::from_secs(10))
.expect("the worker returns after the held tick");
assert_eq!(
ticks.load(Ordering::SeqCst),
1,
"no tick starts after the stop request"
);
}
}