Skip to main content

pipedash_core/infrastructure/run_logs/
store.rs

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