Skip to main content

pipedash_core/application/services/
log_service.rs

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(&regex, &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 finished = work.run().await;
914    service
915        .conclude(guard, finished, generation, epoch, partial)
916        .await;
917}
918
919impl IngestWork {
920    async fn run(&self) -> Finished {
921        let service = &self.service;
922        let provider_id = self.key.provider_id;
923        let interrupted = || service.interrupted(provider_id, self.epoch, &self.cancel);
924
925        let _permit = tokio::select! {
926            permit = Arc::clone(&service.ingest_permits).acquire_owned() => permit,
927            _ = self.cancel.cancelled() => {
928                return Finished::Stopped(interrupted().unwrap_or(Stop::Silent));
929            }
930        };
931        if let Some(stop) = interrupted() {
932            return Finished::Stopped(stop);
933        }
934
935        if let Err(e) = service.ensure_active(provider_id) {
936            return Finished::Failed(failure_message(&e));
937        }
938
939        let base = match service.store.base(&self.key).await {
940            Ok(base) => base,
941            Err(e) => return Finished::Failed(e.to_string()),
942        };
943        let tmp = match service.store.tmp_dir().await {
944            Ok(tmp) => tmp,
945            Err(e) => return Finished::Failed(e.to_string()),
946        };
947        let finished = self.transfer(&tmp, base).await;
948        if !matches!(finished, Finished::Published { .. }) {
949            service.store.discard(tmp).await;
950        }
951
952        finished
953    }
954
955    async fn transfer(&self, tmp: &Path, base: Option<Base>) -> Finished {
956        let service = &self.service;
957        let key = &self.key;
958        let provider_id = key.provider_id;
959        let interrupted = || service.interrupted(provider_id, self.epoch, &self.cancel);
960        let expected = base.as_ref().map(|base| base.revision);
961        let from = base
962            .as_ref()
963            .and_then(|base| base.cursor(&self.steps))
964            .cloned();
965
966        let (tx, mut rx) = mpsc::channel::<Bytes>(INGEST_QUEUE);
967        let mut sink = LogSink::new(tx);
968        let ingester = Ingester {
969            key: key.clone(),
970            revision: self.revision,
971            complete: self.node.log == Some(LogAvailability::Ready),
972            dir: tmp.to_path_buf(),
973            dialect: self.lease.provider.log_dialect(),
974            steps: self.steps.clone(),
975            received: Arc::clone(&self.received),
976            limits: BlockLimits::default(),
977            base,
978            restart: sink.restart_signal(),
979        };
980        let writer = tokio::task::spawn_blocking(move || ingester.run(&mut rx));
981
982        let read_result = {
983            let read = async {
984                let result = self
985                    .lease
986                    .provider
987                    .read_log(&self.run, &key.node_id, from.as_ref(), &mut sink)
988                    .await;
989                drop(sink);
990                result
991            };
992            tokio::pin!(read);
993
994            let mut ticker = tokio::time::interval(PROGRESS_TICK);
995            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
996            let mut reported = 0;
997            loop {
998                tokio::select! {
999                    result = &mut read => break Some(result),
1000                    _ = self.cancel.cancelled() => break None,
1001                    _ = ticker.tick() => {
1002                        let total = self.received.load(Ordering::Relaxed);
1003                        if total != reported {
1004                            reported = total;
1005                            service
1006                                .event_bus
1007                                .emit(CoreEvent::RunLogProgress {
1008                                    pipeline_id: key.pipeline_id.clone(),
1009                                    run_number: key.run_number,
1010                                    node_id: key.node_id.clone(),
1011                                    received_bytes: total,
1012                                })
1013                                .await;
1014                        }
1015                        if service.store.epoch(provider_id) != self.epoch {
1016                            self.cancel.cancel();
1017                        }
1018                    }
1019                }
1020            }
1021        };
1022
1023        let written = writer.await;
1024        if let Some(stop) = interrupted() {
1025            return Finished::Stopped(stop);
1026        }
1027        let ingested = match written {
1028            Ok(Ok(ingested)) => ingested,
1029            Ok(Err(e)) => return Finished::Failed(format!("Couldn't save the log: {e}")),
1030            Err(e) => return Finished::Failed(format!("Couldn't save the log: {e}")),
1031        };
1032        let read = match read_result {
1033            Some(Ok(read)) => Some(read),
1034            Some(Err(e)) if !ingested.truncated() => {
1035                service.note_limit(&self.lease, &e).await;
1036                return Finished::Failed(failure_message(&e));
1037            }
1038            _ => None,
1039        };
1040        if let Some(read) = &read {
1041            if ingested.resumed() != (read.mode == LogReadMode::Append) {
1042                return Finished::Failed(PROVIDER_MISBEHAVED.to_string());
1043            }
1044            if ingested.unchanged(read) {
1045                return Finished::Unchanged {
1046                    resumable: read.cursor.is_some(),
1047                };
1048            }
1049        }
1050
1051        let cursor = read.and_then(|read| read.cursor);
1052        let outcome = match tokio::task::spawn_blocking(move || ingested.finish(cursor)).await {
1053            Ok(Ok(outcome)) => outcome,
1054            Ok(Err(e)) => return Finished::Failed(format!("Couldn't save the log: {e}")),
1055            Err(e) => return Finished::Failed(format!("Couldn't save the log: {e}")),
1056        };
1057
1058        let busy = service.busy_runs();
1059        let committed = service
1060            .provider_service
1061            .commit(&self.lease, || {
1062                service
1063                    .store
1064                    .publish(key, tmp, self.revision, self.epoch, expected, busy)
1065            })
1066            .await;
1067        match committed {
1068            Some(Ok(PublishOutcome::Published)) => Finished::Published {
1069                revision: self.revision,
1070                resumable: outcome.resumable,
1071            },
1072            Some(Ok(PublishOutcome::StaleEpoch)) => {
1073                Finished::Stopped(interrupted().unwrap_or(Stop::ProviderChanged))
1074            }
1075            Some(Ok(PublishOutcome::StaleBase)) => Finished::Unchanged { resumable: false },
1076            Some(Err(e)) => Finished::Failed(e.to_string()),
1077            None => Finished::Stopped(Stop::ProviderChanged),
1078        }
1079    }
1080}
1081
1082fn search_cancelled() -> DomainError {
1083    DomainError::InternalError("Search cancelled".to_string())
1084}
1085
1086fn failure_message(error: &DomainError) -> String {
1087    match error {
1088        DomainError::PipelineNotFound(_) | DomainError::NotFound(_) => {
1089            "The provider no longer has this log. It may have expired.".to_string()
1090        }
1091        DomainError::RateLimited {
1092            retry_after: Some(wait),
1093            ..
1094        } => format!(
1095            "Rate limit reached. Try again in {} min.",
1096            wait.as_millis().div_ceil(60_000).max(1)
1097        ),
1098        DomainError::RateLimited {
1099            retry_after: None, ..
1100        } => "Rate limit reached. Try again later.".to_string(),
1101        other => other.to_string(),
1102    }
1103}
1104
1105fn step_bounds(node: &RunLogNode, tree: &RunLogTree) -> Vec<StepBound> {
1106    tree.nodes
1107        .iter()
1108        .filter(|child| {
1109            child.kind == LogNodeKind::Step
1110                && child.log.is_none()
1111                && child.parent_id.as_deref() == Some(node.id.as_str())
1112        })
1113        .map(|step| StepBound {
1114            node_id: step.id.clone(),
1115            title: step.name.clone(),
1116            status: step.status.clone(),
1117            started_ms: step.started_at.map(|at| at.timestamp_millis()),
1118            finished_ms: step.finished_at.map(|at| at.timestamp_millis()),
1119        })
1120        .collect()
1121}
1122
1123fn tree_from_nodes(run: &PipelineRun, nodes: Vec<LogNode>) -> RunLogTree {
1124    let mut tree: Vec<RunLogNode> = Vec::with_capacity(nodes.len());
1125    let mut index = HashMap::with_capacity(nodes.len());
1126    for node in nodes {
1127        if index.contains_key(&node.id) {
1128            tracing::warn!(
1129                run_number = run.run_number,
1130                "Duplicate run log node ID ignored"
1131            );
1132            continue;
1133        }
1134        let parent = node
1135            .parent_id
1136            .as_ref()
1137            .and_then(|parent| index.get(parent).copied());
1138        let depth = parent.map_or(0, |parent: usize| tree[parent].depth.saturating_add(1));
1139        let duration_seconds = match (node.started_at, node.finished_at) {
1140            (Some(started), Some(finished)) => Some((finished - started).num_seconds()),
1141            _ => None,
1142        };
1143        index.insert(node.id.clone(), tree.len());
1144        tree.push(RunLogNode {
1145            navigable: node.log.is_some(),
1146            parent_id: parent.map(|_| node.parent_id.clone()).unwrap_or_default(),
1147            id: node.id,
1148            kind: node.kind,
1149            name: node.name,
1150            status: node.status,
1151            started_at: node.started_at,
1152            finished_at: node.finished_at,
1153            duration_seconds,
1154            log: node.log,
1155            web_url: node.web_url,
1156            depth,
1157        });
1158    }
1159
1160    for position in (0..tree.len()).rev() {
1161        if !tree[position].navigable {
1162            continue;
1163        }
1164        let parent = tree[position]
1165            .parent_id
1166            .as_ref()
1167            .and_then(|parent| index.get(parent).copied());
1168        if let Some(parent) = parent {
1169            tree[parent].navigable = true;
1170        }
1171    }
1172
1173    let complete = run.status.is_terminal()
1174        && tree.iter().all(|node| {
1175            node.status.is_terminal()
1176                && !matches!(
1177                    node.log,
1178                    Some(LogAvailability::Partial | LogAvailability::Pending)
1179                )
1180        });
1181    let with_log = || tree.iter().filter(|node| node.log.is_some());
1182    let default_node_id = with_log()
1183        .find(|node| node.status == PipelineStatus::Failed)
1184        .or_else(|| {
1185            with_log().find(|node| {
1186                matches!(
1187                    node.log,
1188                    Some(LogAvailability::Ready | LogAvailability::Partial)
1189                )
1190            })
1191        })
1192        .or_else(|| with_log().next())
1193        .map(|node| node.id.clone());
1194
1195    RunLogTree {
1196        nodes: tree,
1197        default_node_id,
1198        complete,
1199    }
1200}
1201
1202mod live;
1203
1204#[cfg(test)]
1205mod tests;