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::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(&current));
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 &reg.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(&reg, 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}