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 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;