1use std::collections::{
2 HashMap,
3 HashSet,
4};
5use std::future::Future;
6use std::io;
7use std::path::Path;
8use std::sync::atomic::{
9 AtomicBool,
10 AtomicU64,
11 Ordering,
12};
13use std::sync::{
14 Arc,
15 Mutex,
16 PoisonError,
17};
18use std::time::Duration;
19
20use bytes::Bytes;
21use chrono::Utc;
22use dashmap::{
23 DashMap,
24 Entry,
25};
26use futures::Stream;
27use tokio::sync::{
28 mpsc,
29 oneshot,
30 Semaphore,
31};
32use tokio::time::Instant;
33use tokio_util::sync::CancellationToken;
34use tokio_util::task::TaskTracker;
35
36use self::live::{
37 Interest,
38 Live,
39 RunKey,
40};
41use super::provider_service::{
42 ProviderLease,
43 ProviderService,
44};
45use crate::application::Generation;
46use crate::domain::{
47 DomainError,
48 DomainResult,
49 LogAvailability,
50 LogCacheStats,
51 LogLinesPage,
52 LogNode,
53 LogNodeKind,
54 LogReadMode,
55 LogSearchQuery,
56 LogSearchResult,
57 LogSink,
58 PipelineRun,
59 PipelineStatus,
60 RunLogDocument,
61 RunLogNode,
62 RunLogTree,
63};
64use crate::event::{
65 CoreEvent,
66 EventBus,
67};
68use crate::infrastructure::database::Repository;
69use crate::infrastructure::deduplication::{
70 hash_request,
71 RequestDeduplicator,
72};
73use crate::infrastructure::run_logs::{
74 search_regex,
75 Base,
76 BlockLimits,
77 CacheEpoch,
78 DocError,
79 DocKey,
80 Document,
81 Ingester,
82 LogStore,
83 PublishOutcome,
84 ReadLease,
85 StepBound,
86 StoredDocMeta,
87 StoredTree,
88};
89
90const INGEST_PERMITS: usize = 2;
91const SEARCH_PERMITS: usize = 2;
92const INGEST_QUEUE: usize = 16;
93const EXPORT_QUEUE: usize = 4;
94const PROGRESS_TICK: Duration = Duration::from_millis(500);
95const COMPLETE_TREE_TTL: Duration = Duration::from_secs(60);
96const OPEN_TREE_TTL: Duration = Duration::from_secs(10);
97const FAILURE_COOLDOWN: Duration = Duration::from_secs(5 * 60);
98const INTEREST_TTL: Duration = Duration::from_secs(45);
99const TREE_INTERVAL: Duration = Duration::from_secs(5);
100const TREE_BACKOFF_MAX: Duration = Duration::from_secs(60);
101const RESUMABLE_INTERVAL: Duration = Duration::from_secs(2);
102const SNAPSHOT_INTERVAL: Duration = Duration::from_secs(10);
103const SHUTDOWN_PATIENCE: Duration = Duration::from_secs(5);
104const REVISION_GONE: &str = "This log revision is no longer available";
105const EXPORT_INTERRUPTED: &str = "Pipedash stopped before the log finished downloading";
106const PROVIDER_CHANGED: &str = "The provider changed while the log was downloading. Open it again.";
107const PROVIDER_MISBEHAVED: &str =
108 "The provider returned the log in an unexpected way. Try again later.";
109
110type TreeKey = (i64, Generation, CacheEpoch, String, i64);
111type Built = (Arc<RunLogTree>, Arc<PipelineRun>);
112
113struct Attempt {
114 id: u64,
115 generation: Generation,
116 epoch: CacheEpoch,
117 received: Arc<AtomicU64>,
118 cancel: CancellationToken,
119}
120
121struct AttemptRecord {
122 generation: Generation,
123 epoch: CacheEpoch,
124 at: Instant,
125 failure: Option<String>,
126 resumable: bool,
127}
128
129enum Stop {
130 Silent,
131 ProviderChanged,
132}
133
134enum Finished {
135 Stopped(Stop),
136 Published { revision: i64, resumable: bool },
137 Unchanged { resumable: bool },
138 Failed(String),
139}
140
141struct AttemptGuard {
142 service: Arc<LogService>,
143 key: DocKey,
144 id: u64,
145}
146
147impl Drop for AttemptGuard {
148 fn drop(&mut self) {
149 self.service
150 .inflight
151 .remove_if(&self.key, |_, attempt| attempt.id == self.id);
152 }
153}
154
155struct SearchSession<'a> {
156 searches: &'a DashMap<(DocKey, String), (u64, CancellationToken)>,
157 key: (DocKey, String),
158 id: u64,
159 cancel: CancellationToken,
160}
161
162impl Drop for SearchSession<'_> {
163 fn drop(&mut self) {
164 self.cancel.cancel();
165 self.searches
166 .remove_if(&self.key, |_, (id, _)| *id == self.id);
167 }
168}
169
170pub struct LogService {
171 store: Arc<LogStore>,
172 provider_service: Arc<ProviderService>,
173 repository: Arc<Repository>,
174 event_bus: Arc<dyn EventBus>,
175 trees: RequestDeduplicator<Built, DomainError>,
176 tree_memo: DashMap<TreeKey, (Instant, Arc<RunLogTree>, Arc<PipelineRun>)>,
177 pipeline_providers: DashMap<String, i64>,
178 inflight: DashMap<DocKey, Attempt>,
179 attempts: DashMap<DocKey, AttemptRecord>,
180 searches: DashMap<(DocKey, String), (u64, CancellationToken)>,
181 interests: DashMap<RunKey, Interest>,
182 lives: DashMap<RunKey, Live>,
183 ingest_permits: Arc<Semaphore>,
184 search_permits: Arc<Semaphore>,
185 admission: Mutex<bool>,
186 root: CancellationToken,
187 tracker: TaskTracker,
188 next_id: AtomicU64,
189}
190
191pub struct LogExport {
192 doc: Arc<Document>,
193 lease: ReadLease,
194 provider_id: i64,
195 epoch: CacheEpoch,
196 service: Arc<LogService>,
197}
198
199struct IngestWork {
200 service: Arc<LogService>,
201 lease: ProviderLease,
202 epoch: CacheEpoch,
203 key: DocKey,
204 node: RunLogNode,
205 run: Arc<PipelineRun>,
206 steps: Vec<StepBound>,
207 revision: i64,
208 cancel: CancellationToken,
209 received: Arc<AtomicU64>,
210}
211
212impl LogService {
213 pub fn new(
214 store: Arc<LogStore>, provider_service: Arc<ProviderService>, repository: Arc<Repository>,
215 event_bus: Arc<dyn EventBus>,
216 ) -> Self {
217 Self {
218 store,
219 provider_service,
220 repository,
221 event_bus,
222 trees: RequestDeduplicator::new(),
223 tree_memo: DashMap::new(),
224 pipeline_providers: DashMap::new(),
225 inflight: DashMap::new(),
226 attempts: DashMap::new(),
227 interests: DashMap::new(),
228 lives: DashMap::new(),
229 searches: DashMap::new(),
230 ingest_permits: Arc::new(Semaphore::new(INGEST_PERMITS)),
231 search_permits: Arc::new(Semaphore::new(SEARCH_PERMITS)),
232 admission: Mutex::new(true),
233 root: CancellationToken::new(),
234 tracker: TaskTracker::new(),
235 next_id: AtomicU64::new(1),
236 }
237 }
238
239 fn admit(&self, spawn: impl FnOnce(&TaskTracker)) -> DomainResult<()> {
240 let open = self
241 .admission
242 .lock()
243 .unwrap_or_else(PoisonError::into_inner);
244 if !*open {
245 return Err(DomainError::InternalError(
246 "Pipedash is shutting down".to_string(),
247 ));
248 }
249 spawn(&self.tracker);
250
251 Ok(())
252 }
253
254 async fn provider_of(&self, pipeline_id: &str) -> DomainResult<i64> {
255 if let Some(provider_id) = self.pipeline_providers.get(pipeline_id).map(|id| *id) {
256 return Ok(provider_id);
257 }
258 let provider_id = self
259 .repository
260 .get_cached_pipelines(None)
261 .await?
262 .into_iter()
263 .find(|pipeline| pipeline.id == pipeline_id)
264 .map(|pipeline| pipeline.provider_id)
265 .ok_or_else(|| DomainError::PipelineNotFound(pipeline_id.to_string()))?;
266 self.pipeline_providers
267 .insert(pipeline_id.to_string(), provider_id);
268
269 Ok(provider_id)
270 }
271
272 async fn lease_of(&self, pipeline_id: &str) -> DomainResult<(ProviderLease, CacheEpoch)> {
273 let provider_id = self.provider_of(pipeline_id).await?;
274 let lease = match self.provider_service.acquire(provider_id).await {
275 Err(DomainError::ProviderNotFound(_)) => {
276 self.pipeline_providers.remove(pipeline_id);
277 return Err(DomainError::PipelineNotFound(pipeline_id.to_string()));
278 }
279 other => other?,
280 };
281 let epoch = self.store.epoch(provider_id);
282 if !lease.provider.capabilities().logs {
283 return Err(DomainError::NotSupported(
284 "Run logs are not available for this provider".to_string(),
285 ));
286 }
287
288 Ok((lease, epoch))
289 }
290
291 async fn doc_key(
292 &self, pipeline_id: &str, run_number: i64, node_id: &str,
293 ) -> DomainResult<DocKey> {
294 Ok(DocKey {
295 provider_id: self.provider_of(pipeline_id).await?,
296 pipeline_id: pipeline_id.to_string(),
297 run_number,
298 node_id: node_id.to_string(),
299 })
300 }
301
302 pub async fn run_log_tree(
303 self: &Arc<Self>, pipeline_id: &str, run_number: i64,
304 ) -> DomainResult<RunLogTree> {
305 let (lease, epoch) = self.lease_of(pipeline_id).await?;
306 let built = self.tree_of(&lease, epoch, pipeline_id, run_number).await?;
307 if !built.0.complete {
308 let run = (pipeline_id.to_string(), run_number);
309 self.watch(&lease, epoch, &run, None, &built)?;
310 }
311
312 Ok(RunLogTree::clone(&built.0))
313 }
314
315 async fn tree_of(
316 &self, lease: &ProviderLease, epoch: CacheEpoch, pipeline_id: &str, run_number: i64,
317 ) -> DomainResult<Built> {
318 let request = hash_request(
319 lease.id,
320 lease.generation,
321 &format!(
322 "run-log-tree:{}:{}:{pipeline_id}:{run_number}",
323 epoch.global, epoch.provider
324 ),
325 );
326 let built = self
327 .trees
328 .deduplicate(request, || {
329 self.build_tree(lease, epoch, pipeline_id, run_number)
330 })
331 .await?;
332 if self.store.epoch(lease.id) == epoch {
333 self.memoize(
334 (
335 lease.id,
336 lease.generation,
337 epoch,
338 pipeline_id.to_string(),
339 run_number,
340 ),
341 built.clone(),
342 );
343 }
344
345 Ok(built)
346 }
347
348 async fn build_tree(
349 &self, lease: &ProviderLease, epoch: CacheEpoch, pipeline_id: &str, run_number: i64,
350 ) -> DomainResult<Built> {
351 let run = self
352 .provider_call(
353 lease,
354 lease.provider.fetch_run_details(pipeline_id, run_number),
355 )
356 .await?;
357 if run.status.is_terminal() {
358 if let Some(stored) = self
359 .store
360 .read_tree(lease.id, pipeline_id, run_number)
361 .await
362 {
363 if stored.run_status == run.status && stored.run_concluded_at == run.concluded_at {
364 return Ok((Arc::new(stored.tree), Arc::new(run)));
365 }
366 }
367 }
368
369 let nodes = self
370 .provider_call(lease, lease.provider.fetch_log_nodes(&run))
371 .await?;
372 let tree = tree_from_nodes(&run, nodes);
373 if tree.complete {
374 let stored = StoredTree {
375 run_status: run.status.clone(),
376 run_concluded_at: run.concluded_at,
377 tree: tree.clone(),
378 };
379 let saved = self
380 .provider_service
381 .commit(lease, || {
382 self.store
383 .write_tree(lease.id, pipeline_id, run_number, &stored, epoch)
384 })
385 .await;
386 if let Some(Err(e)) = saved {
387 tracing::warn!(provider_id = lease.id, error = %e, "Failed to save the run log tree");
388 }
389 }
390
391 Ok((Arc::new(tree), Arc::new(run)))
392 }
393
394 fn memoize(&self, key: TreeKey, built: Built) {
395 let now = Instant::now();
396 self.tree_memo
397 .retain(|_, (at, _, _)| now.saturating_duration_since(*at) < COMPLETE_TREE_TTL);
398 self.tree_memo.insert(key, (now, built.0, built.1));
399 }
400
401 fn memoized(
402 &self, lease: &ProviderLease, epoch: CacheEpoch, pipeline_id: &str, run_number: i64,
403 ) -> Option<Built> {
404 let entry = self.tree_memo.get(&(
405 lease.id,
406 lease.generation,
407 epoch,
408 pipeline_id.to_string(),
409 run_number,
410 ))?;
411 let (at, tree, run) = entry.value();
412 let ttl = if tree.complete {
413 COMPLETE_TREE_TTL
414 } else {
415 OPEN_TREE_TTL
416 };
417
418 (Instant::now().saturating_duration_since(*at) < ttl)
419 .then(|| (Arc::clone(tree), Arc::clone(run)))
420 }
421
422 fn ensure_active(&self, provider_id: i64) -> DomainResult<()> {
423 let now = Utc::now();
424 match self
425 .provider_service
426 .scheduler()
427 .paused_until(provider_id, now)
428 {
429 Some(until) => Err(DomainError::RateLimited {
430 message: "Provider API quota or rate-limit cooldown is active".to_string(),
431 retry_after: (until - now).to_std().ok(),
432 }),
433 None => Ok(()),
434 }
435 }
436
437 async fn note_limit(&self, lease: &ProviderLease, error: &DomainError) {
438 if matches!(
439 error,
440 DomainError::RateLimited { .. } | DomainError::ServiceUnavailable { .. }
441 ) {
442 self.provider_service.check_quota(lease).await;
443 }
444 }
445
446 async fn provider_call<T>(
447 &self, lease: &ProviderLease, call: impl Future<Output = DomainResult<T>>,
448 ) -> DomainResult<T> {
449 self.ensure_active(lease.id)?;
450 let result = call.await;
451 if let Err(e) = &result {
452 self.note_limit(lease, e).await;
453 }
454
455 result
456 }
457
458 pub async fn open_document(
459 self: &Arc<Self>, pipeline_id: &str, run_number: i64, node_id: &str, retry: bool,
460 ) -> DomainResult<RunLogDocument> {
461 let (lease, epoch) = self.lease_of(pipeline_id).await?;
462 let built = match self.memoized(&lease, epoch, pipeline_id, run_number) {
463 Some(built) => built,
464 None => self.tree_of(&lease, epoch, pipeline_id, run_number).await?,
465 };
466 let (tree, run) = &built;
467 let node = tree
468 .nodes
469 .iter()
470 .find(|node| node.id == node_id)
471 .ok_or_else(|| {
472 DomainError::NotFound(format!("Job {node_id} isn't part of run #{run_number}"))
473 })?;
474 let Some(availability) = node.log else {
475 return Err(DomainError::NotFound(format!(
476 "{} has no log of its own",
477 node.name
478 )));
479 };
480 let run_key = (pipeline_id.to_string(), run_number);
481 if availability == LogAvailability::Pending {
482 self.watch(&lease, epoch, &run_key, Some(node_id), &built)?;
483 return Ok(RunLogDocument::Pending);
484 }
485
486 let key = DocKey {
487 provider_id: lease.id,
488 pipeline_id: pipeline_id.to_string(),
489 run_number,
490 node_id: node_id.to_string(),
491 };
492 let failure = self.recent_failure(&key, &lease, epoch, retry);
493 let meta = self.stored(&key, StoredDocMeta::to_meta).await?;
494 let live = availability != LogAvailability::Ready
495 || meta.as_ref().is_some_and(|meta| !meta.complete);
496 if live {
497 self.watch(&lease, epoch, &run_key, Some(node_id), &built)?;
498 }
499 if let Some(meta) = meta {
500 return Ok(RunLogDocument::Ready(meta));
501 }
502 if let Some(received_bytes) = self.running(&key, &lease, epoch) {
503 return Ok(RunLogDocument::Loading { received_bytes });
504 }
505 if let Some(message) = failure {
506 return Ok(RunLogDocument::Unavailable { message });
507 }
508
509 Ok(RunLogDocument::Loading {
510 received_bytes: self.claim(&key, lease, epoch, node, tree, Arc::clone(run))?,
511 })
512 }
513
514 async fn stored<T>(
515 &self, key: &DocKey, read: impl FnOnce(&StoredDocMeta) -> T,
516 ) -> DomainResult<Option<T>> {
517 let Some(revision) = self.store.current(key) else {
518 return Ok(None);
519 };
520
521 Ok(self
522 .store
523 .lease(key, revision)
524 .await?
525 .map(|(doc, _lease)| read(doc.meta())))
526 }
527
528 fn recent_failure(
529 &self, key: &DocKey, lease: &ProviderLease, epoch: CacheEpoch, retry: bool,
530 ) -> Option<String> {
531 let record = self.attempts.get(key)?;
532 if retry || record.generation != lease.generation || record.epoch != epoch {
533 return None;
534 }
535 let age = Instant::now().saturating_duration_since(record.at);
536
537 record.failure.clone().filter(|_| age < FAILURE_COOLDOWN)
538 }
539
540 fn running(&self, key: &DocKey, lease: &ProviderLease, epoch: CacheEpoch) -> Option<u64> {
541 let attempt = self.inflight.get(key)?;
542
543 (attempt.generation == lease.generation && attempt.epoch == epoch)
544 .then(|| attempt.received.load(Ordering::Relaxed))
545 }
546
547 fn claim(
548 self: &Arc<Self>, key: &DocKey, lease: ProviderLease, epoch: CacheEpoch, node: &RunLogNode,
549 tree: &RunLogTree, run: Arc<PipelineRun>,
550 ) -> DomainResult<u64> {
551 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
552 let cancel = self.root.child_token();
553 let received = Arc::new(AtomicU64::new(0));
554 let attempt = Attempt {
555 id,
556 generation: lease.generation,
557 epoch,
558 received: Arc::clone(&received),
559 cancel: cancel.clone(),
560 };
561
562 {
563 match self.inflight.entry(key.clone()) {
564 Entry::Occupied(mut entry) => {
565 let current = entry.get();
566 if current.generation == lease.generation && current.epoch == epoch {
567 return Ok(current.received.load(Ordering::Relaxed));
568 }
569 current.cancel.cancel();
570 entry.insert(attempt);
571 }
572 Entry::Vacant(entry) => {
573 entry.insert(attempt);
574 }
575 }
576 }
577
578 let guard = AttemptGuard {
579 service: Arc::clone(self),
580 key: key.clone(),
581 id,
582 };
583 let work = IngestWork {
584 service: Arc::clone(self),
585 lease,
586 epoch,
587 key: key.clone(),
588 node: node.clone(),
589 run,
590 steps: step_bounds(node, tree),
591 revision: self.store.allocate_revision(),
592 cancel,
593 received,
594 };
595 self.admit(move |tracker| {
596 tracker.spawn(ingest(guard, work));
597 })?;
598
599 Ok(0)
600 }
601
602 fn interrupted(
603 &self, provider_id: i64, epoch: CacheEpoch, cancel: &CancellationToken,
604 ) -> Option<Stop> {
605 let now = self.store.epoch(provider_id);
606 if self.root.is_cancelled() || now.global != epoch.global {
607 Some(Stop::Silent)
608 } else if now.provider != epoch.provider {
609 Some(Stop::ProviderChanged)
610 } else if cancel.is_cancelled() {
611 Some(Stop::Silent)
612 } else {
613 None
614 }
615 }
616
617 fn busy_runs(&self) -> HashSet<(i64, String)> {
618 self.inflight
619 .iter()
620 .map(|entry| entry.key().run_id())
621 .collect()
622 }
623
624 async fn conclude(
625 &self, guard: AttemptGuard, finished: Finished, generation: Generation, epoch: CacheEpoch,
626 partial: bool,
627 ) {
628 let key = guard.key.clone();
629 let record = |failure: Option<String>, resumable: bool| AttemptRecord {
630 generation,
631 epoch,
632 at: Instant::now(),
633 failure,
634 resumable,
635 };
636 let failed = |message: String| CoreEvent::RunLogFailed {
637 pipeline_id: key.pipeline_id.clone(),
638 run_number: key.run_number,
639 node_id: key.node_id.clone(),
640 message,
641 };
642 let event = match finished {
643 Finished::Stopped(Stop::Silent) => None,
644 Finished::Stopped(Stop::ProviderChanged) => Some(failed(PROVIDER_CHANGED.to_string())),
645 Finished::Published {
646 revision,
647 resumable,
648 } => {
649 if partial {
650 self.attempts.insert(key.clone(), record(None, resumable));
651 } else {
652 self.attempts.remove(&key);
653 }
654 Some(CoreEvent::RunLogReady {
655 pipeline_id: key.pipeline_id.clone(),
656 run_number: key.run_number,
657 node_id: key.node_id.clone(),
658 revision,
659 })
660 }
661 Finished::Unchanged { resumable } => {
662 self.attempts.insert(key.clone(), record(None, resumable));
663 None
664 }
665 Finished::Failed(message) => {
666 self.attempts
667 .insert(key.clone(), record(Some(message.clone()), false));
668 Some(failed(message))
669 }
670 };
671 drop(guard);
672 self.wake(&key);
673 if let Some(event) = event {
674 self.event_bus.emit(event).await;
675 }
676 }
677
678 async fn leased(
679 &self, pipeline_id: &str, run_number: i64, node_id: &str, revision: i64,
680 ) -> DomainResult<(DocKey, Arc<Document>, ReadLease)> {
681 let key = self.doc_key(pipeline_id, run_number, node_id).await?;
682 match self.store.lease(&key, revision).await? {
683 Some((doc, lease)) => Ok((key, doc, lease)),
684 None => Err(DomainError::NotFound(REVISION_GONE.to_string())),
685 }
686 }
687
688 fn doc_failure(&self, key: &DocKey, revision: i64, error: DocError) -> DomainError {
689 match error {
690 DocError::Corrupt => {
691 self.store.mark_corrupt(key, revision);
692 DomainError::InternalError("Log cache is corrupted".to_string())
693 }
694 DocError::Io(e) => DomainError::InternalError(format!("Couldn't read the log: {e}")),
695 DocError::Cancelled => DomainError::InternalError("Log read cancelled".to_string()),
696 }
697 }
698
699 async fn sweep(&self, reading: &DocKey) {
700 if let Err(e) = self.store.sweep_with(self.busy_runs(), Some(reading)).await {
701 tracing::warn!(error = %e, "Failed to remove stale run logs");
702 }
703 }
704
705 #[allow(clippy::too_many_arguments)]
706 pub async fn read_lines(
707 &self, pipeline_id: &str, run_number: i64, node_id: &str, revision: i64, start: u32,
708 count: u32, max_line_units: u32,
709 ) -> DomainResult<LogLinesPage> {
710 let (key, doc, lease) = self
711 .leased(pipeline_id, run_number, node_id, revision)
712 .await?;
713 let store = Arc::clone(&self.store);
714 let page = tokio::task::spawn_blocking(move || {
715 let page = doc.read_lines(store.block_cache(), start, count, max_line_units);
716 drop(lease);
717 page
718 })
719 .await
720 .map_err(|e| DomainError::InternalError(format!("Log reader stopped: {e}")))?;
721 self.sweep(&key).await;
722
723 page.map_err(|e| self.doc_failure(&key, revision, e))
724 }
725
726 pub async fn search(
727 &self, pipeline_id: &str, run_number: i64, node_id: &str, revision: i64, session_id: &str,
728 query: &LogSearchQuery,
729 ) -> DomainResult<LogSearchResult> {
730 let Some(regex) = search_regex(query)? else {
731 return Ok(LogSearchResult::default());
732 };
733
734 let (key, doc, lease) = self
735 .leased(pipeline_id, run_number, node_id, revision)
736 .await?;
737
738 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
739 let cancel = self.root.child_token();
740 let session_key = (key.clone(), session_id.to_string());
741 if let Some((_, previous)) = self
742 .searches
743 .insert(session_key.clone(), (id, cancel.clone()))
744 {
745 previous.cancel();
746 }
747 let _session = SearchSession {
748 searches: &self.searches,
749 key: session_key,
750 id,
751 cancel: cancel.clone(),
752 };
753
754 let permit = tokio::select! {
755 permit = Arc::clone(&self.search_permits).acquire_owned() => permit,
756 _ = cancel.cancelled() => return Err(search_cancelled()),
757 }
758 .map_err(|_| search_cancelled())?;
759
760 let (tx, rx) = oneshot::channel();
761 self.admit(|tracker| {
762 tracker.spawn_blocking(move || {
763 let result = doc.search(®ex, &cancel);
764 drop(lease);
765 drop(permit);
766 let _ = tx.send(result);
767 });
768 })?;
769 let result = rx.await.map_err(|_| search_cancelled())?;
770 self.sweep(&key).await;
771
772 result.map_err(|e| match e {
773 DocError::Cancelled => search_cancelled(),
774 other => self.doc_failure(&key, revision, other),
775 })
776 }
777
778 pub async fn prepare_export(
779 self: &Arc<Self>, pipeline_id: &str, run_number: i64, node_id: &str, revision: i64,
780 ) -> DomainResult<LogExport> {
781 let provider_id = self.provider_of(pipeline_id).await?;
782 let epoch = self.store.epoch(provider_id);
783 let (_, doc, lease) = self
784 .leased(pipeline_id, run_number, node_id, revision)
785 .await?;
786
787 Ok(LogExport {
788 doc,
789 lease,
790 provider_id,
791 epoch,
792 service: Arc::clone(self),
793 })
794 }
795
796 pub async fn stats(&self) -> LogCacheStats {
797 self.store.stats().await
798 }
799
800 pub async fn clear(&self) -> DomainResult<()> {
801 let cleared = self.store.clear().await;
802 for attempt in self.inflight.iter() {
803 attempt.cancel.cancel();
804 }
805 for live in self.lives.iter() {
806 live.cancel.cancel();
807 }
808 for search in self.searches.iter() {
809 search.value().1.cancel();
810 }
811 self.interests.clear();
812 self.tree_memo.clear();
813 self.attempts.clear();
814 self.searches.clear();
815
816 cleared
817 }
818
819 pub async fn shutdown(&self) {
820 {
821 let mut open = self
822 .admission
823 .lock()
824 .unwrap_or_else(PoisonError::into_inner);
825 *open = false;
826 self.root.cancel();
827 }
828 self.tracker.close();
829 if tokio::time::timeout(SHUTDOWN_PATIENCE, self.tracker.wait())
830 .await
831 .is_err()
832 {
833 tracing::warn!("Waiting for run log tasks to stop");
834 self.tracker.wait().await;
835 }
836 }
837}
838
839impl LogExport {
840 pub fn into_stream(
841 self,
842 ) -> DomainResult<impl Stream<Item = io::Result<Bytes>> + Send + 'static> {
843 let Self {
844 doc,
845 lease,
846 provider_id,
847 epoch,
848 service,
849 } = self;
850 if service.store.epoch(provider_id) != epoch {
851 return Err(DomainError::NotFound(REVISION_GONE.to_string()));
852 }
853
854 let (tx, rx) = mpsc::channel::<io::Result<Bytes>>(EXPORT_QUEUE);
855 let finished = Arc::new(AtomicBool::new(false));
856 let root = service.root.clone();
857 let done = Arc::clone(&finished);
858 service.admit(|tracker| {
859 tracker.spawn(export(doc, lease, tx, root, done));
860 })?;
861
862 Ok(futures::stream::unfold(Some(rx), move |rx| {
863 let finished = Arc::clone(&finished);
864 async move {
865 let mut rx = rx?;
866 match rx.recv().await {
867 Some(item) => Some((item, Some(rx))),
868 None if finished.load(Ordering::SeqCst) => None,
869 None => Some((Err(io::Error::other(EXPORT_INTERRUPTED)), None)),
870 }
871 }
872 }))
873 }
874}
875
876async fn export(
877 doc: Arc<Document>, lease: ReadLease, tx: mpsc::Sender<io::Result<Bytes>>,
878 root: CancellationToken, finished: Arc<AtomicBool>,
879) {
880 let _lease = lease;
881 for index in 0..doc.block_count() {
882 if root.is_cancelled() {
883 return;
884 }
885 let block = Arc::clone(&doc);
886 let item = match tokio::task::spawn_blocking(move || block.text_block(index)).await {
887 Ok(Ok(bytes)) => Ok(bytes),
888 Ok(Err(e)) => Err(io::Error::other(e.to_string())),
889 Err(e) => Err(io::Error::other(e)),
890 };
891 let failed = item.is_err();
892 tokio::select! {
893 sent = tx.send(item) => {
894 if sent.is_err() {
895 return;
896 }
897 }
898 _ = root.cancelled() => return,
899 }
900 if failed {
901 finished.store(true, Ordering::SeqCst);
902 return;
903 }
904 }
905 finished.store(true, Ordering::SeqCst);
906}
907
908async fn ingest(guard: AttemptGuard, work: IngestWork) {
909 let service = Arc::clone(&work.service);
910 let generation = work.lease.generation;
911 let epoch = work.epoch;
912 let partial = work.node.log == Some(LogAvailability::Partial);
913 let read = work.run();
914 tokio::pin!(read);
915 let finished = tokio::select! {
916 finished = &mut read => finished,
917 _ = service.interest_expired(&work.key), if partial => {
918 work.cancel.cancel();
919 read.await
920 }
921 };
922 service
923 .conclude(guard, finished, generation, epoch, partial)
924 .await;
925}
926
927impl IngestWork {
928 async fn run(&self) -> Finished {
929 let service = &self.service;
930 let provider_id = self.key.provider_id;
931 let interrupted = || service.interrupted(provider_id, self.epoch, &self.cancel);
932
933 let _permit = tokio::select! {
934 permit = Arc::clone(&service.ingest_permits).acquire_owned() => permit,
935 _ = self.cancel.cancelled() => {
936 return Finished::Stopped(interrupted().unwrap_or(Stop::Silent));
937 }
938 };
939 if let Some(stop) = interrupted() {
940 return Finished::Stopped(stop);
941 }
942
943 if let Err(e) = service.ensure_active(provider_id) {
944 return Finished::Failed(failure_message(&e));
945 }
946
947 let base = match service.store.base(&self.key).await {
948 Ok(base) => base,
949 Err(e) => return Finished::Failed(e.to_string()),
950 };
951 let tmp = match service.store.tmp_dir().await {
952 Ok(tmp) => tmp,
953 Err(e) => return Finished::Failed(e.to_string()),
954 };
955 let finished = self.transfer(&tmp, base).await;
956 if !matches!(finished, Finished::Published { .. }) {
957 service.store.discard(tmp).await;
958 }
959
960 finished
961 }
962
963 async fn transfer(&self, tmp: &Path, base: Option<Base>) -> Finished {
964 let service = &self.service;
965 let key = &self.key;
966 let provider_id = key.provider_id;
967 let interrupted = || service.interrupted(provider_id, self.epoch, &self.cancel);
968 let expected = base.as_ref().map(|base| base.revision);
969 let from = base
970 .as_ref()
971 .and_then(|base| base.cursor(&self.steps))
972 .cloned();
973
974 let (tx, mut rx) = mpsc::channel::<Bytes>(INGEST_QUEUE);
975 let mut sink = LogSink::new(tx);
976 let ingester = Ingester {
977 key: key.clone(),
978 revision: self.revision,
979 complete: self.node.log == Some(LogAvailability::Ready),
980 dir: tmp.to_path_buf(),
981 dialect: self.lease.provider.log_dialect(),
982 steps: self.steps.clone(),
983 received: Arc::clone(&self.received),
984 limits: BlockLimits::default(),
985 base,
986 restart: sink.restart_signal(),
987 };
988 let writer = tokio::task::spawn_blocking(move || ingester.run(&mut rx));
989
990 let read_result = {
991 let read = async {
992 let result = self
993 .lease
994 .provider
995 .read_log(&self.run, &key.node_id, from.as_ref(), &mut sink)
996 .await;
997 drop(sink);
998 result
999 };
1000 tokio::pin!(read);
1001
1002 let mut ticker = tokio::time::interval(PROGRESS_TICK);
1003 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1004 let mut reported = 0;
1005 loop {
1006 tokio::select! {
1007 result = &mut read => break Some(result),
1008 _ = self.cancel.cancelled() => break None,
1009 _ = ticker.tick() => {
1010 let total = self.received.load(Ordering::Relaxed);
1011 if total != reported {
1012 reported = total;
1013 service
1014 .event_bus
1015 .emit(CoreEvent::RunLogProgress {
1016 pipeline_id: key.pipeline_id.clone(),
1017 run_number: key.run_number,
1018 node_id: key.node_id.clone(),
1019 received_bytes: total,
1020 })
1021 .await;
1022 }
1023 if service.store.epoch(provider_id) != self.epoch {
1024 self.cancel.cancel();
1025 }
1026 }
1027 }
1028 }
1029 };
1030
1031 let written = writer.await;
1032 if let Some(stop) = interrupted() {
1033 return Finished::Stopped(stop);
1034 }
1035 let ingested = match written {
1036 Ok(Ok(ingested)) => ingested,
1037 Ok(Err(e)) => return Finished::Failed(format!("Couldn't save the log: {e}")),
1038 Err(e) => return Finished::Failed(format!("Couldn't save the log: {e}")),
1039 };
1040 let read = match read_result {
1041 Some(Ok(read)) => Some(read),
1042 Some(Err(e)) if !ingested.truncated() => {
1043 service.note_limit(&self.lease, &e).await;
1044 return Finished::Failed(failure_message(&e));
1045 }
1046 _ => None,
1047 };
1048 if let Some(read) = &read {
1049 if ingested.resumed() != (read.mode == LogReadMode::Append) {
1050 return Finished::Failed(PROVIDER_MISBEHAVED.to_string());
1051 }
1052 if ingested.unchanged(read) {
1053 return Finished::Unchanged {
1054 resumable: read.cursor.is_some(),
1055 };
1056 }
1057 }
1058
1059 let cursor = read.and_then(|read| read.cursor);
1060 let outcome = match tokio::task::spawn_blocking(move || ingested.finish(cursor)).await {
1061 Ok(Ok(outcome)) => outcome,
1062 Ok(Err(e)) => return Finished::Failed(format!("Couldn't save the log: {e}")),
1063 Err(e) => return Finished::Failed(format!("Couldn't save the log: {e}")),
1064 };
1065
1066 let busy = service.busy_runs();
1067 let committed = service
1068 .provider_service
1069 .commit(&self.lease, || {
1070 service
1071 .store
1072 .publish(key, tmp, self.revision, self.epoch, expected, busy)
1073 })
1074 .await;
1075 match committed {
1076 Some(Ok(PublishOutcome::Published)) => Finished::Published {
1077 revision: self.revision,
1078 resumable: outcome.resumable,
1079 },
1080 Some(Ok(PublishOutcome::StaleEpoch)) => {
1081 Finished::Stopped(interrupted().unwrap_or(Stop::ProviderChanged))
1082 }
1083 Some(Ok(PublishOutcome::StaleBase)) => Finished::Unchanged { resumable: false },
1084 Some(Err(e)) => Finished::Failed(e.to_string()),
1085 None => Finished::Stopped(Stop::ProviderChanged),
1086 }
1087 }
1088}
1089
1090fn search_cancelled() -> DomainError {
1091 DomainError::InternalError("Search cancelled".to_string())
1092}
1093
1094fn failure_message(error: &DomainError) -> String {
1095 match error {
1096 DomainError::PipelineNotFound(_) | DomainError::NotFound(_) => {
1097 "The provider no longer has this log. It may have expired.".to_string()
1098 }
1099 DomainError::RateLimited {
1100 retry_after: Some(wait),
1101 ..
1102 } => format!(
1103 "Rate limit reached. Try again in {} min.",
1104 wait.as_millis().div_ceil(60_000).max(1)
1105 ),
1106 DomainError::RateLimited {
1107 retry_after: None, ..
1108 } => "Rate limit reached. Try again later.".to_string(),
1109 other => other.to_string(),
1110 }
1111}
1112
1113fn step_bounds(node: &RunLogNode, tree: &RunLogTree) -> Vec<StepBound> {
1114 tree.nodes
1115 .iter()
1116 .filter(|child| {
1117 child.kind == LogNodeKind::Step
1118 && child.log.is_none()
1119 && child.parent_id.as_deref() == Some(node.id.as_str())
1120 })
1121 .map(|step| StepBound {
1122 node_id: step.id.clone(),
1123 title: step.name.clone(),
1124 status: step.status.clone(),
1125 started_ms: step.started_at.map(|at| at.timestamp_millis()),
1126 finished_ms: step.finished_at.map(|at| at.timestamp_millis()),
1127 })
1128 .collect()
1129}
1130
1131fn tree_from_nodes(run: &PipelineRun, nodes: Vec<LogNode>) -> RunLogTree {
1132 let mut tree: Vec<RunLogNode> = Vec::with_capacity(nodes.len());
1133 let mut index = HashMap::with_capacity(nodes.len());
1134 for node in nodes {
1135 if index.contains_key(&node.id) {
1136 tracing::warn!(
1137 run_number = run.run_number,
1138 "Duplicate run log node ID ignored"
1139 );
1140 continue;
1141 }
1142 let parent = node
1143 .parent_id
1144 .as_ref()
1145 .and_then(|parent| index.get(parent).copied());
1146 let depth = parent.map_or(0, |parent: usize| tree[parent].depth.saturating_add(1));
1147 let duration_seconds = match (node.started_at, node.finished_at) {
1148 (Some(started), Some(finished)) => Some((finished - started).num_seconds()),
1149 _ => None,
1150 };
1151 index.insert(node.id.clone(), tree.len());
1152 tree.push(RunLogNode {
1153 navigable: node.log.is_some(),
1154 parent_id: parent.map(|_| node.parent_id.clone()).unwrap_or_default(),
1155 id: node.id,
1156 kind: node.kind,
1157 name: node.name,
1158 status: node.status,
1159 started_at: node.started_at,
1160 finished_at: node.finished_at,
1161 duration_seconds,
1162 log: node.log,
1163 web_url: node.web_url,
1164 depth,
1165 });
1166 }
1167
1168 for position in (0..tree.len()).rev() {
1169 if !tree[position].navigable {
1170 continue;
1171 }
1172 let parent = tree[position]
1173 .parent_id
1174 .as_ref()
1175 .and_then(|parent| index.get(parent).copied());
1176 if let Some(parent) = parent {
1177 tree[parent].navigable = true;
1178 }
1179 }
1180
1181 let complete = run.status.is_terminal()
1182 && tree.iter().all(|node| {
1183 node.status.is_terminal()
1184 && !matches!(
1185 node.log,
1186 Some(LogAvailability::Partial | LogAvailability::Pending)
1187 )
1188 });
1189 let with_log = || tree.iter().filter(|node| node.log.is_some());
1190 let default_node_id = with_log()
1191 .find(|node| node.status == PipelineStatus::Failed)
1192 .or_else(|| {
1193 with_log().find(|node| {
1194 matches!(
1195 node.log,
1196 Some(LogAvailability::Ready | LogAvailability::Partial)
1197 )
1198 })
1199 })
1200 .or_else(|| with_log().next())
1201 .map(|node| node.id.clone());
1202
1203 RunLogTree {
1204 nodes: tree,
1205 default_node_id,
1206 complete,
1207 }
1208}
1209
1210mod live;
1211
1212#[cfg(test)]
1213mod tests;