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(¤t));
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 ®.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(®, 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}