Skip to main content

aft/views/
generation.rs

1//! Generation-owned derived artifacts. Unpublished files are protected by the
2//! assembly pin; published files by the pointer and query read markers.
3use 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        // Serialize new ownership references with sweeping across processes. The
39        // caller keeps the base pin until this durable reference is visible.
40        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    /// Remove generation files only after checking both durable publication and
94    /// liveness. A dead assembler can leave a derived file before any manifest.
95    pub fn sweep_generations(&self) -> Result<usize> {
96        // A publisher cannot add a reference after the ownership snapshot and
97        // release its base pin before the sweep checks that pin.
98        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            // Even an obsolete reference protects its owner until the next sweep.
137            // This keeps shared files alive without depending on directory order.
138            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            // Recheck after pin inspection: a publisher keeps its assembly pin
163            // until its committed pointer is visible.
164            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
262/// Run the generation-sized checkpoint after pointer publication. A later
263/// publication cancels a not-yet-started obsolete job; its clone has already
264/// forced the source checkpoint through [`clone_derived`].
265pub(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            // Include the keeper close: it may checkpoint even a superseded job.
309            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(&current.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(&current.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
351/// Checkpoint the source before copying its main file. This also recovers a
352/// commit-durable WAL left by a process that exited before detached maintenance.
353pub(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    // Both C strings remain alive for the duration of clonefile.
477    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    // FICLONE takes the source descriptor as its third argument.
493    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}