Skip to main content

turbo_tasks_backend/database/
db_invalidation.rs

1use std::{
2    borrow::Cow,
3    io::{BufReader, BufWriter, ErrorKind, Write},
4    path::Path,
5};
6
7use anyhow::Context;
8use fs_err::{self as fs, File, read_dir};
9use serde::{Deserialize, Serialize};
10
11const INVALIDATION_MARKER: &str = "__turbo_tasks_invalidated_db";
12
13const EXPLANATION: &str = "The cache database has been invalidated. The existence of this file \
14                           will cause the cache directory to be cleaned up the next time \
15                           Turbopack starts up.";
16const EASTER_EGG: &str =
17    "you just wrote me, and this is crazy, but if you see me, delete everything maybe?";
18
19/// The data written to the file at [`INVALIDATION_MARKER`].
20#[derive(Serialize, Deserialize)]
21struct InvalidationFile<'a> {
22    #[serde(skip_deserializing)]
23    _explanation: Option<&'static str>,
24    #[serde(skip_deserializing)]
25    _easter_egg: Option<&'static str>,
26    /// See [`StartupCacheState::Invalidated::reason_code`].
27    reason_code: Cow<'a, str>,
28}
29
30/// Information about if there's was a pre-existing cache or if the cache was detected as
31/// invalidated during startup.
32///
33/// If the cache was invalidated, the application may choose to show a warning to the user or log it
34/// to telemetry.
35///
36/// This value is returned by [`crate::turbo_backing_storage`].
37pub enum StartupCacheState {
38    NoCache,
39    Cached,
40    Invalidated {
41        /// A short code passed to `TurboBackingStorage::invalidate`. This value is
42        /// application-specific.
43        ///
44        /// If the value is `None` or doesn't match an expected value, the application should just
45        /// treat this reason as unknown. The invalidation file may have been corrupted or
46        /// modified by an external tool.
47        ///
48        /// See [`invalidation_reasons`] for some common reason codes.
49        reason_code: Option<String>,
50    },
51}
52
53/// Common invalidation reason codes. The application or libraries it uses may choose to use these
54/// reasons, or it may define it's own reasons.
55pub mod invalidation_reasons {
56    /// This invalidation reason is used by [`crate::turbo_backing_storage`] when the database was
57    /// invalidated by a panic.
58    pub const PANIC: &str = concat!(module_path!(), "::PANIC");
59    /// Indicates that the user explicitly clicked a button or ran a command that invalidates the
60    /// cache.
61    pub const USER_REQUEST: &str = concat!(module_path!(), "::USER_REQUEST");
62}
63
64/// Atomically create an invalidation marker.
65///
66/// Makes a best-effort attempt to write `reason_code` to the file, but ignores any failure with
67/// writing to the file.
68///
69/// Because attempting to delete currently open database files could cause issues, actual deletion
70/// of files is deferred until the next start-up (in [`check_db_invalidation_and_cleanup`]).
71///
72/// In the case that no database is currently open (e.g. via a separate CLI subcommand), you should
73/// call [`cleanup_db`] *after* this to eagerly remove the database files.
74///
75/// This should be run with the base (non-versioned) path, as that likely aligns closest with user
76/// expectations (e.g. if they're clearing the cache for disk space reasons).
77///
78/// In most cases, you should prefer a higher-level API like `TurboBackingStorage::invalidate`
79/// to this one.
80pub(crate) fn invalidate_db(base_path: &Path, reason_code: &str) -> anyhow::Result<()> {
81    match File::create_new(base_path.join(INVALIDATION_MARKER)) {
82        Ok(file) => {
83            let mut writer = BufWriter::new(file);
84            // ignore errors: We've already successfully invalidated the cache just by creating the
85            // marker file, writing the reason_code is best-effort.
86            let _ = serde_json::to_writer_pretty(
87                &mut writer,
88                &InvalidationFile {
89                    _explanation: Some(EXPLANATION),
90                    _easter_egg: Some(EASTER_EGG),
91                    reason_code: Cow::Borrowed(reason_code),
92                },
93            );
94            let _ = writer.flush();
95            Ok(())
96        }
97        // the database was already invalidated, avoid overwriting that reason or risking concurrent
98        // writes to the same file.
99        Err(err) if err.kind() == ErrorKind::AlreadyExists => Ok(()),
100        // just ignore if the cache directory doesn't exist at all
101        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
102        Err(err) => Err(err).context("Failed to invalidate database"),
103    }
104}
105
106/// Called during startup. See if the db is in a partially-completed invalidation state. Find and
107/// delete any invalidated database files.
108///
109/// This should be run with the base (non-versioned) path.
110///
111/// In most cases, you should prefer a higher-level API like
112/// [`crate::KeyValueDatabaseBackingStorage::open_versioned_on_disk`] to this one.
113pub(crate) fn check_db_invalidation_and_cleanup(
114    base_path: &Path,
115) -> anyhow::Result<StartupCacheState> {
116    match File::open(base_path.join(INVALIDATION_MARKER)) {
117        Ok(file) => {
118            // Best-effort: Try to read the reason_code from the file, if the file format is
119            // corrupted (or anything else) just use `None`.
120            let reason_code = serde_json::from_reader::<_, InvalidationFile>(BufReader::new(file))
121                .ok()
122                .map(|contents| contents.reason_code.into_owned());
123            // `file` is dropped at this point: That's important for Windows where we can't delete
124            // open files.
125
126            // if this cleanup fails, we might try to open an invalid database later, so it's best
127            // to just propagate the error here.
128            cleanup_db(base_path)?;
129            Ok(StartupCacheState::Invalidated { reason_code })
130        }
131        Err(err) if err.kind() == ErrorKind::NotFound => {
132            if fs::exists(base_path)? {
133                Ok(StartupCacheState::Cached)
134            } else {
135                Ok(StartupCacheState::NoCache)
136            }
137        }
138        Err(err) => Err(err)
139            .with_context(|| format!("Failed to check for {INVALIDATION_MARKER} in {base_path:?}")),
140    }
141}
142
143/// Helper for [`check_db_invalidation_and_cleanup`]. You can call this to explicitly clean up a
144/// database after running [`invalidate_db`] when turbo-tasks is not running.
145///
146/// You should not run this if the database has not yet been invalidated, as this operation is not
147/// atomic and could result in a partially-deleted and corrupted database.
148pub(crate) fn cleanup_db(base_path: &Path) -> anyhow::Result<()> {
149    cleanup_db_inner(base_path).with_context(|| {
150        format!(
151            "Unable to remove invalid database. If this issue persists you can work around by \
152             deleting {base_path:?}."
153        )
154    })
155}
156
157fn cleanup_db_inner(base_path: &Path) -> anyhow::Result<()> {
158    let Ok(contents) = read_dir(base_path) else {
159        return Ok(());
160    };
161
162    // delete everything except the invalidation marker
163    for entry in contents {
164        let entry = entry?;
165        let path = entry.path();
166        if entry.file_name() != INVALIDATION_MARKER {
167            if entry.file_type()?.is_dir() {
168                fs::remove_dir_all(&path)?;
169            } else {
170                fs::remove_file(&path)?;
171            }
172        }
173    }
174
175    // delete the invalidation marker last, once we're sure everything is cleaned up
176    fs::remove_file(base_path.join(INVALIDATION_MARKER))?;
177    Ok(())
178}