1use std::{
4 collections::HashMap,
5 fs,
6 path::{Path, PathBuf},
7 sync::{
8 atomic::{AtomicBool, Ordering},
9 Arc, Mutex, OnceLock, Weak,
10 },
11 time::{Duration, Instant},
12};
13
14use rusqlite::Connection;
15
16use super::{Result, ViewError, ViewStore};
17
18impl ViewStore {
19 pub fn derived_path(&self, generation: &str) -> Result<PathBuf> {
20 let owner = self.derived_owner(generation)?;
21 Ok(self.view_dir().join(format!("derived-{owner}.sqlite")))
22 }
23
24 pub(super) fn derived_owner(&self, generation: &str) -> Result<String> {
25 super::validate_generation(generation)?;
26 match fs::read_to_string(self.view_dir().join(format!("derived-{generation}.ref"))) {
27 Ok(owner) => {
28 super::validate_generation(&owner)?;
29 Ok(owner)
30 }
31 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(generation.to_owned()),
32 Err(error) => Err(error.into()),
33 }
34 }
35
36 pub(super) fn reuse_derived(&self, generation: &str, base: &str) -> Result<()> {
37 super::validate_generation(generation)?;
38 let mut pointer = self.open_pointer_connection()?;
41 let _ownership =
42 pointer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
43 let owner = self.derived_owner(base)?;
44 let path = self.view_dir().join(format!("derived-{generation}.ref"));
45 let mut file = fs::OpenOptions::new()
46 .write(true)
47 .create_new(true)
48 .open(&path)?;
49 use std::io::Write as _;
50 file.write_all(owner.as_bytes())?;
51 file.sync_all()?;
52 super::sync_parent(&path)
53 }
54
55 pub fn trigram_path(&self, generation: &str) -> Result<PathBuf> {
56 super::validate_generation(generation)?;
57 Ok(self.view_dir().join(format!("trigram-{generation}.bin")))
58 }
59
60 pub fn blob_references_by_generation(
61 &self,
62 ) -> Result<std::collections::BTreeMap<String, std::collections::BTreeSet<[u8; 32]>>> {
63 let mut result = std::collections::BTreeMap::new();
64 for entry in fs::read_dir(self.view_dir())? {
65 let entry = entry?;
66 let name = entry.file_name();
67 let Some(generation) = name
68 .to_str()
69 .and_then(|name| name.strip_prefix("manifest-"))
70 .and_then(|name| name.strip_suffix(".json"))
71 else {
72 continue;
73 };
74 let manifest = self.load_manifest(generation)?;
75 let keys = manifest
76 .plane_keys()
77 .filter_map(|(_, key)| {
78 if key.len() != 64 {
79 return None;
80 }
81 let bytes = (0..64)
82 .step_by(2)
83 .map(|offset| u8::from_str_radix(&key[offset..offset + 2], 16).ok())
84 .collect::<Option<Vec<_>>>()?;
85 bytes.try_into().ok()
86 })
87 .collect();
88 result.insert(generation.to_owned(), keys);
89 }
90 Ok(result)
91 }
92
93 pub fn sweep_generations(&self) -> Result<usize> {
96 let mut pointer = self.open_pointer_connection()?;
99 let _ownership =
100 pointer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
101 let current = self.current_generation()?;
102 let mut generations = std::collections::BTreeSet::new();
103 let mut derived_owners = std::collections::BTreeSet::new();
104 for entry in fs::read_dir(self.view_dir())? {
105 let entry = entry?;
106 let name = entry.file_name();
107 let Some(name) = name.to_str() else { continue };
108 if let Some(generation) = name
109 .strip_prefix("derived-")
110 .and_then(|s| s.strip_suffix(".ref"))
111 {
112 generations.insert(generation.to_owned());
113 derived_owners.insert(self.derived_owner(generation)?);
114 }
115 let generation = name
116 .strip_prefix("derived-")
117 .and_then(|s| s.strip_suffix(".sqlite"))
118 .or_else(|| {
119 name.strip_prefix("manifest-")
120 .and_then(|s| s.strip_suffix(".json"))
121 })
122 .or_else(|| {
123 name.strip_prefix("trigram-")
124 .and_then(|s| s.strip_suffix(".bin"))
125 })
126 .or_else(|| {
127 name.strip_prefix(".manifest-")
128 .and_then(|s| s.split_once(".json.tmp.").map(|(generation, _)| generation))
129 });
130 if let Some(generation) = generation {
131 generations.insert(generation.to_owned());
132 }
133 }
134 let mut removed = 0;
135 for generation in generations {
136 if current.as_deref() == Some(&generation) || derived_owners.contains(&generation) {
139 continue;
140 }
141 let (metadata_path, keys_path) = crate::pins::pin_paths(self.view_dir(), &generation);
142 if metadata_path.exists() {
143 let Ok(bytes) = fs::read(&metadata_path) else {
144 continue;
145 };
146 let Ok(metadata) = serde_json::from_slice::<crate::pins::PinMetadata>(&bytes)
147 else {
148 continue;
149 };
150 if crate::pins::owner_is_live(&metadata.owner)
151 && crate::pins::now_ms().saturating_sub(metadata.renewed_at)
152 <= crate::pins::PIN_TTL_MS
153 {
154 continue;
155 }
156 let _ = fs::remove_file(metadata_path);
157 let _ = fs::remove_file(keys_path);
158 }
159 if crate::root_cache::sweep_read_markers(self.view_dir(), &generation).protected {
160 continue;
161 }
162 if self.current_generation()?.as_deref() == Some(&generation) {
165 continue;
166 }
167 self.remove_generation_files(&generation);
168 removed += 1;
169 }
170 Ok(removed)
171 }
172
173 pub(super) fn remove_generation_files(&self, generation: &str) {
174 if super::validate_generation(generation).is_err() {
175 return;
176 }
177 let temporary_prefix = format!(".manifest-{generation}.json.tmp.");
178 if let Ok(entries) = fs::read_dir(self.view_dir()) {
179 for entry in entries.flatten() {
180 if entry
181 .file_name()
182 .to_str()
183 .is_some_and(|name| name.starts_with(&temporary_prefix))
184 {
185 let _ = fs::remove_file(entry.path());
186 }
187 }
188 }
189 for path in [
190 Ok(self.view_dir().join(format!("derived-{generation}.sqlite"))),
191 Ok(self.view_dir().join(format!("derived-{generation}.ref"))),
192 self.trigram_path(generation),
193 self.manifest_path(generation),
194 ]
195 .into_iter()
196 .flatten()
197 {
198 for suffix in ["", "-wal", "-shm"] {
199 let mut name = path.as_os_str().to_owned();
200 name.push(suffix);
201 let _ = fs::remove_file(PathBuf::from(name));
202 }
203 }
204 }
205}
206
207#[derive(Clone)]
208struct DeferredCheckpointJob {
209 path: PathBuf,
210 cancelled: Arc<AtomicBool>,
211}
212
213static DEFERRED_CHECKPOINTS: OnceLock<Mutex<HashMap<PathBuf, DeferredCheckpointJob>>> =
214 OnceLock::new();
215static CHECKPOINT_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
216const DEFERRED_CHECKPOINT_IDLE_DELAY: Duration = Duration::from_millis(250);
217
218fn checkpoint_lock(path: &Path) -> Arc<Mutex<()>> {
219 let mut locks = CHECKPOINT_LOCKS
220 .get_or_init(|| Mutex::new(HashMap::new()))
221 .lock()
222 .unwrap_or_else(std::sync::PoisonError::into_inner);
223 if let Some(lock) = locks.get(path).and_then(Weak::upgrade) {
224 return lock;
225 }
226 let lock = Arc::new(Mutex::new(()));
227 locks.insert(path.to_path_buf(), Arc::downgrade(&lock));
228 lock
229}
230
231fn checkpoint_derived(path: &Path, connection: Option<&Connection>) -> Result<()> {
232 let lock = checkpoint_lock(path);
233 let _guard = lock
234 .lock()
235 .unwrap_or_else(std::sync::PoisonError::into_inner);
236 let owned;
237 let connection = if let Some(connection) = connection {
238 connection
239 } else {
240 owned = Connection::open(path)?;
241 &owned
242 };
243 connection.busy_timeout(Duration::from_secs(5))?;
244 connection.pragma_update(None, "synchronous", "FULL")?;
245 let (busy, log_frames, checkpointed_frames): (i64, i64, i64) =
246 connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
247 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
248 })?;
249 if busy != 0 {
250 return Err(ViewError::InvalidManifest(format!(
251 "derived WAL checkpoint remained busy: path={} log_frames={} checkpointed_frames={}",
252 path.display(),
253 log_frames,
254 checkpointed_frames
255 )));
256 }
257 super::sync_file(path)?;
258 super::sync_parent(path)?;
259 Ok(())
260}
261
262pub(super) fn schedule_derived_checkpoint(path: PathBuf, connection: Connection, root: PathBuf) {
266 let key = path.parent().unwrap_or(&path).to_path_buf();
267 let cancelled = Arc::new(AtomicBool::new(false));
268 let job = DeferredCheckpointJob {
269 path: path.clone(),
270 cancelled: Arc::clone(&cancelled),
271 };
272 if let Some(previous) = DEFERRED_CHECKPOINTS
273 .get_or_init(|| Mutex::new(HashMap::new()))
274 .lock()
275 .unwrap_or_else(std::sync::PoisonError::into_inner)
276 .insert(key.clone(), job)
277 {
278 previous.cancelled.store(true, Ordering::Release);
279 log::debug!(
280 "view derived checkpoint superseded path={}",
281 previous.path.display()
282 );
283 }
284 let path_for_error = path.clone();
285 let key_for_error = key.clone();
286 let cancelled_for_error = Arc::clone(&cancelled);
287 let spawn = std::thread::Builder::new()
288 .name("aft-view-checkpoint".to_owned())
289 .spawn(move || {
290 std::thread::sleep(DEFERRED_CHECKPOINT_IDLE_DELAY);
291 let started = Instant::now();
292 let mut io = super::io::Window::new();
293 let skipped = cancelled.load(Ordering::Acquire);
294 if !skipped {
295 match checkpoint_derived(&path, Some(&connection)) {
296 Ok(()) => log::info!(
297 "view derived checkpoint completed ms={} path={}",
298 started.elapsed().as_millis(),
299 path.display()
300 ),
301 Err(error) => log::warn!(
302 "view derived checkpoint deferred to next clone path={} error={}",
303 path.display(),
304 error
305 ),
306 }
307 }
308 drop(connection);
310 crate::slog_info!(
311 "index_event kind=view_checkpoint root={} generation={} skipped={} {}",
312 root.display(),
313 path.file_stem()
314 .and_then(|name| name.to_str())
315 .unwrap_or("unknown")
316 .strip_prefix("derived-")
317 .unwrap_or("unknown"),
318 skipped,
319 io.finish()
320 );
321 let mut jobs = DEFERRED_CHECKPOINTS
322 .get_or_init(|| Mutex::new(HashMap::new()))
323 .lock()
324 .unwrap_or_else(std::sync::PoisonError::into_inner);
325 if jobs
326 .get(&key)
327 .is_some_and(|current| Arc::ptr_eq(¤t.cancelled, &cancelled))
328 {
329 jobs.remove(&key);
330 }
331 });
332 if let Err(error) = spawn {
333 let mut jobs = DEFERRED_CHECKPOINTS
334 .get_or_init(|| Mutex::new(HashMap::new()))
335 .lock()
336 .unwrap_or_else(std::sync::PoisonError::into_inner);
337 if jobs
338 .get(&key_for_error)
339 .is_some_and(|current| Arc::ptr_eq(¤t.cancelled, &cancelled_for_error))
340 {
341 jobs.remove(&key_for_error);
342 }
343 log::warn!(
344 "view derived checkpoint worker unavailable; next clone will checkpoint path={} error={}",
345 path_for_error.display(),
346 error
347 );
348 }
349}
350
351pub(super) fn clone_derived(source: &Path, destination: &Path) -> Result<()> {
354 checkpoint_derived(source, None)?;
355 let started = Instant::now();
356 let mechanism = if try_clone(source, destination) {
357 if cfg!(target_os = "macos") {
358 "clonefile"
359 } else {
360 "reflink"
361 }
362 } else {
363 let _ = fs::remove_file(destination);
364 fs::copy(source, destination)?;
365 "copy"
366 };
367 log::info!(
368 "view derived clone mechanism={} ms={} source={} destination={}",
369 mechanism,
370 started.elapsed().as_millis(),
371 source.display(),
372 destination.display()
373 );
374 Ok(())
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn deferred_checkpoint_runs_after_the_publication_path_returns() {
383 let directory = tempfile::tempdir().unwrap();
384 let source = directory.path().join("source.sqlite");
385 let connection = Connection::open(&source).unwrap();
386 connection
387 .pragma_update(None, "journal_mode", "WAL")
388 .unwrap();
389 connection
390 .pragma_update(None, "synchronous", "FULL")
391 .unwrap();
392 connection
393 .pragma_update(None, "wal_autocheckpoint", 0)
394 .unwrap();
395 connection
396 .execute_batch(
397 "CREATE TABLE state (value TEXT NOT NULL);\
398 INSERT INTO state VALUES ('durable');",
399 )
400 .unwrap();
401 let wal = PathBuf::from(format!("{}-wal", source.display()));
402 assert!(fs::metadata(&wal).unwrap().len() > 0);
403
404 schedule_derived_checkpoint(
405 source.clone(),
406 connection,
407 source.parent().unwrap().to_path_buf(),
408 );
409
410 assert!(
411 fs::metadata(&wal).is_ok_and(|metadata| metadata.len() > 0),
412 "checkpoint ran synchronously on the publication path"
413 );
414 let deadline = Instant::now() + Duration::from_secs(5);
415 while fs::metadata(&wal).is_ok_and(|metadata| metadata.len() > 0) {
416 assert!(
417 Instant::now() < deadline,
418 "detached derived checkpoint did not finish"
419 );
420 std::thread::sleep(Duration::from_millis(10));
421 }
422 assert_eq!(
423 Connection::open(&source)
424 .unwrap()
425 .query_row("SELECT value FROM state", [], |row| row.get::<_, String>(0))
426 .unwrap(),
427 "durable"
428 );
429 }
430
431 #[test]
432 fn clone_checkpoints_committed_wal_before_copying_the_main_file() {
433 let directory = tempfile::tempdir().unwrap();
434 let source = directory.path().join("source.sqlite");
435 let destination = directory.path().join("destination.sqlite");
436 let connection = Connection::open(&source).unwrap();
437 connection
438 .pragma_update(None, "journal_mode", "WAL")
439 .unwrap();
440 connection
441 .pragma_update(None, "synchronous", "FULL")
442 .unwrap();
443 connection
444 .pragma_update(None, "wal_autocheckpoint", 0)
445 .unwrap();
446 connection
447 .execute_batch(
448 "CREATE TABLE state (value TEXT NOT NULL);\
449 INSERT INTO state VALUES ('committed-in-wal');",
450 )
451 .unwrap();
452 let wal = PathBuf::from(format!("{}-wal", source.display()));
453 assert!(fs::metadata(&wal).unwrap().len() > 0);
454
455 clone_derived(&source, &destination).unwrap();
456
457 assert_eq!(
458 Connection::open(&destination)
459 .unwrap()
460 .query_row("SELECT value FROM state", [], |row| row.get::<_, String>(0))
461 .unwrap(),
462 "committed-in-wal"
463 );
464 }
465}
466
467#[cfg(target_os = "macos")]
468fn try_clone(source: &Path, destination: &Path) -> bool {
469 use std::{ffi::CString, os::unix::ffi::OsStrExt};
470 let (Ok(source), Ok(destination)) = (
471 CString::new(source.as_os_str().as_bytes()),
472 CString::new(destination.as_os_str().as_bytes()),
473 ) else {
474 return false;
475 };
476 unsafe { libc::clonefile(source.as_ptr(), destination.as_ptr(), 0) == 0 }
478}
479
480#[cfg(target_os = "linux")]
481fn try_clone(source: &Path, destination: &Path) -> bool {
482 use std::os::fd::AsRawFd;
483 let (Ok(source), Ok(destination)) = (
484 fs::File::open(source),
485 fs::OpenOptions::new()
486 .write(true)
487 .create_new(true)
488 .open(destination),
489 ) else {
490 return false;
491 };
492 unsafe {
494 libc::ioctl(
495 destination.as_raw_fd(),
496 0x40049409 as libc::c_ulong,
497 source.as_raw_fd(),
498 ) == 0
499 }
500}
501
502#[cfg(not(any(target_os = "linux", target_os = "macos")))]
503fn try_clone(_source: &Path, _destination: &Path) -> bool {
504 false
505}
506
507#[cfg(test)]
508mod ownership_tests {
509 use super::*;
510
511 #[test]
512 fn ownership_reference_waits_for_sweep_pointer_lock() {
513 let storage = tempfile::tempdir().unwrap();
514 let view = ViewStore::open(storage.path(), "ownership-test").unwrap();
515 let mut pointer = view.open_pointer_connection().unwrap();
516 let ownership = pointer
517 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
518 .unwrap();
519 let (started_tx, started_rx) = std::sync::mpsc::channel();
520 let (done_tx, done_rx) = std::sync::mpsc::channel();
521 let worker = std::thread::spawn(move || {
522 started_tx.send(()).unwrap();
523 done_tx.send(view.reuse_derived("fill", "base")).unwrap();
524 });
525 started_rx.recv().unwrap();
526 assert!(
527 matches!(
528 done_rx.recv_timeout(Duration::from_millis(200)),
529 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
530 ),
531 "ownership reference escaped the sweep lock"
532 );
533 drop(ownership);
534 done_rx
535 .recv_timeout(Duration::from_secs(10))
536 .unwrap()
537 .unwrap();
538 worker.join().unwrap();
539 }
540}