1use std::collections::{BTreeMap, BTreeSet};
12use std::fs::{self, File};
13use std::io::{BufRead, Write};
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime};
16
17use anyhow::{Context, Result, bail};
18use fs2::FileExt;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22const IN_FLIGHT: Duration = Duration::from_secs(60 * 60);
24
25#[derive(Debug, Default, PartialEq, Eq)]
26pub struct Pruned {
27 pub size: u64,
29 pub entries: usize,
30 pub blobs: usize,
31 pub freed: u64,
32}
33
34pub fn parse_size(text: &str) -> Result<u64> {
37 let text = text.trim();
38 let (number, unit) = match text.find(|c: char| !(c.is_ascii_digit() || c == '.')) {
39 Some(at) => (&text[..at], text[at..].trim_start()),
40 None => (text, ""),
41 };
42 let factor: u64 = match unit {
43 "" | "B" => 1,
44 "KB" => 1 << 10,
45 "MB" => 1 << 20,
46 "GB" => 1 << 30,
47 _ => bail!("invalid cache size {text:?}: use a number with an optional KB, MB or GB"),
48 };
49 if number.matches('.').count() > 1 || number.is_empty() {
50 bail!("invalid cache size {text:?}");
51 }
52 let number: f64 = number
53 .parse()
54 .with_context(|| format!("invalid cache size {text:?}"))?;
55 Ok((number * factor as f64) as u64)
56}
57
58pub fn max_size(configured: Option<&Value>, cache: &Path) -> Result<Option<u64>> {
62 let configured = match std::env::var("NX_MAX_CACHE_SIZE") {
63 Ok(value) => Some(value),
64 Err(_) => configured.map(|value| match value {
65 Value::String(text) => text.clone(),
66 other => other.to_string(),
67 }),
68 };
69 let limit = match configured {
70 Some(text) => parse_size(&text)?,
71 None => {
72 let existing = cache
73 .ancestors()
74 .find(|path| path.exists())
75 .context("cache path has no existing ancestor")?;
76 fs2::total_space(existing)? / 10
77 }
78 };
79 Ok((limit > 0).then_some(limit))
80}
81
82fn pins(root: &Path) -> PathBuf {
84 root.join("locks").join(".pins")
85}
86
87fn pin_gate(root: &Path) -> Result<File> {
90 let directory = pins(root);
91 fs::create_dir_all(&directory)?;
92 Ok(File::options()
93 .create(true)
94 .truncate(false)
95 .write(true)
96 .open(directory.join(".gate"))?)
97}
98
99#[derive(Debug)]
104pub(crate) struct Pin {
105 lock: Option<File>,
106 path: PathBuf,
107}
108
109pub(crate) fn pin(root: &Path, blobs: &BTreeSet<String>) -> Result<Pin> {
113 let gate = pin_gate(root)?;
114 gate.lock_shared()?;
115 let directory = pins(root);
116 let name = format!(
117 "{}-{}",
118 std::process::id(),
119 &blake3::hash(
120 format!("{:?}{:?}", std::thread::current().id(), SystemTime::now()).as_bytes()
121 )
122 .to_hex()[..16]
123 );
124 let path = directory.join(name);
125 let lock = File::options()
126 .create_new(true)
127 .write(true)
128 .open(path.with_extension("lock"))?;
129 lock.lock_exclusive()?;
130 let pin = Pin {
131 lock: Some(lock),
132 path,
133 };
134 let mut listing = String::new();
135 for blob in blobs {
136 listing.push_str(blob);
137 listing.push('\n');
138 }
139 let mut file = File::create(pin.path.with_extension("blobs"))?;
140 file.write_all(listing.as_bytes())?;
141 drop(gate);
142 Ok(pin)
143}
144
145impl Drop for Pin {
146 fn drop(&mut self) {
147 let _ = fs::remove_file(self.path.with_extension("blobs"));
148 drop(self.lock.take());
150 let _ = fs::remove_file(self.path.with_extension("lock"));
151 }
152}
153
154fn pinned(root: &Path) -> BTreeSet<String> {
156 let mut held = BTreeSet::new();
157 for item in fs::read_dir(pins(root)).into_iter().flatten().flatten() {
158 let path = item.path();
159 if path.extension().is_none_or(|extension| extension != "lock") {
160 continue;
161 }
162 let Ok(lock) = File::open(&path) else {
163 continue;
164 };
165 if lock.try_lock_exclusive().is_ok() {
166 let _ = fs::remove_file(path.with_extension("blobs"));
167 let _ = fs::remove_file(&path);
168 continue;
169 }
170 if let Ok(listing) = File::open(path.with_extension("blobs")) {
171 held.extend(
172 std::io::BufReader::new(listing)
173 .lines()
174 .map_while(Result::ok),
175 );
176 }
177 }
178 held
179}
180
181pub(crate) fn touch(manifest: &Path) {
183 if let Ok(file) = File::options().write(true).open(manifest) {
184 let _ = file.set_modified(SystemTime::now());
185 }
186}
187
188struct Entry {
189 path: std::path::PathBuf,
190 used: SystemTime,
191 size: u64,
192 blobs: BTreeSet<String>,
193}
194
195pub fn prune(root: &Path, limit: u64) -> Result<Pruned> {
202 prune_inner(root, limit, false)
203}
204
205pub fn prune_automatic(root: &Path, limit: u64) -> Result<Pruned> {
210 prune_inner(root, limit, true)
211}
212
213fn prune_inner(root: &Path, limit: u64, automatic: bool) -> Result<Pruned> {
214 if !root.join("entries").is_dir() {
215 return Ok(Pruned::default());
216 }
217 fs::create_dir_all(root.join("locks"))?;
218 let marker = root.join("locks").join(".prune");
219 let recent = fs::metadata(&marker)
220 .ok()
221 .and_then(|metadata| metadata.modified().ok())
222 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
223 .is_some_and(|age| age < IN_FLIGHT);
224 if automatic && recent {
225 let size = cached_size(root)?;
226 if size <= limit {
227 return Ok(Pruned {
228 size,
229 ..Pruned::default()
230 });
231 }
232 }
233 let guard = File::options()
234 .create(true)
235 .truncate(false)
236 .write(true)
237 .open(&marker)?;
238 if guard.try_lock_exclusive().is_err() {
239 return Ok(Pruned::default());
240 }
241 let _ = fs::remove_file(root.join("locks").join(".size"));
244 let _ = guard.set_modified(SystemTime::now());
245 let gate = pin_gate(root)?;
247 gate.lock_exclusive()?;
248 let now = SystemTime::now();
249 let age = |metadata: &fs::Metadata| {
250 metadata
251 .modified()
252 .ok()
253 .and_then(|modified| now.duration_since(modified).ok())
254 .unwrap_or_default()
255 };
256 let mut entries = Vec::new();
257 let mut references: BTreeMap<String, usize> = BTreeMap::new();
258 let warm = fs::read_dir(root.join("warm")).into_iter().flatten();
260 for item in fs::read_dir(root.join("entries"))?.chain(warm) {
261 let item = item?;
262 let metadata = item.metadata()?;
263 let blobs = fs::read(item.path())
265 .ok()
266 .and_then(|bytes| crate::record::StoredRecord::read(&bytes).ok())
267 .map(|record| record.blobs())
268 .unwrap_or_default();
269 for blob in &blobs {
270 *references.entry(blob.clone()).or_default() += 1;
271 }
272 entries.push(Entry {
273 path: item.path(),
274 used: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
275 size: metadata.len(),
276 blobs,
277 });
278 }
279 for blob in pinned(root) {
281 *references.entry(blob).or_default() += 1;
282 }
283 let mut blob_sizes = BTreeMap::new();
284 let mut pruned = Pruned::default();
285 for item in fs::read_dir(root.join("blobs"))? {
286 let item = item?;
287 let metadata = item.metadata()?;
288 let name = item.file_name().to_string_lossy().into_owned();
289 if !references.contains_key(&name) && age(&metadata) > IN_FLIGHT {
290 if fs::remove_file(item.path()).is_ok() {
291 pruned.blobs += 1;
292 pruned.freed += metadata.len();
293 }
294 continue;
295 }
296 blob_sizes.insert(name, metadata.len());
297 }
298 let mut size: u64 =
299 entries.iter().map(|entry| entry.size).sum::<u64>() + blob_sizes.values().sum::<u64>();
300 entries.sort_by_key(|entry| entry.used);
301 for entry in entries {
302 if size <= limit {
303 break;
304 }
305 if fs::remove_file(&entry.path).is_err() {
306 continue;
307 }
308 pruned.entries += 1;
309 pruned.freed += entry.size;
310 size -= entry.size;
311 for blob in entry.blobs {
312 let count = references.get_mut(&blob).expect("counted above");
313 *count -= 1;
314 if *count == 0
315 && let Some(blob_size) = blob_sizes.remove(&blob)
316 && fs::remove_file(root.join("blobs").join(&blob)).is_ok()
317 {
318 pruned.blobs += 1;
319 pruned.freed += blob_size;
320 size -= blob_size;
321 }
322 }
323 }
324 drop(gate);
325 for item in fs::read_dir(root.join("tmp"))
327 .into_iter()
328 .flatten()
329 .flatten()
330 {
331 if item
332 .metadata()
333 .is_ok_and(|metadata| age(&metadata) > IN_FLIGHT)
334 {
335 let _ = fs::remove_dir_all(item.path()).or_else(|_| fs::remove_file(item.path()));
336 }
337 }
338 for item in fs::read_dir(root.join("locks"))?.flatten() {
340 let name = item.file_name().to_string_lossy().into_owned();
341 if name.starts_with('.') || root.join("entries").join(format!("{name}.json")).exists() {
342 continue;
343 }
344 if item
345 .metadata()
346 .is_ok_and(|metadata| age(&metadata) > IN_FLIGHT)
347 && let Ok(file) = File::open(item.path())
348 && file.try_lock_exclusive().is_ok()
349 {
350 let _ = fs::remove_file(item.path());
351 }
352 }
353 pruned.size = if automatic { cached_size(root)? } else { size };
354 Ok(pruned)
355}
356
357const STAMP_SETTLE: Duration = Duration::from_secs(2);
359
360#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
361struct DirectoryStamp {
362 modified: SystemTime,
363 len: u64,
364 #[cfg(unix)]
365 identity: (u64, u64, i64, i64),
366}
367
368#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
369struct SizeMemo {
370 measured: SystemTime,
371 stamps: Vec<Option<DirectoryStamp>>,
372 size: u64,
373}
374
375fn directory_stamps(root: &Path) -> Result<Vec<Option<DirectoryStamp>>> {
376 ["entries", "blobs", "warm"]
377 .iter()
378 .map(|name| {
379 let metadata = match fs::metadata(root.join(name)) {
380 Ok(metadata) => metadata,
381 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
382 Err(error) => return Err(error.into()),
383 };
384 #[cfg(unix)]
385 use std::os::unix::fs::MetadataExt;
386 Ok(Some(DirectoryStamp {
387 modified: metadata.modified()?,
388 len: metadata.len(),
389 #[cfg(unix)]
390 identity: (
391 metadata.dev(),
392 metadata.ino(),
393 metadata.ctime(),
394 metadata.ctime_nsec(),
395 ),
396 }))
397 })
398 .collect()
399}
400
401fn settled(stamps: &[Option<DirectoryStamp>], now: SystemTime) -> bool {
402 stamps.iter().flatten().all(|stamp| {
403 let old_enough = |time| {
404 now.duration_since(time)
405 .is_ok_and(|age| age >= STAMP_SETTLE)
406 };
407 if !old_enough(stamp.modified) {
408 return false;
409 }
410 #[cfg(unix)]
411 {
412 let (_, _, seconds, nanos) = stamp.identity;
413 if seconds < 0 || !(0..1_000_000_000).contains(&nanos) {
414 return false;
415 }
416 let changed = SystemTime::UNIX_EPOCH + Duration::new(seconds as u64, nanos as u32);
417 if !old_enough(changed) {
418 return false;
419 }
420 }
421 true
422 })
423}
424
425fn cached_size(root: &Path) -> Result<u64> {
426 let memo_path = root.join("locks").join(".size");
427 let now = SystemTime::now();
428 let before = directory_stamps(root)?;
429 if settled(&before, now)
430 && let Some(memo) = fs::read(&memo_path)
431 .ok()
432 .and_then(|bytes| serde_json::from_slice::<SizeMemo>(&bytes).ok())
433 && now
434 .duration_since(memo.measured)
435 .is_ok_and(|age| age < IN_FLIGHT)
436 && memo.stamps == before
437 {
438 return Ok(memo.size);
439 }
440 let size = ["entries", "blobs", "warm"]
441 .iter()
442 .zip(&before)
443 .filter(|(_, stamp)| stamp.is_some())
444 .try_fold(0u64, |total, (name, _)| {
445 directory_size(&root.join(name)).map(|size| total.saturating_add(size))
446 })?;
447 let after = directory_stamps(root)?;
448 if before == after && settled(&after, now) {
449 let memo = SizeMemo {
450 measured: now,
451 stamps: after,
452 size,
453 };
454 let mut temporary = tempfile::NamedTempFile::new_in(root.join("locks"))?;
457 serde_json::to_writer(temporary.as_file_mut(), &memo)?;
458 temporary.persist(&memo_path).map_err(|error| error.error)?;
459 } else {
460 let _ = fs::remove_file(memo_path);
461 }
462 Ok(size)
463}
464
465fn directory_size(directory: &Path) -> Result<u64> {
466 let mut size = 0;
467 for item in fs::read_dir(directory)? {
468 size += item?.metadata()?.len();
469 }
470 Ok(size)
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn fixture() -> tempfile::TempDir {
478 let root = tempfile::tempdir().unwrap();
479 for name in ["entries", "blobs", "locks"] {
480 fs::create_dir(root.path().join(name)).unwrap();
481 }
482 fs::write(root.path().join("entries/a.json"), b"bad record").unwrap();
483 fs::write(root.path().join("blobs/a"), [0; 100]).unwrap();
484 fs::write(root.path().join("locks/.prune"), []).unwrap();
485 root
486 }
487
488 fn cited() -> (tempfile::TempDir, String) {
490 let root = fixture();
491 let blob = "b".repeat(64);
492 fs::write(root.path().join("blobs").join(&blob), [0; 100]).unwrap();
493 fs::write(
494 root.path().join("entries/a.json"),
495 serde_json::to_vec(&serde_json::json!({"log": blob})).unwrap(),
496 )
497 .unwrap();
498 let old = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
499 for path in [format!("blobs/{blob}"), "entries/a.json".into()] {
500 File::options()
501 .write(true)
502 .open(root.path().join(path))
503 .unwrap()
504 .set_modified(old)
505 .unwrap();
506 }
507 (root, blob)
508 }
509
510 fn prune_away(root: &Path, limit: u64, blob: &str) {
514 let deadline = std::time::Instant::now() + Duration::from_secs(5);
515 while root.join("blobs").join(blob).exists() && std::time::Instant::now() < deadline {
516 prune(root, limit).unwrap();
517 std::thread::sleep(Duration::from_millis(10));
518 }
519 assert!(!root.join("blobs").join(blob).exists());
520 }
521
522 #[test]
523 fn a_pinned_blob_survives_evicting_the_last_entry_that_cites_it() {
524 let (root, blob) = cited();
525 let root = root.path();
526 let pin = pin(root, &BTreeSet::from([blob.clone()])).unwrap();
527 assert_eq!(prune(root, 0).unwrap().entries, 1);
528 assert!(root.join("blobs").join(&blob).exists());
529 drop(pin);
530 prune_away(root, 0, &blob);
531 }
532
533 #[test]
534 fn a_pinned_old_blob_no_entry_cites_survives() {
535 let (root, blob) = cited();
536 let root = root.path();
537 fs::remove_file(root.join("entries/a.json")).unwrap();
538 let pin = pin(root, &BTreeSet::from([blob.clone()])).unwrap();
539 prune(root, 1 << 30).unwrap();
540 assert!(root.join("blobs").join(&blob).exists());
541 drop(pin);
542 assert!(
543 fs::read_dir(pins(root))
544 .unwrap()
545 .all(|item| { item.unwrap().file_name() == ".gate" })
546 );
547 prune_away(root, 1 << 30, &blob);
548 }
549
550 #[test]
551 fn an_abandoned_pin_neither_keeps_blobs_nor_stays() {
552 let (root, blob) = cited();
553 let root = root.path();
554 fs::create_dir_all(pins(root)).unwrap();
555 fs::write(pins(root).join("gone.lock"), []).unwrap();
556 fs::write(pins(root).join("gone.blobs"), format!("{blob}\n")).unwrap();
557 prune(root, 0).unwrap();
558 assert!(!root.join("blobs").join(&blob).exists());
559 assert!(!pins(root).join("gone.lock").exists());
560 assert!(!pins(root).join("gone.blobs").exists());
561 }
562
563 #[test]
564 fn a_pin_waits_for_a_prune_that_is_deleting() {
565 let (root, blob) = cited();
566 let root = root.path().to_owned();
567 let gate = pin_gate(&root).unwrap();
568 gate.lock_exclusive().unwrap();
569 let (sender, receiver) = std::sync::mpsc::channel();
570 let pinning = std::thread::spawn({
571 let root = root.clone();
572 move || {
573 let pin = pin(&root, &BTreeSet::from([blob])).unwrap();
574 sender.send(()).unwrap();
575 pin
576 }
577 });
578 assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
579 drop(gate);
580 receiver.recv_timeout(Duration::from_secs(5)).unwrap();
581 drop(pinning.join().unwrap());
582 }
583
584 #[test]
585 fn automatic_size_memo_reuses_and_reconciles() {
586 let root = fixture();
587 let root = root.path();
588 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 110);
590 assert!(!root.join("locks/.size").exists());
591 std::thread::sleep(STAMP_SETTLE + Duration::from_millis(100));
592 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 110);
593 assert!(root.join("locks/.size").exists());
594
595 fs::write(root.join("blobs/a"), [0; 200]).unwrap();
598 touch(&root.join("entries/a.json"));
599 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 110);
600 assert_eq!(prune(root, 1_000).unwrap().size, 210);
601 assert!(!root.join("locks/.size").exists());
602
603 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 210);
604 fs::write(root.join("locks/.size"), b"corrupt").unwrap();
605 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 210);
606 let mut memo: SizeMemo =
607 serde_json::from_slice(&fs::read(root.join("locks/.size")).unwrap()).unwrap();
608 memo.measured = SystemTime::UNIX_EPOCH;
609 memo.size = 0;
610 fs::write(root.join("locks/.size"), serde_json::to_vec(&memo).unwrap()).unwrap();
611 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 210);
612
613 let temp = root.join("entries/new.tmp");
615 fs::write(&temp, [0; 30]).unwrap();
616 fs::rename(temp, root.join("entries/new.json")).unwrap();
617 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 240);
618 fs::create_dir(root.join("warm")).unwrap();
619 fs::write(root.join("warm/a.json"), [0; 40]).unwrap();
620 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 280);
621 std::thread::sleep(STAMP_SETTLE + Duration::from_millis(100));
622 assert_eq!(prune_automatic(root, 1_000).unwrap().size, 280);
623 assert!(root.join("locks/.size").exists());
624 let result = prune_automatic(root, 250).unwrap();
626 assert!(result.entries > 0);
627 assert!(result.size <= 250);
628 }
629
630 #[test]
631 fn old_prune_marker_forces_full_reconciliation() {
632 let root = fixture();
633 let root = root.path();
634 let marker = File::options()
635 .write(true)
636 .open(root.join("locks/.prune"))
637 .unwrap();
638 marker.set_modified(SystemTime::UNIX_EPOCH).unwrap();
639 let result = prune_automatic(root, 100).unwrap();
640 assert_eq!(result.entries, 1);
641 assert_eq!(result.size, 100);
642 }
643
644 #[test]
645 fn parses_sizes_like_nx() {
646 assert_eq!(parse_size("1024").unwrap(), 1024);
647 assert_eq!(parse_size("1KB").unwrap(), 1024);
648 assert_eq!(parse_size("1.5 MB").unwrap(), 1_572_864);
649 assert_eq!(parse_size("2GB").unwrap(), 2 << 30);
650 assert_eq!(parse_size("0").unwrap(), 0);
651 for bad in ["", "1TB", "1.2.3MB", "MB", "-1"] {
652 assert!(parse_size(bad).is_err(), "{bad}");
653 }
654 }
655}