Skip to main content

turbo_tasks_backend/database/
db_versioning.rs

1use std::{
2    env,
3    ffi::{OsStr, OsString},
4    path::{Path, PathBuf},
5    time::{Duration, SystemTime},
6};
7
8use anyhow::Result;
9use fs_err::{DirEntry, metadata, read_dir, remove_dir_all, rename};
10use jiff::Timestamp;
11use turbo_persistence::read_current_version;
12
13/// Information gathered by `vergen_gitcl` in the top-level binary crate and passed down. This
14/// information must be computed in the top-level crate for cargo incremental compilation to work
15/// correctly.
16///
17/// See `crates/next-napi-bindings/build.rs` for details.
18pub struct GitVersionInfo<'a> {
19    /// Output of `git describe --match 'v[0-9]' --dirty`.
20    pub describe: &'a str,
21    /// Is the git repository dirty? Always forced to `false` when the `CI` environment variable is
22    /// set and non-empty.
23    pub dirty: bool,
24}
25
26/// How many days a database with a version other than the current one is retained since it was
27/// last used. Overridable via the `TURBO_ENGINE_VERSION_TTL_DAYS` environment variable.
28const DEFAULT_OTHER_DB_VERSION_TTL_DAYS: u64 = 3;
29
30/// Directories are prefixed with this before being deleted, so that if we fail to fully delete the
31/// directory, we can pick up where we left off last time.
32const DELETION_PREFIX: &str = "__stale_";
33
34/// Given a base path, creates a version directory for the given `version_info`. Automatically
35/// cleans up old/stale databases.
36///
37/// The current version is always retained. Alongside it, exactly one database whose version isn't
38/// the current one is kept — the most recently used, and only if it was used within
39/// [`DEFAULT_OTHER_DB_VERSION_TTL_DAYS`] — so that switching back to a branch you recently left
40/// still finds its cache intact. On CI none are retained.
41///
42/// **Environment Variables**
43/// - `TURBO_ENGINE_VERSION`: Forces use of a specific database version.
44/// - `TURBO_ENGINE_IGNORE_DIRTY`: Enable filesystem cache in a dirty git repository. Otherwise a
45///   temporary directory is created.
46/// - `TURBO_ENGINE_DISABLE_VERSIONING`: Ignores versioning and always uses the same "unversioned"
47///   database when set.
48/// - `TURBO_ENGINE_VERSION_TTL_DAYS`: How many days to retain a database whose version isn't the
49///   current one, as a whole number. Overrides [`DEFAULT_OTHER_DB_VERSION_TTL_DAYS`].
50pub fn handle_db_versioning(
51    base_path: &Path,
52    version_info: &GitVersionInfo,
53    is_ci: bool,
54) -> Result<PathBuf> {
55    if let Ok(version) = env::var("TURBO_ENGINE_VERSION") {
56        return Ok(base_path.join(version));
57    }
58    let ignore_dirty = env::var("TURBO_ENGINE_IGNORE_DIRTY").ok().is_some();
59    let disabled_versioning = env::var("TURBO_ENGINE_DISABLE_VERSIONING").ok().is_some();
60    let version = if disabled_versioning {
61        println!(
62            "WARNING: File System Cache versioning is disabled. Manual removal of the filesystem \
63             caching database might be required."
64        );
65        Some("unversioned")
66    } else if !version_info.dirty {
67        Some(version_info.describe)
68    } else if ignore_dirty {
69        println!(
70            "WARNING: The git repository is dirty, but File System Cache is still enabled. Manual \
71             removal of the filesystem cache database might be required."
72        );
73        Some(version_info.describe)
74    } else {
75        println!(
76            "WARNING: The git repository is dirty: File System Cache is disabled. Use \
77             TURBO_ENGINE_IGNORE_DIRTY=1 to ignore dirtiness of the repository."
78        );
79        None
80    };
81    let path;
82    if let Some(version) = version {
83        path = base_path.join(version);
84
85        // On CI nothing is ever switched back to, so no other version is worth its disk.
86        let ttl = if is_ci {
87            None
88        } else {
89            Some(other_db_version_ttl())
90        };
91
92        if let Ok(read_dir) = read_dir(base_path) {
93            let evict = |entry: DirEntry| {
94                let mut new_name = OsString::from(DELETION_PREFIX);
95                new_name.push(entry.file_name());
96                let new_path = base_path.join(new_name);
97                // rename first, it's an atomic operation
98                let rename_result = rename(entry.path(), &new_path);
99                // Only try to delete the files if the rename succeeded, it's not safe to delete
100                // contents if we didn't manage to first poison the directory by renaming it.
101                if rename_result.is_ok() {
102                    // It's okay if this fails, as we've already poisoned the directory.
103                    let _ = remove_dir_all(&new_path);
104                }
105            };
106
107            // Of the other versions we keep only the most recently used one, and only if it's
108            // within the TTL.
109            let mut newest: Option<(Duration, DirEntry)> = None;
110            for entry in read_dir {
111                let Ok(entry) = entry else { continue };
112
113                // skip our target version (if it exists)
114                let name = entry.file_name();
115                if name == version {
116                    continue;
117                }
118
119                // skip non-directories
120                let Ok(file_type) = entry.file_type() else {
121                    continue;
122                };
123                if !file_type.is_dir() {
124                    continue;
125                }
126
127                // Find and try to finish removing any partially deleted directories
128                if name
129                    .as_encoded_bytes()
130                    .starts_with(AsRef::<OsStr>::as_ref(DELETION_PREFIX).as_encoded_bytes())
131                {
132                    // failures during cleanup of a cache directory are not fatal
133                    let _ = remove_dir_all(entry.path());
134                    continue;
135                }
136
137                // With no TTL nothing is retained, so don't read an age that can't change the
138                // outcome.
139                let Some(ttl) = ttl else {
140                    evict(entry);
141                    continue;
142                };
143
144                let age = time_since_last_commit(&entry);
145                if age > ttl {
146                    evict(entry);
147                    continue;
148                }
149                match &newest {
150                    Some((newest_age, _)) if *newest_age <= age => evict(entry),
151                    _ => {
152                        if let Some((_, previous)) = newest.replace((age, entry)) {
153                            evict(previous);
154                        }
155                    }
156                }
157            }
158        }
159    } else {
160        path = base_path.join("temp");
161        if path.exists() {
162            // propagate errors: if this fails we may have stale files left over in the temp
163            // directory
164            remove_dir_all(&path)?;
165        }
166    }
167
168    Ok(path)
169}
170
171/// How long to retain a database whose version isn't the current one. Falls back to
172/// [`DEFAULT_OTHER_DB_VERSION_TTL_DAYS`] if `TURBO_ENGINE_VERSION_TTL_DAYS` is unset or unparsable.
173fn other_db_version_ttl() -> Duration {
174    let Ok(raw) = env::var("TURBO_ENGINE_VERSION_TTL_DAYS") else {
175        return ttl_from_days(DEFAULT_OTHER_DB_VERSION_TTL_DAYS);
176    };
177    match raw.trim().parse::<u64>() {
178        Ok(days) => ttl_from_days(days),
179        Err(_) => {
180            eprintln!(
181                "WARNING: Ignoring TURBO_ENGINE_VERSION_TTL_DAYS={raw:?}, expected a whole number \
182                 of days."
183            );
184            ttl_from_days(DEFAULT_OTHER_DB_VERSION_TTL_DAYS)
185        }
186    }
187}
188
189fn ttl_from_days(days: u64) -> Duration {
190    Duration::from_secs(days.saturating_mul(24 * 60 * 60))
191}
192
193/// How long ago the version directory `entry` was last committed to, read from the `commit_time`
194/// its `CURRENT` file records
195///
196/// - If the `CURRENT` file is missing return [`Duration::MAX`] so it's evicted ahead of any real
197///   cache
198/// - If the `CURRENT` file is from a different version that we cannot parse, use the `mtime`
199///     - If the mtime is unreadable, return [`Duration::MAX`] since we assume some kind of disk
200///       corruption
201///
202/// NOTE: this is a rare place where we read `CURRENT` files from different versions of the engine,
203/// so it's the only reader that has to tolerate a format it doesn't understand — hence the mtime
204/// fallback rather than propagating the parse error. We only read `commit_time`, which is stable
205/// across every format that has it.
206fn time_since_last_commit(entry: &DirEntry) -> Duration {
207    let path = entry.path();
208    match read_current_version(&path) {
209        Ok(Some(version)) => Timestamp::now()
210            .duration_since(version.commit_time)
211            .try_into()
212            // if somehow the time is in the future
213            .unwrap_or(Duration::MAX),
214        Ok(None) => Duration::MAX,
215        Err(_) => {
216            // fallback to mtime
217            metadata(path.join("CURRENT"))
218                .and_then(|metadata| metadata.modified())
219                .ok()
220                .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
221                .unwrap_or(Duration::MAX)
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use std::fs;
229
230    use rstest::rstest;
231    use turbo_persistence::CurrentDbVersion;
232
233    use super::*;
234    use crate::utils::test_temp_dir::test_temp_dir;
235
236    const CURRENT_VERSION: &str = "mock-version";
237
238    fn version_info() -> GitVersionInfo<'static> {
239        GitVersionInfo {
240            describe: CURRENT_VERSION,
241            dirty: false,
242        }
243    }
244
245    /// Creates a version directory that looks like a real database (i.e. has a `CURRENT` file),
246    /// last committed to `committed_ago` in the past.
247    fn create_version_dir(base_path: &Path, name: &str, committed_ago: Duration) {
248        let path = base_path.join(name);
249        fs::create_dir(&path).unwrap();
250        let commit_time = Timestamp::now() - jiff::SignedDuration::try_from(committed_ago).unwrap();
251        fs::write(
252            path.join("CURRENT"),
253            serde_json::to_vec(&CurrentDbVersion {
254                max_sequence_number: 0,
255                commit_time,
256            })
257            .unwrap(),
258        )
259        .unwrap();
260    }
261
262    fn entry_names(base_path: &Path) -> Vec<String> {
263        let mut names = fs::read_dir(base_path)
264            .unwrap()
265            .map(|e| e.unwrap().file_name().into_string().unwrap())
266            .collect::<Vec<_>>();
267        names.sort();
268        names
269    }
270
271    /// Only the most recently used other version survives, and the current version survives
272    /// regardless of how stale it is. On CI no other version survives at all.
273    #[rstest]
274    #[case::not_ci(false, &["mock-version", "other-dir-0"])]
275    #[case::ci(true, &["mock-version"])]
276    fn test_only_most_recently_used_other_version_is_retained(
277        #[case] is_ci: bool,
278        #[case] expected: &[&str],
279    ) {
280        let tmp_dir = test_temp_dir().unwrap();
281        let base_path = tmp_dir.path();
282
283        // the least recently used of all, and preserved anyway
284        create_version_dir(base_path, CURRENT_VERSION, Duration::from_secs(60 * 60));
285
286        for i in 0..4 {
287            // `other-dir-0` is the most recently used, so it's the one retained
288            create_version_dir(
289                base_path,
290                &format!("other-dir-{i}"),
291                Duration::from_secs(i + 1),
292            );
293        }
294
295        let versioned_path = handle_db_versioning(base_path, &version_info(), is_ci).unwrap();
296        assert_eq!(versioned_path, base_path.join(CURRENT_VERSION));
297        assert_eq!(entry_names(base_path), expected);
298    }
299
300    /// A version that hasn't been used within the TTL is evicted, even with the retention slot
301    /// free.
302    #[test]
303    fn test_ttl_evicts_unused_version() {
304        let tmp_dir = test_temp_dir().unwrap();
305        let base_path = tmp_dir.path();
306
307        create_version_dir(base_path, CURRENT_VERSION, Duration::ZERO);
308        create_version_dir(
309            base_path,
310            "stale-version",
311            ttl_from_days(DEFAULT_OTHER_DB_VERSION_TTL_DAYS) + Duration::from_secs(60),
312        );
313
314        handle_db_versioning(base_path, &version_info(), /* is_ci */ false).unwrap();
315
316        assert_eq!(entry_names(base_path), vec![CURRENT_VERSION]);
317    }
318
319    /// A directory with no `CURRENT` file isn't one of ours, so it's evicted rather than occupying
320    /// the single retention slot — even when it holds recently written files.
321    #[rstest]
322    #[case::empty(false)]
323    #[case::with_recent_data_file(true)]
324    fn test_version_without_stamp_is_evicted(#[case] with_data_file: bool) {
325        let tmp_dir = test_temp_dir().unwrap();
326        let base_path = tmp_dir.path();
327
328        create_version_dir(base_path, CURRENT_VERSION, Duration::ZERO);
329
330        let unstamped = base_path.join("unstamped-version");
331        fs::create_dir(&unstamped).unwrap();
332        if with_data_file {
333            fs::write(unstamped.join("00000001.sst"), b"data").unwrap();
334        }
335
336        handle_db_versioning(base_path, &version_info(), /* is_ci */ false).unwrap();
337
338        assert_eq!(entry_names(base_path), vec![CURRENT_VERSION]);
339    }
340
341    /// A `CURRENT` we can't parse — most likely the pre-JSON format, a bare big-endian `u32` — is
342    /// aged by its mtime rather than failing the run. A freshly written one is inside the TTL, so
343    /// it takes the retention slot.
344    #[rstest]
345    #[case::old_u32_format(&0u32.to_be_bytes())]
346    #[case::garbage(b"not json")]
347    fn test_unparsable_current_falls_back_to_mtime(#[case] contents: &[u8]) {
348        let tmp_dir = test_temp_dir().unwrap();
349        let base_path = tmp_dir.path();
350
351        create_version_dir(base_path, CURRENT_VERSION, Duration::ZERO);
352        let legacy = base_path.join("legacy-version");
353        fs::create_dir(&legacy).unwrap();
354        fs::write(legacy.join("CURRENT"), contents).unwrap();
355
356        handle_db_versioning(base_path, &version_info(), /* is_ci */ false).unwrap();
357
358        assert_eq!(
359            entry_names(base_path),
360            vec!["legacy-version", CURRENT_VERSION]
361        );
362    }
363
364    #[rstest]
365    #[case::recent(Duration::from_secs(60), &["future-version", "mock-version"])]
366    #[case::past_ttl(
367        ttl_from_days(DEFAULT_OTHER_DB_VERSION_TTL_DAYS) + Duration::from_secs(60),
368        &["mock-version"],
369    )]
370    fn test_current_with_unknown_fields_uses_its_commit_time(
371        #[case] committed_ago: Duration,
372        #[case] expected: &[&str],
373    ) {
374        let tmp_dir = test_temp_dir().unwrap();
375        let base_path = tmp_dir.path();
376
377        create_version_dir(base_path, CURRENT_VERSION, Duration::ZERO);
378
379        // The mtime here is "now", so relying on it instead would retain even the stale case.
380        let future = base_path.join("future-version");
381        fs::create_dir(&future).unwrap();
382        let commit_time = Timestamp::now() - jiff::SignedDuration::try_from(committed_ago).unwrap();
383        fs::write(
384            future.join("CURRENT"),
385            format!(
386                r#"{{"max_sequence_number":0,"commit_time":"{commit_time}","added_later":{{"a":1}}}}"#
387            ),
388        )
389        .unwrap();
390
391        handle_db_versioning(base_path, &version_info(), /* is_ci */ false).unwrap();
392
393        assert_eq!(entry_names(base_path), expected);
394    }
395
396    /// The age of an unparsable `CURRENT` comes from its mtime, so it's a real age that the TTL
397    /// can act on — not [`Duration::MAX`], which would evict it unconditionally.
398    #[test]
399    fn test_unparsable_current_is_aged_by_mtime() {
400        let tmp_dir = test_temp_dir().unwrap();
401        let legacy = tmp_dir.path().join("legacy-version");
402        fs::create_dir(&legacy).unwrap();
403        fs::write(legacy.join("CURRENT"), 0u32.to_be_bytes()).unwrap();
404
405        let age = {
406            let path: &Path = &legacy;
407            metadata(path.join("CURRENT"))
408                .and_then(|metadata| metadata.modified())
409                .ok()
410                .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
411                .unwrap_or(Duration::MAX)
412        };
413        assert!(
414            age < Duration::from_secs(60),
415            "a just-written CURRENT should read as recent, got {age:?}"
416        );
417    }
418
419    /// On CI every other version is evicted regardless of age, so the mtime fallback doesn't buy a
420    /// legacy-format database a reprieve there.
421    #[test]
422    fn test_ci_evicts_unreadable_current() {
423        let tmp_dir = test_temp_dir().unwrap();
424        let base_path = tmp_dir.path();
425
426        create_version_dir(base_path, CURRENT_VERSION, Duration::ZERO);
427        let corrupt = base_path.join("corrupt-version");
428        fs::create_dir(&corrupt).unwrap();
429        fs::write(corrupt.join("CURRENT"), 0u32.to_be_bytes()).unwrap();
430
431        handle_db_versioning(base_path, &version_info(), /* is_ci */ true).unwrap();
432
433        assert_eq!(entry_names(base_path), vec![CURRENT_VERSION]);
434    }
435
436    #[test]
437    fn test_cleanup_of_prefixed_items() {
438        let tmp_dir = test_temp_dir().unwrap();
439        let base_path = tmp_dir.path();
440
441        for i in 0..5 {
442            fs::create_dir(base_path.join(format!("{DELETION_PREFIX}other-dir-{i}"))).unwrap();
443        }
444
445        handle_db_versioning(base_path, &version_info(), /* is_ci */ false).unwrap();
446
447        assert!(entry_names(base_path).is_empty());
448    }
449}