turbo_tasks_backend/database/
db_versioning.rs1use 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
13pub struct GitVersionInfo<'a> {
19 pub describe: &'a str,
21 pub dirty: bool,
24}
25
26const DEFAULT_OTHER_DB_VERSION_TTL_DAYS: u64 = 3;
29
30const DELETION_PREFIX: &str = "__stale_";
33
34pub 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 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 let rename_result = rename(entry.path(), &new_path);
99 if rename_result.is_ok() {
102 let _ = remove_dir_all(&new_path);
104 }
105 };
106
107 let mut newest: Option<(Duration, DirEntry)> = None;
110 for entry in read_dir {
111 let Ok(entry) = entry else { continue };
112
113 let name = entry.file_name();
115 if name == version {
116 continue;
117 }
118
119 let Ok(file_type) = entry.file_type() else {
121 continue;
122 };
123 if !file_type.is_dir() {
124 continue;
125 }
126
127 if name
129 .as_encoded_bytes()
130 .starts_with(AsRef::<OsStr>::as_ref(DELETION_PREFIX).as_encoded_bytes())
131 {
132 let _ = remove_dir_all(entry.path());
134 continue;
135 }
136
137 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 remove_dir_all(&path)?;
165 }
166 }
167
168 Ok(path)
169}
170
171fn 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
193fn 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 .unwrap_or(Duration::MAX),
214 Ok(None) => Duration::MAX,
215 Err(_) => {
216 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 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 #[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 create_version_dir(base_path, CURRENT_VERSION, Duration::from_secs(60 * 60));
285
286 for i in 0..4 {
287 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 #[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(), false).unwrap();
315
316 assert_eq!(entry_names(base_path), vec![CURRENT_VERSION]);
317 }
318
319 #[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(), false).unwrap();
337
338 assert_eq!(entry_names(base_path), vec![CURRENT_VERSION]);
339 }
340
341 #[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(), 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 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(), false).unwrap();
392
393 assert_eq!(entry_names(base_path), expected);
394 }
395
396 #[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 #[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(), 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(), false).unwrap();
446
447 assert!(entry_names(base_path).is_empty());
448 }
449}