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