1use std::collections::{
2 BTreeMap,
3 HashMap,
4 HashSet,
5};
6use std::fs::{
7 self,
8 OpenOptions,
9};
10use std::io;
11use std::path::{
12 Path,
13 PathBuf,
14};
15use std::sync::atomic::{
16 AtomicBool,
17 AtomicU32,
18 Ordering,
19};
20use std::sync::{
21 Arc,
22 LazyLock,
23 Mutex,
24 MutexGuard,
25 PoisonError,
26 Weak,
27};
28use std::time::{
29 Duration,
30 SystemTime,
31};
32
33use chrono::{
34 DateTime,
35 Utc,
36};
37use serde::{
38 Deserialize,
39 Serialize,
40};
41use sha2::{
42 Digest,
43 Sha256,
44};
45
46use super::checkpoint::{
47 Base,
48 Checkpoint,
49};
50use super::document::{
51 BlockCache,
52 DocError,
53 Document,
54};
55use super::ingest::META_FILE;
56use super::{
57 CACHE_MAX_IDLE,
58 DOC_CACHE_CAPACITY,
59 TOUCH_INTERVAL,
60};
61use crate::domain::{
62 DomainError,
63 DomainResult,
64 LogCacheStats,
65 LogDocumentMeta,
66 LogSection,
67 LogStyle,
68 PipelineStatus,
69 RunLogTree,
70};
71
72const LAYOUT_DIR: &str = "v1";
73const TMP_DIR: &str = "tmp";
74const DOCS_DIR: &str = "docs";
75const TOUCH_FILE: &str = ".touch";
76const TREE_FILE: &str = "tree.json";
77const TREE_TMP_FILE: &str = "tree.json.tmp";
78pub(super) const STORED_FORMAT: u32 = 4;
79
80type RunId = (i64, String);
81type TrimScope = (HashSet<RunId>, Option<(RunId, String)>);
82
83#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub struct DocKey {
85 pub provider_id: i64,
86 pub pipeline_id: String,
87 pub run_number: i64,
88 pub node_id: String,
89}
90
91pub(super) fn short_hex(digest: &[u8]) -> String {
92 let mut head = [0u8; 16];
93 head.copy_from_slice(&digest[..16]);
94 format!("{:032x}", u128::from_be_bytes(head))
95}
96
97fn hex_key(input: &str) -> String {
98 short_hex(&Sha256::digest(input.as_bytes()))
99}
100
101fn run_key_of(pipeline_id: &str, run_number: i64) -> String {
102 hex_key(&format!("{pipeline_id}\n{run_number}"))
103}
104
105impl DocKey {
106 pub fn run_key(&self) -> String {
107 run_key_of(&self.pipeline_id, self.run_number)
108 }
109
110 pub fn run_id(&self) -> (i64, String) {
111 (self.provider_id, self.run_key())
112 }
113
114 fn doc_key(&self) -> String {
115 hex_key(&self.node_id)
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct StoredDocMeta {
121 pub format: u32,
122 pub key: DocKey,
123 pub revision: i64,
124 pub complete: bool,
125 pub truncated: bool,
126 pub line_count: u32,
127 pub text_bytes: u64,
128 pub disk_bytes: u64,
129 pub first_timestamp_ms: Option<i64>,
130 pub styles: Vec<LogStyle>,
131 pub sections: Vec<LogSection>,
132 pub error_lines: Vec<u32>,
133 pub warning_lines: Vec<u32>,
134 pub error_total: u32,
135 pub warning_total: u32,
136 pub focus_line: Option<u32>,
137 pub updated_at_ms: i64,
138 pub stable_lines: u32,
139 pub previous_revision: Option<i64>,
140}
141
142impl StoredDocMeta {
143 pub fn to_meta(&self) -> LogDocumentMeta {
144 LogDocumentMeta {
145 node_id: self.key.node_id.clone(),
146 revision: self.revision,
147 complete: self.complete,
148 truncated: self.truncated,
149 line_count: self.line_count,
150 text_bytes: self.text_bytes,
151 first_timestamp_ms: self.first_timestamp_ms,
152 styles: self.styles.clone(),
153 sections: self.sections.clone(),
154 error_lines: self.error_lines.clone(),
155 warning_lines: self.warning_lines.clone(),
156 error_total: self.error_total,
157 warning_total: self.warning_total,
158 focus_line: self.focus_line,
159 updated_at_ms: self.updated_at_ms,
160 stable_lines: self.stable_lines,
161 previous_revision: self.previous_revision,
162 }
163 }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct StoredTree {
168 pub run_status: PipelineStatus,
169 pub run_concluded_at: Option<DateTime<Utc>>,
170 pub tree: RunLogTree,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
174pub struct CacheEpoch {
175 pub global: u64,
176 pub provider: u64,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum PublishOutcome {
181 Published,
182 StaleEpoch,
183 StaleBase,
184}
185
186struct RevisionState {
187 dir: PathBuf,
188 bytes: u64,
189 readers: AtomicU32,
190 doomed: AtomicBool,
191}
192
193pub struct ReadLease {
194 state: Arc<RevisionState>,
195 deletes: Arc<Mutex<Vec<PathBuf>>>,
196}
197
198impl Drop for ReadLease {
199 fn drop(&mut self) {
200 if self.state.readers.fetch_sub(1, Ordering::SeqCst) == 1
201 && self.state.doomed.load(Ordering::SeqCst)
202 {
203 lock(&self.deletes).push(self.state.dir.clone());
204 }
205 }
206}
207
208struct DocEntry {
209 current: Option<i64>,
210 revisions: BTreeMap<i64, Arc<RevisionState>>,
211 last_access: SystemTime,
212}
213
214impl DocEntry {
215 fn new(last_access: SystemTime) -> Self {
216 Self {
217 current: None,
218 revisions: BTreeMap::new(),
219 last_access,
220 }
221 }
222
223 fn bytes(&self) -> u64 {
224 self.revisions.values().map(|r| r.bytes).sum()
225 }
226
227 fn leased(&self) -> bool {
228 self.revisions
229 .values()
230 .any(|r| r.readers.load(Ordering::SeqCst) > 0)
231 }
232}
233
234struct RunEntry {
235 dir: PathBuf,
236 tree_bytes: u64,
237 last_access: SystemTime,
238 last_touch: SystemTime,
239 docs: HashMap<String, DocEntry>,
240}
241
242impl RunEntry {
243 fn bytes(&self) -> u64 {
244 self.tree_bytes
245 + self
246 .docs
247 .values()
248 .flat_map(|d| d.revisions.values())
249 .map(|r| r.bytes)
250 .sum::<u64>()
251 }
252
253 fn leased(&self) -> bool {
254 self.docs
255 .values()
256 .flat_map(|d| d.revisions.values())
257 .any(|r| r.readers.load(Ordering::SeqCst) > 0)
258 }
259}
260
261#[derive(Default)]
262struct Registry {
263 global_epoch: u64,
264 provider_epochs: HashMap<i64, u64>,
265 last_revision: i64,
266 runs: HashMap<RunId, RunEntry>,
267 retired: Vec<Arc<RevisionState>>,
268 last_idle_sweep: Option<SystemTime>,
269}
270
271impl Registry {
272 fn epoch(&self, provider_id: i64) -> CacheEpoch {
273 CacheEpoch {
274 global: self.global_epoch,
275 provider: self.provider_epochs.get(&provider_id).copied().unwrap_or(0),
276 }
277 }
278
279 fn total_bytes(&self) -> u64 {
280 self.runs.values().map(RunEntry::bytes).sum::<u64>()
281 + self.retired.iter().map(|r| r.bytes).sum::<u64>()
282 }
283}
284
285fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
286 mutex.lock().unwrap_or_else(PoisonError::into_inner)
287}
288
289fn retire(
290 retired: &mut Vec<Arc<RevisionState>>, deletes: &Mutex<Vec<PathBuf>>, state: Arc<RevisionState>,
291) {
292 state.doomed.store(true, Ordering::SeqCst);
293 if state.readers.load(Ordering::SeqCst) == 0 {
294 lock(deletes).push(state.dir.clone());
295 }
296 retired.push(state);
297}
298
299fn remove_file_quiet(path: &Path) {
300 match fs::remove_file(path) {
301 Ok(()) => {}
302 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
303 Err(e) => tracing::warn!("Couldn't remove cached log file: {e}"),
304 }
305}
306
307fn remove_run(reg: &mut Registry, deletes: &Mutex<Vec<PathBuf>>, id: &RunId) -> Vec<PathBuf> {
308 let Some(run) = reg.runs.remove(id) else {
309 return Vec::new();
310 };
311 let mut dirs = Vec::new();
312 for doc in run.docs.into_values() {
313 for state in doc.revisions.into_values() {
314 dirs.push(state.dir.clone());
315 retire(&mut reg.retired, deletes, state);
316 }
317 }
318 remove_file_quiet(&run.dir.join(TREE_FILE));
319 remove_file_quiet(&run.dir.join(TOUCH_FILE));
320 dirs
321}
322
323fn remove_doc(
324 reg: &mut Registry, deletes: &Mutex<Vec<PathBuf>>, id: &RunId, doc_key: &str,
325) -> Vec<PathBuf> {
326 let Some(run) = reg.runs.get_mut(id) else {
327 return Vec::new();
328 };
329 let Some(doc) = run.docs.remove(doc_key) else {
330 return Vec::new();
331 };
332 let mut dirs = Vec::new();
333 for state in doc.revisions.into_values() {
334 dirs.push(state.dir.clone());
335 retire(&mut reg.retired, deletes, state);
336 }
337 if run.docs.is_empty() && run.tree_bytes == 0 {
338 dirs.extend(remove_run(reg, deletes, id));
339 }
340 dirs
341}
342
343fn dir_size(dir: &Path) -> u64 {
344 fs::read_dir(dir)
345 .map(|entries| {
346 entries
347 .flatten()
348 .filter_map(|e| e.metadata().ok())
349 .filter(|m| m.is_file())
350 .map(|m| m.len())
351 .sum()
352 })
353 .unwrap_or(0)
354}
355
356fn io_error(context: &str, error: &io::Error) -> DomainError {
357 DomainError::InternalError(format!("{context}: {error}"))
358}
359
360fn join_error(error: tokio::task::JoinError) -> DomainError {
361 DomainError::InternalError(format!("Log cache task failed: {error}"))
362}
363
364fn touch_file(path: &Path) -> io::Result<()> {
365 OpenOptions::new()
366 .write(true)
367 .create(true)
368 .truncate(false)
369 .open(path)?
370 .set_modified(SystemTime::now())
371}
372
373static STORES: LazyLock<tokio::sync::Mutex<HashMap<PathBuf, Weak<LogStore>>>> =
374 LazyLock::new(Default::default);
375
376pub struct LogStore {
377 root: PathBuf,
378 budget: u64,
379 touch_interval: Duration,
380 registry: Mutex<Registry>,
381 deletes: Arc<Mutex<Vec<PathBuf>>>,
382 documents: Mutex<Vec<(PathBuf, Arc<Document>)>>,
383 blocks: BlockCache,
384}
385
386impl LogStore {
387 pub async fn open(root: PathBuf, budget: u64) -> DomainResult<Arc<LogStore>> {
388 Self::open_with(root, budget, TOUCH_INTERVAL).await
389 }
390
391 async fn open_with(
392 root: PathBuf, budget: u64, touch_interval: Duration,
393 ) -> DomainResult<Arc<LogStore>> {
394 let open_error = |e: io::Error| io_error("Couldn't open the log cache", &e);
395 let root = tokio::task::spawn_blocking(move || {
396 fs::create_dir_all(&root)?;
397 fs::canonicalize(&root)
398 })
399 .await
400 .map_err(join_error)?
401 .map_err(open_error)?;
402
403 let mut stores = STORES.lock().await;
404 if let Some(store) = stores.get(&root).and_then(Weak::upgrade) {
405 return Ok(store);
406 }
407 let scan_root = root.clone();
408 let registry = tokio::task::spawn_blocking(move || scan(&scan_root, SystemTime::now()))
409 .await
410 .map_err(join_error)?
411 .map_err(open_error)?;
412 let store = Arc::new(LogStore {
413 root: root.clone(),
414 budget,
415 touch_interval,
416 registry: Mutex::new(registry),
417 deletes: Arc::default(),
418 documents: Mutex::new(Vec::new()),
419 blocks: BlockCache::default(),
420 });
421 stores.retain(|_, weak| weak.strong_count() > 0);
422 stores.insert(root, Arc::downgrade(&store));
423 Ok(store)
424 }
425
426 fn base_dir(&self) -> PathBuf {
427 self.root.join(LAYOUT_DIR)
428 }
429
430 fn tmp_root(&self) -> PathBuf {
431 self.base_dir().join(TMP_DIR)
432 }
433
434 fn run_dir(&self, provider_id: i64, run_key: &str) -> PathBuf {
435 self.base_dir().join(provider_id.to_string()).join(run_key)
436 }
437
438 pub fn block_cache(&self) -> &BlockCache {
439 &self.blocks
440 }
441
442 pub fn epoch(&self, provider_id: i64) -> CacheEpoch {
443 lock(&self.registry).epoch(provider_id)
444 }
445
446 pub fn allocate_revision(&self) -> i64 {
447 self.allocate_at(Utc::now().timestamp_millis())
448 }
449
450 fn allocate_at(&self, now_ms: i64) -> i64 {
451 let mut reg = lock(&self.registry);
452 let revision = now_ms.max(reg.last_revision + 1);
453 reg.last_revision = revision;
454 revision
455 }
456
457 pub async fn tmp_dir(&self) -> DomainResult<PathBuf> {
458 let dir = self
459 .tmp_root()
460 .join(format!("{:032x}", rand::random::<u128>()));
461 tokio::task::spawn_blocking(move || fs::create_dir_all(&dir).map(|()| dir))
462 .await
463 .map_err(join_error)?
464 .map_err(|e| io_error("Couldn't prepare the log download", &e))
465 }
466
467 pub async fn discard(&self, tmp: PathBuf) {
468 if !tmp.starts_with(self.tmp_root()) {
469 return;
470 }
471 let removed = tokio::task::spawn_blocking(move || match fs::remove_dir_all(&tmp) {
472 Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
473 _ => Ok(()),
474 })
475 .await;
476 match removed {
477 Ok(Ok(())) => {}
478 Ok(Err(e)) => tracing::warn!("Couldn't remove a temporary log download: {e}"),
479 Err(e) => tracing::warn!("Couldn't remove a temporary log download: {e}"),
480 }
481 }
482
483 pub fn current(&self, key: &DocKey) -> Option<i64> {
484 lock(&self.registry)
485 .runs
486 .get(&key.run_id())
487 .and_then(|run| run.docs.get(&key.doc_key()))
488 .and_then(|doc| doc.current)
489 }
490
491 pub async fn lease(
492 self: &Arc<Self>, key: &DocKey, revision: i64,
493 ) -> DomainResult<Option<(Arc<Document>, ReadLease)>> {
494 let store = self.clone();
495 let key = key.clone();
496 tokio::task::spawn_blocking(move || store.lease_blocking(&key, revision))
497 .await
498 .map_err(join_error)?
499 }
500
501 fn lease_blocking(
502 &self, key: &DocKey, revision: i64,
503 ) -> DomainResult<Option<(Arc<Document>, ReadLease)>> {
504 let now = SystemTime::now();
505 let (lease, touch) = {
506 let mut reg = lock(&self.registry);
507 let Some(run) = reg.runs.get_mut(&key.run_id()) else {
508 return Ok(None);
509 };
510 let Some(doc) = run.docs.get_mut(&key.doc_key()) else {
511 return Ok(None);
512 };
513 let Some(state) = doc
514 .revisions
515 .get(&revision)
516 .filter(|state| !state.doomed.load(Ordering::SeqCst))
517 .cloned()
518 else {
519 return Ok(None);
520 };
521 doc.last_access = now;
522 state.readers.fetch_add(1, Ordering::SeqCst);
523 let lease = ReadLease {
524 state,
525 deletes: self.deletes.clone(),
526 };
527 run.last_access = now;
528 let stale =
529 now.duration_since(run.last_touch).unwrap_or_default() >= self.touch_interval;
530 let touch = stale.then(|| {
531 run.last_touch = now;
532 run.dir.join(TOUCH_FILE)
533 });
534 (lease, touch)
535 };
536
537 let document = match self.load_document(&lease.state.dir) {
538 Ok(document) => document,
539 Err(error @ (DocError::Corrupt | DocError::Io(_)))
540 if matches!(error, DocError::Corrupt) || error.is_missing() =>
541 {
542 tracing::warn!("Dropping an unreadable cached log: {error}");
543 drop(lease);
544 self.mark_corrupt(key, revision);
545 return Ok(None);
546 }
547 Err(error) => {
548 return Err(DomainError::InternalError(format!(
549 "Couldn't read the log: {error}"
550 )))
551 }
552 };
553 if let Some(path) = touch {
554 if let Err(e) = touch_file(&path) {
555 tracing::warn!("Couldn't update the log cache access time: {e}");
556 }
557 }
558 Ok(Some((document, lease)))
559 }
560
561 fn load_document(&self, dir: &Path) -> Result<Arc<Document>, DocError> {
562 {
563 let mut documents = lock(&self.documents);
564 if let Some(position) = documents.iter().position(|(path, _)| path == dir) {
565 let entry = documents.remove(position);
566 let document = entry.1.clone();
567 documents.push(entry);
568 return Ok(document);
569 }
570 }
571 let document = Arc::new(Document::open(dir)?);
572 let mut documents = lock(&self.documents);
573 if !documents.iter().any(|(path, _)| path == dir) {
574 if documents.len() >= DOC_CACHE_CAPACITY {
575 documents.remove(0);
576 }
577 documents.push((dir.to_path_buf(), document.clone()));
578 }
579 Ok(document)
580 }
581
582 fn forget(&self, dirs: &[PathBuf]) {
583 if dirs.is_empty() {
584 return;
585 }
586 lock(&self.documents).retain(|(path, _)| !dirs.contains(path));
587 for dir in dirs {
588 self.blocks.forget(dir);
589 }
590 }
591
592 pub fn mark_corrupt(&self, key: &DocKey, revision: i64) {
593 let doomed = {
594 let mut reg = lock(&self.registry);
595 let state = reg
596 .runs
597 .get_mut(&key.run_id())
598 .and_then(|run| run.docs.get_mut(&key.doc_key()))
599 .and_then(|doc| {
600 if doc.current == Some(revision) {
601 doc.current = None;
602 }
603 doc.revisions.remove(&revision)
604 });
605 state.map(|state| {
606 let dir = state.dir.clone();
607 retire(&mut reg.retired, &self.deletes, state);
608 dir
609 })
610 };
611 if let Some(dir) = doomed {
612 self.forget(&[dir]);
613 }
614 }
615
616 pub async fn base(self: &Arc<Self>, key: &DocKey) -> DomainResult<Option<Base>> {
617 let Some(revision) = self.current(key) else {
618 return Ok(None);
619 };
620 let Some((document, lease)) = self.lease(key, revision).await? else {
621 return Ok(None);
622 };
623 let loaded = Arc::clone(&document);
624 let checkpoint = tokio::task::spawn_blocking(move || Checkpoint::load(&loaded))
625 .await
626 .map_err(join_error)?;
627
628 Ok(Some(Base {
629 revision,
630 document,
631 _lease: lease,
632 checkpoint,
633 }))
634 }
635
636 pub async fn publish(
637 self: &Arc<Self>, key: &DocKey, tmp: &Path, revision: i64, epoch: CacheEpoch,
638 expected_current: Option<i64>, busy: HashSet<(i64, String)>,
639 ) -> DomainResult<PublishOutcome> {
640 if let Err(e) = self.sweep().await {
641 tracing::warn!("{e}");
642 }
643 let store = self.clone();
644 let (key, tmp) = (key.clone(), tmp.to_path_buf());
645 let (outcome, dirs) = tokio::task::spawn_blocking(move || {
646 store.publish_blocking(&key, &tmp, revision, epoch, expected_current, &busy)
647 })
648 .await
649 .map_err(join_error)??;
650 self.forget(&dirs);
651 Ok(outcome)
652 }
653
654 fn publish_blocking(
655 &self, key: &DocKey, tmp: &Path, revision: i64, epoch: CacheEpoch,
656 expected_current: Option<i64>, busy: &HashSet<RunId>,
657 ) -> DomainResult<(PublishOutcome, Vec<PathBuf>)> {
658 let save_error = |e: io::Error| io_error("Couldn't save the log", &e);
659 let mut guard = lock(&self.registry);
660 let reg = &mut *guard;
661 if reg.epoch(key.provider_id) != epoch {
662 return Ok((PublishOutcome::StaleEpoch, Vec::new()));
663 }
664 let current = reg
665 .runs
666 .get(&key.run_id())
667 .and_then(|run| run.docs.get(&key.doc_key()))
668 .and_then(|doc| doc.current);
669 if current != expected_current {
670 return Ok((PublishOutcome::StaleBase, Vec::new()));
671 }
672 let run_id = key.run_id();
673 let doc_key = key.doc_key();
674 let run_dir = self.run_dir(key.provider_id, &run_id.1);
675 let doc_dir = run_dir.join(DOCS_DIR).join(&doc_key);
676 let target = doc_dir.join(revision.to_string());
677 fs::create_dir_all(&doc_dir).map_err(save_error)?;
678 OpenOptions::new()
679 .write(true)
680 .create(true)
681 .truncate(false)
682 .open(run_dir.join(TOUCH_FILE))
683 .map_err(save_error)?;
684 if target.exists() {
685 return Err(save_error(io::Error::from(io::ErrorKind::AlreadyExists)));
686 }
687 fs::rename(tmp, &target).map_err(save_error)?;
688
689 let now = SystemTime::now();
690 let state = Arc::new(RevisionState {
691 bytes: dir_size(&target),
692 dir: target,
693 readers: AtomicU32::new(0),
694 doomed: AtomicBool::new(false),
695 });
696 let run = reg.runs.entry(run_id.clone()).or_insert_with(|| RunEntry {
697 dir: run_dir,
698 tree_bytes: 0,
699 last_access: now,
700 last_touch: now,
701 docs: HashMap::new(),
702 });
703 run.last_access = now;
704 let doc = run
705 .docs
706 .entry(doc_key.clone())
707 .or_insert_with(|| DocEntry::new(now));
708 doc.last_access = now;
709 let previous = doc
710 .current
711 .take()
712 .and_then(|current| doc.revisions.remove(¤t));
713 doc.revisions.insert(revision, state);
714 doc.current = Some(revision);
715 let mut dirs = Vec::new();
716 if let Some(previous) = previous {
717 dirs.push(previous.dir.clone());
718 retire(&mut reg.retired, &self.deletes, previous);
719 }
720 reg.last_revision = reg.last_revision.max(revision);
721 dirs.extend(self.evict(reg, busy, Some((&run_id, &doc_key))));
722 Ok((PublishOutcome::Published, dirs))
723 }
724
725 fn evict(
726 &self, reg: &mut Registry, busy: &HashSet<RunId>, keep: Option<(&RunId, &str)>,
727 ) -> Vec<PathBuf> {
728 let mut total = reg.total_bytes();
729 if total <= self.budget {
730 return Vec::new();
731 }
732 let target = self.budget / 10 * 9;
733 let keep_run = keep.map(|(run, _)| run);
734 let mut runs: Vec<(SystemTime, RunId, u64)> = reg
735 .runs
736 .iter()
737 .filter(|(id, run)| Some(*id) != keep_run && !busy.contains(*id) && !run.leased())
738 .map(|(id, run)| (run.last_access, id.clone(), run.bytes()))
739 .collect();
740 runs.sort_by_key(|candidate| candidate.0);
741 let mut dirs = Vec::new();
742 for (_, id, bytes) in runs {
743 if total <= target {
744 return dirs;
745 }
746 total = total.saturating_sub(bytes);
747 dirs.extend(remove_run(reg, &self.deletes, &id));
748 }
749
750 let mut docs: Vec<(SystemTime, RunId, String, u64)> = Vec::new();
751 for (id, run) in ®.runs {
752 if Some(id) != keep_run && busy.contains(id) {
753 continue;
754 }
755 for (doc_key, doc) in &run.docs {
756 let protected = keep.is_some_and(|(r, d)| r == id && d == doc_key.as_str());
757 if !protected && !doc.leased() {
758 docs.push((doc.last_access, id.clone(), doc_key.clone(), doc.bytes()));
759 }
760 }
761 }
762 docs.sort_by_key(|candidate| candidate.0);
763 for (_, id, doc_key, bytes) in docs {
764 if total <= target {
765 break;
766 }
767 total = total.saturating_sub(bytes);
768 dirs.extend(remove_doc(reg, &self.deletes, &id, &doc_key));
769 }
770 dirs
771 }
772
773 pub async fn read_tree(
774 self: &Arc<Self>, provider_id: i64, pipeline_id: &str, run_number: i64,
775 ) -> Option<StoredTree> {
776 let path = self
777 .run_dir(provider_id, &run_key_of(pipeline_id, run_number))
778 .join(TREE_FILE);
779 tokio::task::spawn_blocking(move || serde_json::from_slice(&fs::read(path).ok()?).ok())
780 .await
781 .ok()
782 .flatten()
783 }
784
785 pub async fn write_tree(
786 self: &Arc<Self>, provider_id: i64, pipeline_id: &str, run_number: i64, tree: &StoredTree,
787 epoch: CacheEpoch,
788 ) -> DomainResult<()> {
789 let json = serde_json::to_vec(tree)
790 .map_err(|e| DomainError::InternalError(format!("Couldn't save the log tree: {e}")))?;
791 let store = self.clone();
792 let run_id = (provider_id, run_key_of(pipeline_id, run_number));
793 tokio::task::spawn_blocking(move || store.write_tree_blocking(run_id, &json, epoch))
794 .await
795 .map_err(join_error)?
796 }
797
798 fn write_tree_blocking(
799 &self, run_id: RunId, json: &[u8], epoch: CacheEpoch,
800 ) -> DomainResult<()> {
801 let save_error = |e: io::Error| io_error("Couldn't save the log tree", &e);
802 let mut reg = lock(&self.registry);
803 if reg.epoch(run_id.0) != epoch {
804 return Ok(());
805 }
806 let run_dir = self.run_dir(run_id.0, &run_id.1);
807 fs::create_dir_all(&run_dir).map_err(save_error)?;
808 OpenOptions::new()
809 .write(true)
810 .create(true)
811 .truncate(false)
812 .open(run_dir.join(TOUCH_FILE))
813 .map_err(save_error)?;
814 let tmp = run_dir.join(TREE_TMP_FILE);
815 fs::write(&tmp, json).map_err(save_error)?;
816 fs::rename(&tmp, run_dir.join(TREE_FILE)).map_err(save_error)?;
817 let now = SystemTime::now();
818 let run = reg.runs.entry(run_id).or_insert_with(|| RunEntry {
819 dir: run_dir,
820 tree_bytes: 0,
821 last_access: now,
822 last_touch: now,
823 docs: HashMap::new(),
824 });
825 run.tree_bytes = json.len() as u64;
826 Ok(())
827 }
828
829 pub async fn purge_provider(self: &Arc<Self>, provider_id: i64) -> DomainResult<()> {
830 self.invalidate(Some(provider_id)).await
831 }
832
833 pub async fn clear(self: &Arc<Self>) -> DomainResult<()> {
834 self.invalidate(None).await
835 }
836
837 async fn invalidate(self: &Arc<Self>, provider_id: Option<i64>) -> DomainResult<()> {
838 if let Err(e) = self.sweep().await {
839 tracing::warn!("{e}");
840 }
841 let store = self.clone();
842 tokio::task::spawn_blocking(move || {
843 let dirs = {
844 let mut guard = lock(&store.registry);
845 let reg = &mut *guard;
846 match provider_id {
847 Some(id) => *reg.provider_epochs.entry(id).or_insert(0) += 1,
848 None => reg.global_epoch += 1,
849 }
850 let ids: Vec<RunId> = reg
851 .runs
852 .keys()
853 .filter(|(provider, _)| provider_id.is_none_or(|id| *provider == id))
854 .cloned()
855 .collect();
856 ids.iter()
857 .flat_map(|id| remove_run(reg, &store.deletes, id))
858 .collect::<Vec<_>>()
859 };
860 store.forget(&dirs);
861 })
862 .await
863 .map_err(join_error)?;
864 self.sweep().await
865 }
866
867 pub async fn stats(self: &Arc<Self>) -> LogCacheStats {
868 if let Err(e) = self.sweep().await {
869 tracing::warn!("{e}");
870 }
871 let reg = lock(&self.registry);
872 LogCacheStats {
873 bytes: reg.total_bytes(),
874 runs: reg.runs.len() as u64,
875 }
876 }
877
878 pub(crate) async fn sweep(self: &Arc<Self>) -> DomainResult<()> {
879 self.run_sweep(None).await
880 }
881
882 pub async fn sweep_with(
883 self: &Arc<Self>, busy: HashSet<(i64, String)>, keep: Option<&DocKey>,
884 ) -> DomainResult<()> {
885 let keep = keep.map(|key| (key.run_id(), key.doc_key()));
886 self.run_sweep(Some((busy, keep))).await
887 }
888
889 async fn run_sweep(self: &Arc<Self>, trim: Option<TrimScope>) -> DomainResult<()> {
890 let store = self.clone();
891 tokio::task::spawn_blocking(move || store.sweep_blocking(trim.as_ref()))
892 .await
893 .map_err(join_error)?
894 }
895
896 fn sweep_blocking(&self, trim: Option<&TrimScope>) -> DomainResult<()> {
897 let first = self.drain_deletes();
898 let Some((busy, keep)) = trim else {
899 return first;
900 };
901 let keep = keep.as_ref().map(|(run, doc)| (run, doc.as_str()));
902 let dirs = self.trim(busy, keep);
903 self.forget(&dirs);
904 let second = self.drain_deletes();
905 first.and(second)
906 }
907
908 fn trim(&self, busy: &HashSet<RunId>, keep: Option<(&RunId, &str)>) -> Vec<PathBuf> {
909 let now = SystemTime::now();
910 let mut guard = lock(&self.registry);
911 let reg = &mut *guard;
912 let mut dirs = Vec::new();
913 let due = reg
914 .last_idle_sweep
915 .is_none_or(|last| now.duration_since(last).unwrap_or_default() >= self.touch_interval);
916 if due {
917 reg.last_idle_sweep = Some(now);
918 let expired: Vec<RunId> = reg
919 .runs
920 .iter()
921 .filter(|(id, run)| {
922 !busy.contains(*id)
923 && !run.leased()
924 && now.duration_since(run.last_access).unwrap_or_default() > CACHE_MAX_IDLE
925 })
926 .map(|(id, _)| id.clone())
927 .collect();
928 for id in expired {
929 dirs.extend(remove_run(reg, &self.deletes, &id));
930 }
931 }
932 dirs.extend(self.evict(reg, busy, keep));
933 dirs
934 }
935
936 fn drain_deletes(&self) -> DomainResult<()> {
937 let pending = std::mem::take(&mut *lock(&self.deletes));
938 let mut removed = Vec::new();
939 let mut failed = Vec::new();
940 let mut first_error: Option<io::Error> = None;
941 for dir in pending {
942 match fs::remove_dir_all(&dir) {
943 Ok(()) => removed.push(dir),
944 Err(e) if e.kind() == io::ErrorKind::NotFound => removed.push(dir),
945 Err(e) => {
946 failed.push(dir);
947 first_error.get_or_insert(e);
948 }
949 }
950 }
951 if !failed.is_empty() {
952 lock(&self.deletes).extend(failed);
953 }
954 {
955 let mut reg = lock(&self.registry);
956 reg.retired.retain(|state| state.dir.exists());
957 for dir in &removed {
958 self.prune_ancestors(®, dir);
959 }
960 }
961 match first_error {
962 Some(e) => Err(io_error("Couldn't delete cached logs", &e)),
963 None => Ok(()),
964 }
965 }
966
967 fn prune_ancestors(&self, reg: &Registry, revision_dir: &Path) {
968 let Ok(relative) = revision_dir.strip_prefix(&self.root) else {
969 return;
970 };
971 let parts: Vec<String> = relative
972 .components()
973 .map(|c| c.as_os_str().to_string_lossy().into_owned())
974 .collect();
975 let [_, provider, run_key, _, doc_key, _] = parts.as_slice() else {
976 return;
977 };
978 let Ok(provider_id) = provider.parse::<i64>() else {
979 return;
980 };
981 let run = reg.runs.get(&(provider_id, run_key.clone()));
982 let referenced = [
983 run.is_some_and(|run| run.docs.contains_key(doc_key)),
984 run.is_some_and(|run| !run.docs.is_empty()),
985 run.is_some(),
986 reg.runs.keys().any(|(id, _)| *id == provider_id),
987 ];
988 let mut dir = revision_dir.parent();
989 for referenced in referenced {
990 let Some(current) = dir else {
991 return;
992 };
993 if referenced || fs::remove_dir(current).is_err() {
994 return;
995 }
996 dir = current.parent();
997 }
998 }
999}
1000
1001fn valid_meta(dir: &Path) -> bool {
1002 fs::read(dir.join(META_FILE))
1003 .ok()
1004 .and_then(|bytes| serde_json::from_slice::<StoredDocMeta>(&bytes).ok())
1005 .is_some_and(|meta| meta.format == STORED_FORMAT)
1006}
1007
1008fn remove_path(path: &Path) {
1009 let result = if path.is_dir() {
1010 fs::remove_dir_all(path)
1011 } else {
1012 fs::remove_file(path)
1013 };
1014 if let Err(e) = result {
1015 if e.kind() != io::ErrorKind::NotFound {
1016 tracing::warn!("Couldn't remove a stale log cache entry: {e}");
1017 }
1018 }
1019}
1020
1021fn scan(root: &Path, now: SystemTime) -> io::Result<Registry> {
1022 for entry in fs::read_dir(root)? {
1023 let entry = entry?;
1024 if entry.file_name() != LAYOUT_DIR {
1025 remove_path(&entry.path());
1026 }
1027 }
1028 let base = root.join(LAYOUT_DIR);
1029 fs::create_dir_all(&base)?;
1030 remove_path(&base.join(TMP_DIR));
1031
1032 let mut reg = Registry::default();
1033 for provider in fs::read_dir(&base)? {
1034 let provider = provider?;
1035 let provider_dir = provider.path();
1036 let Some(provider_id) = provider
1037 .file_name()
1038 .to_str()
1039 .and_then(|n| n.parse::<i64>().ok())
1040 else {
1041 remove_path(&provider_dir);
1042 continue;
1043 };
1044 if !provider_dir.is_dir() {
1045 remove_path(&provider_dir);
1046 continue;
1047 }
1048 for run in fs::read_dir(&provider_dir)? {
1049 let run = run?;
1050 let run_dir = run.path();
1051 let Some(run_key) = run.file_name().to_str().map(str::to_owned) else {
1052 remove_path(&run_dir);
1053 continue;
1054 };
1055 match scan_run(&run_dir, now) {
1056 Some((entry, newest)) => {
1057 reg.last_revision = reg.last_revision.max(newest);
1058 reg.runs.insert((provider_id, run_key), entry);
1059 }
1060 None => remove_path(&run_dir),
1061 }
1062 }
1063 let _ = fs::remove_dir(&provider_dir);
1064 }
1065 Ok(reg)
1066}
1067
1068fn scan_run(run_dir: &Path, now: SystemTime) -> Option<(RunEntry, i64)> {
1069 if !run_dir.is_dir() {
1070 return None;
1071 }
1072 let touch = fs::metadata(run_dir.join(TOUCH_FILE))
1073 .and_then(|m| m.modified())
1074 .or_else(|_| fs::metadata(run_dir).and_then(|m| m.modified()))
1075 .unwrap_or(now);
1076 if now.duration_since(touch).unwrap_or_default() > CACHE_MAX_IDLE {
1077 return None;
1078 }
1079 remove_file_quiet(&run_dir.join(TREE_TMP_FILE));
1080 let tree_bytes = fs::metadata(run_dir.join(TREE_FILE)).map_or(0, |m| m.len());
1081
1082 let mut docs = HashMap::new();
1083 let mut newest = 0;
1084 let docs_dir = run_dir.join(DOCS_DIR);
1085 for doc in fs::read_dir(&docs_dir).into_iter().flatten().flatten() {
1086 let doc_dir = doc.path();
1087 let Some(doc_key) = doc.file_name().to_str().map(str::to_owned) else {
1088 remove_path(&doc_dir);
1089 continue;
1090 };
1091 let mut revisions: Vec<(i64, PathBuf)> = Vec::new();
1092 for revision in fs::read_dir(&doc_dir).into_iter().flatten().flatten() {
1093 match revision
1094 .file_name()
1095 .to_str()
1096 .and_then(|n| n.parse::<i64>().ok())
1097 {
1098 Some(number) if revision.path().is_dir() => {
1099 revisions.push((number, revision.path()))
1100 }
1101 _ => remove_path(&revision.path()),
1102 }
1103 }
1104 revisions.sort_by_key(|(number, _)| std::cmp::Reverse(*number));
1105 let keep = revisions.iter().position(|(_, dir)| valid_meta(dir));
1106 let mut entry = DocEntry::new(touch);
1107 for (position, (number, dir)) in revisions.into_iter().enumerate() {
1108 if Some(position) == keep {
1109 let state = RevisionState {
1110 bytes: dir_size(&dir),
1111 dir,
1112 readers: AtomicU32::new(0),
1113 doomed: AtomicBool::new(false),
1114 };
1115 entry.current = Some(number);
1116 entry.revisions.insert(number, Arc::new(state));
1117 newest = newest.max(number);
1118 } else {
1119 remove_path(&dir);
1120 }
1121 }
1122 if entry.current.is_some() {
1123 docs.insert(doc_key, entry);
1124 } else {
1125 remove_path(&doc_dir);
1126 }
1127 }
1128 if docs.is_empty() && tree_bytes == 0 {
1129 return None;
1130 }
1131 let run = RunEntry {
1132 dir: run_dir.to_path_buf(),
1133 tree_bytes,
1134 last_access: touch,
1135 last_touch: touch,
1136 docs,
1137 };
1138 Some((run, newest))
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use std::fs::{
1144 File,
1145 FileTimes,
1146 };
1147
1148 use tempfile::TempDir;
1149
1150 use super::super::checkpoint::{
1151 CHECKPOINT_FILE,
1152 TAIL_FILE,
1153 };
1154 use super::super::ingest::INDEX_FILE;
1155 use super::super::testing::{
1156 doc_key,
1157 feed,
1158 finish,
1159 ingester,
1160 };
1161 use super::super::BlockLimits;
1162 use super::*;
1163 use crate::domain::{
1164 LineKind,
1165 LogCursor,
1166 };
1167
1168 fn root(dir: &TempDir) -> PathBuf {
1169 dir.path().join("run-logs")
1170 }
1171
1172 fn key_n(run: i64) -> DocKey {
1173 DocKey {
1174 run_number: run,
1175 ..doc_key()
1176 }
1177 }
1178
1179 fn rev_dir(store: &LogStore, key: &DocKey, revision: i64) -> PathBuf {
1180 store
1181 .run_dir(key.provider_id, &key.run_key())
1182 .join(DOCS_DIR)
1183 .join(key.doc_key())
1184 .join(revision.to_string())
1185 }
1186
1187 async fn publish_doc(store: &Arc<LogStore>, key: &DocKey, text: &str) -> i64 {
1188 publish_busy(store, key, text, HashSet::new()).await
1189 }
1190
1191 async fn prepare(
1192 store: &Arc<LogStore>, key: &DocKey, text: &str, complete: bool,
1193 ) -> (PathBuf, i64, CacheEpoch) {
1194 let revision = store.allocate_revision();
1195 let epoch = store.epoch(key.provider_id);
1196 let tmp = store.tmp_dir().await.expect("tmp dir");
1197 let base = store.base(key).await.expect("base");
1198 let (dir, key2, data) = (tmp.clone(), key.clone(), text.as_bytes().to_vec());
1199 tokio::task::spawn_blocking(move || {
1200 let mut ingester = ingester(&dir, key2, revision, Vec::new(), BlockLimits::default());
1201 ingester.complete = complete;
1202 ingester.base = base;
1203 let cursor = LogCursor::new(&revision).expect("cursor");
1204 finish(feed(ingester, &data, 1 << 16), Some(cursor));
1205 })
1206 .await
1207 .expect("ingest");
1208
1209 (tmp, revision, epoch)
1210 }
1211
1212 async fn publish_busy(
1213 store: &Arc<LogStore>, key: &DocKey, text: &str, busy: HashSet<RunId>,
1214 ) -> i64 {
1215 let (tmp, revision, epoch) = prepare(store, key, text, true).await;
1216 let outcome = store
1217 .publish(key, &tmp, revision, epoch, store.current(key), busy)
1218 .await
1219 .expect("publish");
1220 assert_eq!(outcome, PublishOutcome::Published);
1221 revision
1222 }
1223
1224 async fn publish_incomplete(store: &Arc<LogStore>, key: &DocKey, text: &str) -> i64 {
1225 let (tmp, revision, epoch) = prepare(store, key, text, false).await;
1226 let outcome = store
1227 .publish(
1228 key,
1229 &tmp,
1230 revision,
1231 epoch,
1232 store.current(key),
1233 HashSet::new(),
1234 )
1235 .await
1236 .expect("publish");
1237 assert_eq!(outcome, PublishOutcome::Published);
1238 revision
1239 }
1240
1241 fn files_of(dir: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
1242 fs::read_dir(dir)
1243 .expect("read dir")
1244 .map(|entry| {
1245 let path = entry.expect("entry").path();
1246 let name = path
1247 .file_name()
1248 .expect("name")
1249 .to_string_lossy()
1250 .into_owned();
1251 (name, fs::read(&path).expect("read"))
1252 })
1253 .collect()
1254 }
1255
1256 async fn lines_of(store: &Arc<LogStore>, key: &DocKey, revision: i64) -> Vec<String> {
1257 let (doc, _lease) = store
1258 .lease(key, revision)
1259 .await
1260 .expect("lease")
1261 .expect("some");
1262 let page = doc
1263 .read_lines(store.block_cache(), 0, 100, 4096)
1264 .expect("page");
1265 page.lines.into_iter().map(|line| line.text).collect()
1266 }
1267
1268 async fn first_line(store: &Arc<LogStore>, key: &DocKey, revision: i64) -> Option<String> {
1269 let (doc, _lease) = store.lease(key, revision).await.expect("lease")?;
1270 let page = doc
1271 .read_lines(store.block_cache(), 0, 1, 4096)
1272 .expect("page");
1273 page.lines.first().map(|l| l.text.clone())
1274 }
1275
1276 #[tokio::test]
1277 async fn run_logs_store_publish_survives_reopen() {
1278 let dir = tempfile::tempdir().expect("tempdir");
1279 let key = doc_key();
1280 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1281 let revision = publish_doc(&store, &key, "hello\nworld\n").await;
1282 assert_eq!(store.current(&key), Some(revision));
1283 drop(store);
1284
1285 let store = LogStore::open(root(&dir), u64::MAX).await.expect("reopen");
1286 assert_eq!(store.current(&key), Some(revision));
1287 assert_eq!(
1288 first_line(&store, &key, revision).await.as_deref(),
1289 Some("hello")
1290 );
1291 assert!(store.allocate_revision() > revision);
1292 let (doc, _lease) = store
1293 .lease(&key, revision)
1294 .await
1295 .expect("lease")
1296 .expect("some");
1297 assert_eq!(doc.meta().to_meta().line_count, 2);
1298 assert_eq!(doc.meta().key, key);
1299 let _ = LineKind::Plain;
1300 }
1301
1302 #[tokio::test]
1303 async fn run_logs_store_drops_documents_indexed_before_error_inference() {
1304 let dir = tempfile::tempdir().expect("tempdir");
1305 let key = doc_key();
1306 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1307 let revision = publish_doc(&store, &key, "ERROR: Job failed: exit code 1\n").await;
1308 let meta_path = rev_dir(&store, &key, revision).join(META_FILE);
1309 let mut old: serde_json::Value =
1310 serde_json::from_slice(&fs::read(&meta_path).expect("meta")).expect("json");
1311 old["format"] = serde_json::json!(1);
1312 old["error_lines"] = serde_json::json!([]);
1313 old["error_total"] = serde_json::json!(0);
1314 fs::write(&meta_path, serde_json::to_vec(&old).expect("encode")).expect("write");
1315 drop(store);
1316
1317 let store = LogStore::open(root(&dir), u64::MAX).await.expect("reopen");
1318
1319 assert_eq!(store.current(&key), None);
1320 assert!(!rev_dir(&store, &key, revision).exists());
1321 }
1322
1323 #[tokio::test]
1324 async fn run_logs_store_drops_documents_stored_before_checkpoints() {
1325 let dir = tempfile::tempdir().expect("tempdir");
1326 let key = doc_key();
1327 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1328 let revision = publish_incomplete(&store, &key, "one\ntwo").await;
1329 let meta_path = rev_dir(&store, &key, revision).join(META_FILE);
1330 let mut old: serde_json::Value =
1331 serde_json::from_slice(&fs::read(&meta_path).expect("meta")).expect("json");
1332 old["format"] = serde_json::json!(2);
1333 fs::write(&meta_path, serde_json::to_vec(&old).expect("encode")).expect("write");
1334 drop(store);
1335
1336 let store = LogStore::open(root(&dir), u64::MAX).await.expect("reopen");
1337
1338 assert_eq!(store.current(&key), None);
1339 assert!(!rev_dir(&store, &key, revision).exists());
1340 }
1341
1342 #[tokio::test]
1343 async fn run_logs_store_publish_rejects_a_stale_base() {
1344 let dir = tempfile::tempdir().expect("tempdir");
1345 let key = doc_key();
1346 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1347 let first = publish_doc(&store, &key, "one\n").await;
1348
1349 let (tmp, revision, epoch) = prepare(&store, &key, "late\n", true).await;
1350 let empty_base = store
1351 .publish(&key, &tmp, revision, epoch, None, HashSet::new())
1352 .await
1353 .expect("publish");
1354
1355 assert_eq!(empty_base, PublishOutcome::StaleBase);
1356 assert_eq!(store.current(&key), Some(first));
1357 assert!(tmp.is_dir());
1358 assert!(!rev_dir(&store, &key, revision).exists());
1359 let second = publish_doc(&store, &key, "two\n").await;
1360 let moved_on = store
1361 .publish(&key, &tmp, revision, epoch, Some(first), HashSet::new())
1362 .await
1363 .expect("publish");
1364 assert_eq!(moved_on, PublishOutcome::StaleBase);
1365 assert_eq!(store.current(&key), Some(second));
1366 let current = store
1367 .publish(&key, &tmp, revision, epoch, Some(second), HashSet::new())
1368 .await
1369 .expect("publish");
1370 assert_eq!(current, PublishOutcome::Published);
1371 assert_eq!(store.current(&key), Some(revision));
1372 assert_eq!(lines_of(&store, &key, revision).await, ["late"]);
1373 }
1374
1375 #[tokio::test]
1376 async fn run_logs_store_resuming_never_touches_the_leased_base() {
1377 let dir = tempfile::tempdir().expect("tempdir");
1378 let key = doc_key();
1379 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1380 let first = publish_incomplete(&store, &key, "one\ntwo\npart").await;
1381 let base = store.base(&key).await.expect("base").expect("some");
1382 assert!(base.cursor(&[]).is_some());
1383 let base_dir = base.document.dir().to_path_buf();
1384 let before = files_of(&base_dir);
1385
1386 let second = publish_incomplete(&store, &key, "ial\nthree\n").await;
1387
1388 assert_ne!(first, second);
1389 assert_eq!(store.current(&key), Some(second));
1390 assert!(base_dir.is_dir());
1391 assert_eq!(files_of(&base_dir), before);
1392 let held = base
1393 .document
1394 .read_lines(store.block_cache(), 0, 100, 4096)
1395 .expect("page");
1396 let held: Vec<_> = held.lines.into_iter().map(|line| line.text).collect();
1397 assert_eq!(held, ["one", "two", "part"]);
1398 assert_eq!(
1399 lines_of(&store, &key, second).await,
1400 ["one", "two", "partial", "three"]
1401 );
1402 #[cfg(unix)]
1403 {
1404 use std::os::unix::fs::MetadataExt;
1405 let blocks =
1406 |revision| fs::metadata(rev_dir(&store, &key, revision).join("blocks.bin"));
1407 let (old, new) = (blocks(first).unwrap(), blocks(second).unwrap());
1408 assert_eq!((old.nlink(), new.nlink()), (1, 1));
1409 assert_ne!(old.ino(), new.ino());
1410 }
1411
1412 drop(base);
1413 store.sweep().await.expect("sweep");
1414 assert!(!base_dir.exists());
1415 }
1416
1417 #[tokio::test]
1418 async fn run_logs_store_budget_counts_the_checkpoint_files() {
1419 let dir = tempfile::tempdir().expect("tempdir");
1420 let key = doc_key();
1421 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1422 let text = format!("##[error]boom\n{}", "x".repeat(300_000));
1423 let revision = publish_incomplete(&store, &key, &text).await;
1424 let files = files_of(&rev_dir(&store, &key, revision));
1425
1426 for name in [CHECKPOINT_FILE, TAIL_FILE, "errors.bin"] {
1427 assert!(files.contains_key(name), "{name} is missing");
1428 }
1429 let total: usize = files.values().map(Vec::len).sum();
1430 assert!(files[TAIL_FILE].len() >= 300_000);
1431 assert_eq!(store.stats().await.bytes, total as u64);
1432 }
1433
1434 #[tokio::test]
1435 async fn run_logs_store_second_open_shares_instance_and_temp_dirs() {
1436 let dir = tempfile::tempdir().expect("tempdir");
1437 let first = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1438 let tmp = first.tmp_dir().await.expect("tmp");
1439 let second = LogStore::open(root(&dir), 1).await.expect("open again");
1440 assert!(Arc::ptr_eq(&first, &second));
1441 assert!(tmp.is_dir());
1442 drop((first, second));
1443 let third = LogStore::open(root(&dir), u64::MAX).await.expect("reopen");
1444 assert!(!tmp.exists());
1445 drop(third);
1446 }
1447
1448 #[tokio::test]
1449 async fn run_logs_store_keeps_leased_revision_readable_until_dropped() {
1450 let dir = tempfile::tempdir().expect("tempdir");
1451 let key = doc_key();
1452 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1453 let a = publish_doc(&store, &key, "alpha\n").await;
1454 let (doc, lease) = store.lease(&key, a).await.expect("lease").expect("some");
1455 let b = publish_doc(&store, &key, "beta\n").await;
1456 assert_eq!(store.current(&key), Some(b));
1457 let page = doc
1458 .read_lines(store.block_cache(), 0, 1, 4096)
1459 .expect("page");
1460 assert_eq!(page.lines[0].text, "alpha");
1461 assert!(store.lease(&key, a).await.expect("lease").is_none());
1462 store.sweep().await.expect("sweep");
1463 assert!(rev_dir(&store, &key, a).is_dir());
1464
1465 drop(lease);
1466 store.sweep().await.expect("sweep");
1467 assert!(!rev_dir(&store, &key, a).exists());
1468 assert_eq!(first_line(&store, &key, b).await.as_deref(), Some("beta"));
1469 }
1470
1471 #[tokio::test]
1472 async fn run_logs_store_purge_with_lease_keeps_new_revision() {
1473 let dir = tempfile::tempdir().expect("tempdir");
1474 let key = doc_key();
1475 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1476 let a = publish_doc(&store, &key, "alpha\n").await;
1477 let (_doc, lease) = store.lease(&key, a).await.expect("lease").expect("some");
1478 store.purge_provider(key.provider_id).await.expect("purge");
1479 assert_eq!(
1480 store.epoch(key.provider_id),
1481 CacheEpoch {
1482 global: 0,
1483 provider: 1
1484 }
1485 );
1486 assert_eq!(store.current(&key), None);
1487 assert!(rev_dir(&store, &key, a).is_dir());
1488
1489 let b = publish_doc(&store, &key, "beta\n").await;
1490 drop(lease);
1491 store.sweep().await.expect("sweep");
1492 assert!(!rev_dir(&store, &key, a).exists());
1493 assert_eq!(first_line(&store, &key, b).await.as_deref(), Some("beta"));
1494 assert!(rev_dir(&store, &key, b).is_dir());
1495 }
1496
1497 #[tokio::test]
1498 async fn run_logs_store_failed_lease_load_leaves_no_readers() {
1499 let dir = tempfile::tempdir().expect("tempdir");
1500 let key = doc_key();
1501 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1502 let revision = publish_doc(&store, &key, "alpha\nbeta\n").await;
1503 let index = rev_dir(&store, &key, revision).join(INDEX_FILE);
1504 let mut bytes = fs::read(&index).expect("index");
1505 bytes.push(0);
1506 fs::write(&index, bytes).expect("write");
1507
1508 assert!(store.lease(&key, revision).await.expect("lease").is_none());
1509 assert_eq!(store.current(&key), None);
1510 {
1511 let reg = lock(&store.registry);
1512 assert_eq!(reg.retired.len(), 1);
1513 assert_eq!(reg.retired[0].readers.load(Ordering::SeqCst), 0);
1514 }
1515 store.sweep().await.expect("sweep");
1516 assert!(!rev_dir(&store, &key, revision).exists());
1517 assert!(lock(&store.registry).retired.is_empty());
1518 }
1519
1520 #[cfg(unix)]
1521 #[tokio::test]
1522 async fn run_logs_store_delete_failure_is_requeued() {
1523 use std::os::unix::fs::PermissionsExt;
1524
1525 struct Restore(PathBuf);
1526 impl Drop for Restore {
1527 fn drop(&mut self) {
1528 let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o755));
1529 }
1530 }
1531
1532 let dir = tempfile::tempdir().expect("tempdir");
1533 let key = doc_key();
1534 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1535 let revision = publish_doc(&store, &key, "alpha\n").await;
1536 let target = rev_dir(&store, &key, revision);
1537 let parent = target.parent().expect("parent").to_path_buf();
1538 let _restore = Restore(parent.clone());
1539 fs::set_permissions(&parent, fs::Permissions::from_mode(0o555)).expect("chmod");
1540 if fs::create_dir(parent.join("probe")).is_ok() {
1541 return;
1542 }
1543
1544 assert!(store.clear().await.is_err());
1545 assert!(target.exists());
1546 assert_eq!(lock(&store.deletes).len(), 1);
1547 assert!(store.stats().await.bytes > 0 || target.exists());
1548
1549 fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).expect("chmod");
1550 store.sweep().await.expect("sweep");
1551 assert!(!target.exists());
1552 assert_eq!(store.stats().await.bytes, 0);
1553 }
1554
1555 async fn doc_bytes(text: &str) -> u64 {
1556 let dir = tempfile::tempdir().expect("tempdir");
1557 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1558 publish_doc(&store, &doc_key(), text).await;
1559 let bytes = store.stats().await.bytes;
1560 drop(store);
1561 bytes
1562 }
1563
1564 fn set_access(store: &LogStore, key: &DocKey, age: Duration) {
1565 let mut reg = lock(&store.registry);
1566 let run = reg.runs.get_mut(&key.run_id()).expect("run");
1567 run.last_access = SystemTime::now() - age;
1568 }
1569
1570 #[tokio::test]
1571 async fn run_logs_store_evicts_least_recently_used_and_skips_leased() {
1572 let text = "a line of log output\nanother line\n";
1573 let size = doc_bytes(text).await;
1574 let dir = tempfile::tempdir().expect("tempdir");
1575 let store = LogStore::open(root(&dir), size * 3 + size / 2)
1576 .await
1577 .expect("open");
1578 let hour = Duration::from_secs(3600);
1579 let keys: Vec<DocKey> = (1..=4).map(key_n).collect();
1580 let mut revisions = Vec::new();
1581 for (key, age) in keys.iter().zip([4, 3, 2]) {
1582 revisions.push(publish_doc(&store, key, text).await);
1583 set_access(&store, key, hour * age);
1584 }
1585 let (_doc, lease) = store
1586 .lease(&keys[0], revisions[0])
1587 .await
1588 .expect("lease")
1589 .expect("some");
1590 set_access(&store, &keys[0], hour * 4);
1591
1592 revisions.push(publish_doc(&store, &keys[3], text).await);
1593 assert!(store.current(&keys[0]).is_some());
1594 assert!(store.current(&keys[1]).is_none());
1595 assert!(store.current(&keys[2]).is_some());
1596 assert!(store.current(&keys[3]).is_some());
1597 drop(lease);
1598 }
1599
1600 #[tokio::test]
1601 async fn run_logs_store_eviction_skips_busy_runs() {
1602 let text = "a line of log output\nanother line\n";
1603 let size = doc_bytes(text).await;
1604 let dir = tempfile::tempdir().expect("tempdir");
1605 let store = LogStore::open(root(&dir), size * 3 + size / 2)
1606 .await
1607 .expect("open");
1608 let hour = Duration::from_secs(3600);
1609 let keys: Vec<DocKey> = (1..=4).map(key_n).collect();
1610 for (key, age) in keys.iter().zip([4, 3, 2]) {
1611 publish_doc(&store, key, text).await;
1612 set_access(&store, key, hour * age);
1613 }
1614 let busy = HashSet::from([keys[0].run_id()]);
1615 publish_busy(&store, &keys[3], text, busy).await;
1616 assert!(store.current(&keys[0]).is_some());
1617 assert!(store.current(&keys[1]).is_none());
1618 assert!(store.current(&keys[2]).is_some());
1619 store.sweep().await.expect("sweep");
1620 assert!(!lock(&store.registry).runs.contains_key(&keys[1].run_id()));
1621 assert!(store.stats().await.bytes <= size * 3 + size / 2);
1622 }
1623
1624 #[tokio::test]
1625 async fn run_logs_store_epochs_reject_stale_publish_and_tree_writes() {
1626 let dir = tempfile::tempdir().expect("tempdir");
1627 let key = doc_key();
1628 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1629 let tree = StoredTree {
1630 run_status: PipelineStatus::Success,
1631 run_concluded_at: None,
1632 tree: RunLogTree {
1633 nodes: Vec::new(),
1634 default_node_id: None,
1635 complete: true,
1636 },
1637 };
1638 let stale = store.epoch(key.provider_id);
1639 store
1640 .write_tree(
1641 key.provider_id,
1642 &key.pipeline_id,
1643 key.run_number,
1644 &tree,
1645 stale,
1646 )
1647 .await
1648 .expect("write tree");
1649 let read = store
1650 .read_tree(key.provider_id, &key.pipeline_id, key.run_number)
1651 .await;
1652 assert!(read.is_some_and(|t| t.tree.complete));
1653
1654 for purge_provider in [true, false] {
1655 let epoch = store.epoch(key.provider_id);
1656 if purge_provider {
1657 store.purge_provider(key.provider_id).await.expect("purge");
1658 } else {
1659 store.clear().await.expect("clear");
1660 }
1661 assert_ne!(store.epoch(key.provider_id), epoch);
1662 let tmp = store.tmp_dir().await.expect("tmp");
1663 let outcome = store
1664 .publish(&key, &tmp, 5, epoch, None, HashSet::new())
1665 .await
1666 .expect("publish");
1667 assert_eq!(outcome, PublishOutcome::StaleEpoch);
1668 store
1669 .write_tree(
1670 key.provider_id,
1671 &key.pipeline_id,
1672 key.run_number,
1673 &tree,
1674 epoch,
1675 )
1676 .await
1677 .expect("stale tree write");
1678 assert!(store
1679 .read_tree(key.provider_id, &key.pipeline_id, key.run_number)
1680 .await
1681 .is_none());
1682 store.discard(tmp.clone()).await;
1683 assert!(!tmp.exists());
1684 }
1685 assert_eq!(
1686 store.epoch(key.provider_id),
1687 CacheEpoch {
1688 global: 1,
1689 provider: 1
1690 }
1691 );
1692 }
1693
1694 #[tokio::test]
1695 async fn run_logs_store_revisions_increase_when_the_clock_goes_back() {
1696 let dir = tempfile::tempdir().expect("tempdir");
1697 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1698 assert_eq!(store.allocate_at(5_000), 5_000);
1699 assert_eq!(store.allocate_at(1_000), 5_001);
1700 assert_eq!(store.allocate_at(5_000), 5_002);
1701 assert!(store.allocate_revision() > 5_002);
1702 }
1703
1704 #[tokio::test]
1705 async fn run_logs_store_touch_advances_with_zero_interval() {
1706 let dir = tempfile::tempdir().expect("tempdir");
1707 let key = doc_key();
1708 let store = LogStore::open_with(root(&dir), u64::MAX, Duration::ZERO)
1709 .await
1710 .expect("open");
1711 let revision = publish_doc(&store, &key, "alpha\n").await;
1712 let touch = store
1713 .run_dir(key.provider_id, &key.run_key())
1714 .join(TOUCH_FILE);
1715 let old = SystemTime::now() - Duration::from_secs(10 * 24 * 3600);
1716 File::options()
1717 .write(true)
1718 .open(&touch)
1719 .expect("touch file")
1720 .set_times(FileTimes::new().set_modified(old))
1721 .expect("set times");
1722 let _ = first_line(&store, &key, revision).await;
1723 let modified = fs::metadata(&touch)
1724 .and_then(|m| m.modified())
1725 .expect("mtime");
1726 assert!(modified > old + Duration::from_secs(24 * 3600));
1727 }
1728
1729 #[tokio::test]
1730 async fn run_logs_store_scan_keeps_newest_revision_and_drops_idle_runs() {
1731 let dir = tempfile::tempdir().expect("tempdir");
1732 let (live, idle) = (key_n(1), key_n(2));
1733 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1734 let first = publish_doc(&store, &live, "one\n").await;
1735 let second = publish_doc(&store, &live, "two\n").await;
1736 publish_doc(&store, &idle, "idle\n").await;
1737 let touch = store
1738 .run_dir(idle.provider_id, &idle.run_key())
1739 .join(TOUCH_FILE);
1740 let old = SystemTime::now() - CACHE_MAX_IDLE - Duration::from_secs(3600);
1741 File::options()
1742 .write(true)
1743 .open(&touch)
1744 .expect("touch")
1745 .set_times(FileTimes::new().set_modified(old))
1746 .expect("set times");
1747 let stray = root(&dir).join("old-layout");
1748 fs::create_dir_all(&stray).expect("stray");
1749 let first_dir = rev_dir(&store, &live, first);
1750 drop(store);
1751
1752 let store = LogStore::open(root(&dir), u64::MAX).await.expect("reopen");
1753 assert!(!stray.exists());
1754 assert!(!first_dir.exists());
1755 assert_eq!(store.current(&live), Some(second));
1756 assert_eq!(store.current(&idle), None);
1757 assert_eq!(
1758 first_line(&store, &live, second).await.as_deref(),
1759 Some("two")
1760 );
1761 assert_eq!(store.stats().await.runs, 1);
1762 }
1763
1764 const DOC_TEXT: &str = "a line of log output\nanother line\n";
1765
1766 fn node_key(node: usize) -> DocKey {
1767 DocKey {
1768 node_id: format!("job-{node}"),
1769 ..doc_key()
1770 }
1771 }
1772
1773 #[tokio::test]
1774 async fn run_logs_store_trims_docs_inside_one_huge_run() {
1775 let size = doc_bytes(DOC_TEXT).await;
1776 let budget = size * 2 + size / 2;
1777 let dir = tempfile::tempdir().expect("tempdir");
1778 let store = LogStore::open(root(&dir), budget).await.expect("open");
1779 let keys: Vec<DocKey> = (0..5).map(node_key).collect();
1780 for key in &keys {
1781 publish_doc(&store, key, DOC_TEXT).await;
1782 }
1783 assert!(store.stats().await.bytes <= budget);
1784 let last = store.current(&keys[4]).expect("newest doc survives");
1785 assert_eq!(
1786 first_line(&store, &keys[4], last).await.as_deref(),
1787 Some("a line of log output")
1788 );
1789 assert!(store.current(&keys[0]).is_none());
1790 }
1791
1792 #[tokio::test]
1793 async fn run_logs_store_trim_in_one_run_skips_leased_docs() {
1794 let size = doc_bytes(DOC_TEXT).await;
1795 let budget = size * 2 + size / 2;
1796 let dir = tempfile::tempdir().expect("tempdir");
1797 let store = LogStore::open(root(&dir), budget).await.expect("open");
1798 let keys: Vec<DocKey> = (0..5).map(node_key).collect();
1799 let first = publish_doc(&store, &keys[0], DOC_TEXT).await;
1800 let (doc, lease) = store
1801 .lease(&keys[0], first)
1802 .await
1803 .expect("lease")
1804 .expect("some");
1805 for key in &keys[1..] {
1806 publish_doc(&store, key, DOC_TEXT).await;
1807 }
1808 assert_eq!(store.current(&keys[0]), Some(first));
1809 assert!(store.current(&keys[4]).is_some());
1810 assert!(store.current(&keys[1]).is_none());
1811 assert!(store.stats().await.bytes <= budget);
1812 let page = doc
1813 .read_lines(store.block_cache(), 0, 1, 4096)
1814 .expect("page");
1815 assert_eq!(page.lines[0].text, "a line of log output");
1816 drop(lease);
1817 }
1818
1819 #[tokio::test]
1820 async fn run_logs_store_sweep_trims_once_docs_are_unleased_and_not_busy() {
1821 let size = doc_bytes(DOC_TEXT).await;
1822 let budget = size * 2 + size / 2;
1823 let dir = tempfile::tempdir().expect("tempdir");
1824 let store = LogStore::open(root(&dir), budget).await.expect("open");
1825 let keys: Vec<DocKey> = (0..3).map(node_key).collect();
1826 let mut leases = Vec::new();
1827 for key in &keys[..2] {
1828 let revision = publish_doc(&store, key, DOC_TEXT).await;
1829 let held = store
1830 .lease(key, revision)
1831 .await
1832 .expect("lease")
1833 .expect("some");
1834 leases.push(held);
1835 }
1836 let revision = publish_doc(&store, &keys[2], DOC_TEXT).await;
1837 let newest = store
1838 .lease(&keys[2], revision)
1839 .await
1840 .expect("lease")
1841 .expect("some");
1842 assert!(store.stats().await.bytes > budget);
1843
1844 drop(leases);
1845 let busy = HashSet::from([keys[0].run_id()]);
1846 store.sweep_with(busy, None).await.expect("sweep");
1847 assert!(store.stats().await.bytes > budget);
1848
1849 store.sweep_with(HashSet::new(), None).await.expect("sweep");
1850 assert!(store.stats().await.bytes <= budget);
1851 assert!(store.current(&keys[0]).is_none());
1852 assert!(store.current(&keys[1]).is_some());
1853 assert!(store.current(&keys[2]).is_some());
1854 drop(newest);
1855 }
1856
1857 #[tokio::test]
1858 async fn run_logs_store_sweep_expires_idle_runs_but_not_leased_or_busy() {
1859 let dir = tempfile::tempdir().expect("tempdir");
1860 let store = LogStore::open_with(root(&dir), u64::MAX, Duration::ZERO)
1861 .await
1862 .expect("open");
1863 let keys: Vec<DocKey> = (1..=3).map(key_n).collect();
1864 let mut revisions = Vec::new();
1865 for key in &keys {
1866 revisions.push(publish_doc(&store, key, DOC_TEXT).await);
1867 }
1868 let held = store
1869 .lease(&keys[1], revisions[1])
1870 .await
1871 .expect("lease")
1872 .expect("some");
1873 let idle = CACHE_MAX_IDLE + Duration::from_secs(3600);
1874 for key in &keys {
1875 set_access(&store, key, idle);
1876 }
1877
1878 let busy = HashSet::from([keys[2].run_id()]);
1879 store.sweep_with(busy, None).await.expect("sweep");
1880 assert!(store.current(&keys[0]).is_none());
1881 assert!(store.current(&keys[1]).is_some());
1882 assert!(store.current(&keys[2]).is_some());
1883 assert!(!rev_dir(&store, &keys[0], revisions[0]).exists());
1884 assert!(rev_dir(&store, &keys[1], revisions[1]).is_dir());
1885 drop(held);
1886 }
1887
1888 #[tokio::test]
1889 async fn run_logs_store_idle_expiry_is_rate_limited() {
1890 let dir = tempfile::tempdir().expect("tempdir");
1891 let store = LogStore::open(root(&dir), u64::MAX).await.expect("open");
1892 let key = doc_key();
1893 publish_doc(&store, &key, DOC_TEXT).await;
1894 store.sweep_with(HashSet::new(), None).await.expect("sweep");
1895 set_access(&store, &key, CACHE_MAX_IDLE + Duration::from_secs(3600));
1896 store.sweep_with(HashSet::new(), None).await.expect("sweep");
1897 assert!(store.current(&key).is_some());
1898 }
1899
1900 #[tokio::test]
1901 async fn run_logs_store_sweep_keeps_the_document_being_read_even_over_budget() {
1902 let size = doc_bytes(DOC_TEXT).await;
1903 let dir = tempfile::tempdir().expect("tempdir");
1904 let store = LogStore::open(root(&dir), size / 2).await.expect("open");
1905 let key = doc_key();
1906 publish_doc(&store, &key, DOC_TEXT).await;
1907 assert!(store.stats().await.bytes > size / 2);
1908
1909 store
1910 .sweep_with(HashSet::new(), Some(&key))
1911 .await
1912 .expect("sweep");
1913 assert!(store.current(&key).is_some());
1914
1915 store.sweep_with(HashSet::new(), None).await.expect("sweep");
1916 assert!(store.current(&key).is_none());
1917 }
1918}