Skip to main content

dial9_core/worker/
mod.rs

1//! The segment-processing worker.
2//!
3//! `WorkerLoop` is the consumer side of the bus: it drains sealed segments
4//! and runs each through a [`SegmentProcessor`](crate::pipeline::SegmentProcessor)
5//! pipeline (compress, symbolize, upload, write-back). It is the generic
6//! executor of the pipeline trait; the processors themselves are supplied by
7//! the caller.
8//!
9//! Behind the `pipeline` feature: the worker builds a tokio runtime to drive
10//! the async processors. Core's default build stays runtime-agnostic without it.
11
12pub(crate) mod metrics;
13pub(crate) mod pipeline_metrics;
14/// Built-in segment processors (gzip, write-back).
15pub mod processors;
16
17use crate::dump::{DumpError, DumpReceipt, DumpRequest, Lookback};
18use crate::fs::{EpochWindow, Fs, RemoveReason, TakenFiles, TakenSegment};
19use crate::pipeline::{ProcessErrorKind, SegmentData, SegmentProcessor};
20use crate::rate_limit::rate_limited;
21use crate::sealed::{self, SegmentRef};
22use crate::worker::metrics::{Operation, SegmentProcessMetrics, WorkerCycleMetrics};
23use crate::worker::pipeline_metrics::{MetriqueResult, PipelineMetrics, StageMetrics};
24use futures_util::FutureExt;
25use metrique::timers::Timer;
26use metrique::writer::BoxEntrySink;
27use std::collections::HashMap;
28use std::io;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32
33pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1);
34
35/// Configuration for the in-process worker pipeline.
36///
37/// The pipeline is composed of a sequence of [`SegmentProcessor`]s supplied
38/// via `processors`. When none are provided the worker runs no processing.
39#[derive(bon::Builder)]
40#[builder(on(String, into))]
41pub struct BackgroundTaskConfig {
42    /// Directory holding the trace segments. `None` for the in-memory backend.
43    #[builder(into)]
44    trace_dir: Option<PathBuf>,
45    /// Segment filename stem (e.g. `trace`). `None` for the in-memory backend.
46    trace_stem: Option<String>,
47    /// How often the worker checks for sealed segments. Defaults to 1 second.
48    #[builder(default = DEFAULT_POLL_INTERVAL)]
49    poll_interval: Duration,
50    /// The processor pipeline executed for each sealed segment, in order.
51    #[builder(default)]
52    processors: Vec<Box<dyn SegmentProcessor>>,
53    /// Metrics sink. Defaults to [`DevNullSink`](metrique::writer::sink::DevNullSink).
54    #[builder(default = metrique::writer::sink::DevNullSink::boxed())]
55    metrics_sink: BoxEntrySink,
56    /// On-demand dump trigger receiver. When present the worker runs in
57    /// triggered mode (see [`crate::dump`]): segments accumulate in the ring
58    /// and the pipeline only runs on an explicit dump request. Wired by the
59    /// facade builder; absent for continuous processing.
60    trigger: Option<crate::dump::DumpRx>,
61}
62
63impl std::fmt::Debug for BackgroundTaskConfig {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("BackgroundTaskConfig")
66            .field("trace_dir", &self.trace_dir)
67            .field("trace_stem", &self.trace_stem)
68            .field("poll_interval", &self.poll_interval)
69            .finish_non_exhaustive()
70    }
71}
72
73impl BackgroundTaskConfig {
74    /// How often the worker checks for sealed segments.
75    pub fn poll_interval(&self) -> Duration {
76        self.poll_interval
77    }
78
79    /// Directory containing trace segments. `.` for the memory backend.
80    pub fn trace_dir(&self) -> &Path {
81        match self.trace_dir.as_deref() {
82            Some(dir) if !dir.as_os_str().is_empty() => dir,
83            _ => Path::new("."),
84        }
85    }
86
87    /// Segment filename stem, e.g. `trace` for `trace.0.bin`. `"trace"` for the
88    /// memory backend (which has no on-disk segments to match).
89    pub fn trace_stem(&self) -> &str {
90        self.trace_stem.as_deref().unwrap_or("trace")
91    }
92}
93
94/// The worker loop function. Runs on a dedicated thread, polls for sealed
95/// segments and processes them through the configured pipeline.
96///
97/// Creates a single-threaded tokio runtime for async processors (e.g. S3 upload).
98/// The worker is a "good citizen": it will lose data rather than disrupt the application.
99pub(crate) fn run_background_task(
100    mut config: BackgroundTaskConfig,
101    shutdown: tokio::sync::oneshot::Receiver<Duration>,
102    fs: Arc<Fs>,
103) {
104    let rt = tokio::runtime::Builder::new_current_thread()
105        .thread_name("dial9-worker-rt")
106        .enable_all()
107        .build()
108        .expect("failed to create worker runtime");
109
110    let processors = std::mem::take(&mut config.processors);
111    let metrics_sink = config.metrics_sink.clone();
112    let trigger = config.trigger.take();
113
114    tracing::info!(target: "dial9_worker", dir = %config.trace_dir().display(), stem = %config.trace_stem(), processors = processors.len(), triggered = trigger.is_some(), "worker started");
115    rt.block_on(async {
116        let stop = tokio_util::sync::CancellationToken::new();
117        let worker_stop = stop.clone();
118        let worker = async move {
119            let mut worker = WorkerLoop::new(
120                fs,
121                config.poll_interval(),
122                processors,
123                worker_stop,
124                metrics_sink,
125                trigger,
126            )
127            .await?;
128            worker.run().await;
129            io::Result::Ok(())
130        };
131        let mut run_fut =
132            std::pin::pin!(std::panic::AssertUnwindSafe(worker).catch_unwind());
133        // Poll the worker until we receive a shutdown signal with a drain timeout.
134        let drain_timeout = tokio::select! {
135            result = &mut run_fut => {
136                match result {
137                    Ok(Ok(())) => {}
138                    Ok(Err(error)) => {
139                        tracing::error!(target: "dial9_worker", %error, "worker initialization failed");
140                    }
141                    Err(_) => {
142                        tracing::error!(target: "dial9_worker", "worker panicked");
143                    }
144                }
145                return;
146            }
147            msg = shutdown => msg.unwrap_or(Duration::ZERO),
148        };
149        tracing::info!(target: "dial9_worker", ?drain_timeout, "stop signal received, draining");
150        // Tell the worker to exit after its current processing cycle.
151        stop.cancel();
152        // Give it `drain_timeout` to finish; after that, drop the future.
153        match tokio::time::timeout(drain_timeout, run_fut).await {
154            Ok(Ok(Ok(()))) => tracing::info!(target: "dial9_worker", "drain complete"),
155            Ok(Ok(Err(error))) => {
156                tracing::error!(target: "dial9_worker", %error, "worker initialization failed");
157            }
158            Ok(Err(_)) => tracing::error!(target: "dial9_worker", "worker panicked"),
159            Err(_) => tracing::warn!(target: "dial9_worker", "drain timed out"),
160        }
161    });
162    tracing::info!(target: "dial9_worker", "worker stopped");
163}
164
165/// Spawn the segment-processing worker on a dedicated thread, draining
166/// `writer`'s sealed segments through `config`'s processor pipeline. Returns the
167/// thread handle, or `None` when `writer` has no filesystem backend.
168/// `thread_init` runs on the worker thread before the loop and returns
169/// a teardown closure run after it (e.g. profiler register/unregister).
170///
171/// Owns the fs handoff so callers never touch the writer's storage backend.
172pub(crate) fn spawn<M, Init, Teardown>(
173    writer: &crate::buffer::SegmentWriter<M>,
174    config: BackgroundTaskConfig,
175    shutdown: tokio::sync::oneshot::Receiver<Duration>,
176    thread_init: Init,
177) -> Option<crate::primitives::thread::JoinHandle<()>>
178where
179    M: crate::buffer::BufferMode,
180    Init: FnOnce() -> Teardown + Send + 'static,
181    Teardown: FnOnce(),
182{
183    let fs = writer.fs_handle()?;
184    Some(crate::primitives::thread::spawn_named(
185        "dial9-worker",
186        move || {
187            let teardown = thread_init();
188            run_background_task(config, shutdown, fs);
189            teardown();
190        },
191    ))
192}
193
194/// Consumer side of the bus: drains sealed segments and runs each through the
195/// configured [`SegmentProcessor`] pipeline. Built and driven by
196/// [`run_background_task`].
197pub(crate) struct WorkerLoop {
198    fs: Arc<Fs>,
199    poll_interval: Duration,
200    processors: Vec<Box<dyn SegmentProcessor>>,
201    metrics_sink: BoxEntrySink,
202    /// When cancelled, the worker finishes its current cycle and exits
203    /// instead of sleeping.
204    stop: tokio_util::sync::CancellationToken,
205    /// Present: on-demand operation, segments only run through the
206    /// pipeline when a dump is requested. Absent: continuous processing.
207    trigger: Option<crate::dump::DumpRx>,
208    /// Triggered mode, disk backend only: `(creation, seal)` epochs of
209    /// segments already inspected and found outside every active window, so
210    /// their files are not re-read on each pass. Entries leave when the
211    /// segment is processed or removed, and each matching pass prunes
212    /// entries for files no longer on disk (writer-evicted).
213    epoch_cache: HashMap<u32, (u64, u64)>,
214}
215
216/// A dump registered with the triggered worker, accumulating receipt state
217/// while its window collects segments.
218struct ActiveDump {
219    id: crate::dump::DumpId,
220    triggered_at: SystemTime,
221    window: EpochWindow,
222    /// `Some` iff a non-zero look-forward was requested; the dump stays
223    /// registered until this elapses.
224    deadline: Option<tokio::time::Instant>,
225    metadata: Vec<(String, String)>,
226    receipt_tx: Option<tokio::sync::oneshot::Sender<Result<DumpReceipt, DumpError>>>,
227    segments_processed: usize,
228    first_epoch: Option<u64>,
229    last_epoch: Option<u64>,
230    first_error: Option<ProcessErrorKind>,
231}
232
233impl ActiveDump {
234    fn register(req: DumpRequest) -> Self {
235        let trigger_epoch = req
236            .triggered_at
237            .duration_since(UNIX_EPOCH)
238            .unwrap_or_default()
239            .as_secs();
240        let start_secs = match req.lookback {
241            Lookback::Unbounded => None,
242            Lookback::Window(d) => Some(trigger_epoch.saturating_sub(d.as_secs())),
243        };
244        let window = EpochWindow {
245            start_secs,
246            end_secs: trigger_epoch.saturating_add(req.lookforward.as_secs()),
247        };
248        let deadline = (!req.lookforward.is_zero()).then(|| {
249            // Anchor at trigger time so worker pickup latency does not
250            // extend the forward window.
251            let elapsed = req.triggered_at.elapsed().unwrap_or_default();
252            tokio::time::Instant::now() + req.lookforward.saturating_sub(elapsed)
253        });
254        Self {
255            id: req.id,
256            triggered_at: req.triggered_at,
257            window,
258            deadline,
259            metadata: req.metadata,
260            receipt_tx: Some(req.receipt_tx),
261            segments_processed: 0,
262            first_epoch: None,
263            last_epoch: None,
264            first_error: None,
265        }
266    }
267
268    /// Whether the dump can resolve once no matching work remains: no
269    /// forward window, or its deadline elapsed.
270    fn due(&self, now: tokio::time::Instant) -> bool {
271        self.deadline.is_none_or(|d| now >= d)
272    }
273
274    /// Actual covered span: the captured segments' epoch extent, or the
275    /// trigger instant for an empty dump.
276    fn time_range(&self) -> (SystemTime, SystemTime) {
277        match (self.first_epoch, self.last_epoch) {
278            (Some(first), Some(last)) => (epoch_to_system(first), epoch_to_system(last)),
279            _ => (self.triggered_at, self.triggered_at),
280        }
281    }
282
283    /// Total failure: a captured segment failed terminally and nothing made
284    /// it through. Drives both the `Err` receipt and the S3 stage skipping
285    /// the manifest.
286    fn failed(&self) -> bool {
287        self.first_error.is_some() && self.segments_processed == 0
288    }
289
290    /// The completion signal handed to each stage's `finalize_dump`.
291    fn completion(&self) -> crate::dump::DumpCompletion {
292        crate::dump::DumpCompletion {
293            dump_id: self.id,
294            triggered_at: self.triggered_at,
295            time_range: self.time_range(),
296            segments_processed: self.segments_processed,
297            metadata: self.metadata.clone(),
298            failed: self.failed(),
299        }
300    }
301
302    /// Best-effort policy: `Ok` whenever anything succeeded or nothing
303    /// failed; `Err(Pipeline)` only on total failure.
304    fn into_result(
305        mut self,
306        manifest_key: Option<String>,
307    ) -> (
308        tokio::sync::oneshot::Sender<Result<DumpReceipt, DumpError>>,
309        Result<DumpReceipt, DumpError>,
310    ) {
311        let tx = self
312            .receipt_tx
313            .take()
314            .expect("receipt_tx only taken at resolution");
315        let result = match (self.failed(), self.first_error.take()) {
316            (true, Some(kind)) => Err(DumpError::Pipeline(kind)),
317            _ => Ok(DumpReceipt {
318                dump_id: self.id,
319                segments_processed: self.segments_processed,
320                finished_at: SystemTime::now(),
321                time_range: self.time_range(),
322                manifest_key,
323            }),
324        };
325        (tx, result)
326    }
327}
328
329fn epoch_to_system(epoch_secs: u64) -> SystemTime {
330    UNIX_EPOCH + Duration::from_secs(epoch_secs)
331}
332
333/// What one matching pass achieved, used by the triggered drain loop to
334/// decide between another pass, bailing to the select (retry pacing), and
335/// declaring the windows quiesced.
336#[derive(Debug, Default)]
337struct PassStats {
338    /// Window-matched segments that reached a terminal outcome (pipeline
339    /// success, terminal failure, eviction, panic, retry budget spent).
340    matched_done: usize,
341    /// Ids of the dumps matched by segments actually re-enqueued after a
342    /// retryable failure; those dumps stay open until the retry settles.
343    retry_dump_ids: Vec<crate::dump::DumpId>,
344    /// Segments that passed window matching and entered the pipeline.
345    entered_pipeline: usize,
346}
347
348/// Record a terminal pipeline error against every matched dump that has none
349/// yet. The first dump takes `kind` itself; the rest get an `Io`-wrapped
350/// copy of its message (`ProcessErrorKind` is not `Clone`).
351fn record_dump_error(dumps: &mut [ActiveDump], matched: &[usize], kind: ProcessErrorKind) {
352    let msg = kind.to_string();
353    let mut kind = Some(kind);
354    for &i in matched {
355        let d = &mut dumps[i];
356        if d.first_error.is_none() {
357            d.first_error = Some(
358                kind.take()
359                    .unwrap_or_else(|| ProcessErrorKind::Io(io::Error::other(msg.clone()))),
360            );
361        }
362    }
363}
364
365impl WorkerLoop {
366    pub(crate) async fn new(
367        fs: Arc<Fs>,
368        poll_interval: Duration,
369        mut processors: Vec<Box<dyn SegmentProcessor>>,
370        stop: tokio_util::sync::CancellationToken,
371        metrics_sink: BoxEntrySink,
372        trigger: Option<crate::dump::DumpRx>,
373    ) -> io::Result<Self> {
374        for processor in &mut processors {
375            let processor_name = processor.name();
376            tracing::debug!(
377                target: "dial9_worker",
378                processor = processor_name,
379                "initializing processor"
380            );
381            processor.initialize().await.map_err(|error| {
382                io::Error::new(
383                    error.kind(),
384                    format!("processor {processor_name} initialization failed: {error}"),
385                )
386            })?;
387        }
388
389        Ok(Self {
390            fs,
391            poll_interval,
392            processors,
393            metrics_sink,
394            stop,
395            trigger,
396            epoch_cache: HashMap::new(),
397        })
398    }
399
400    pub(crate) async fn run(&mut self) {
401        match self.trigger.take() {
402            None => self.run_continuous().await,
403            Some(rx) => self.run_triggered(rx).await,
404        }
405    }
406
407    async fn run_continuous(&mut self) {
408        loop {
409            let taken = self.fs.take_files();
410            let dispatched = taken.segments.len() as u64;
411            self.emit_cycle_metrics(&taken, dispatched);
412            self.process_segments(taken.segments, &mut []).await;
413
414            if self.stop.is_cancelled() || self.fs.writer_done() {
415                // Drain-to-empty: keep popping until the ring/directory is clear.
416                // Ordering invariant: writer calls mark_writer_done (Release) after
417                // the seal-time queue push, so any late-racing push is visible here.
418                loop {
419                    let taken = self.fs.take_files();
420                    let dispatched = taken.segments.len() as u64;
421                    self.emit_cycle_metrics(&taken, dispatched);
422                    if taken.segments.is_empty() {
423                        tracing::debug!(target: "dial9_worker", "Exiting run loop: drain complete");
424                        return;
425                    }
426                    self.process_segments(taken.segments, &mut []).await;
427                }
428            }
429
430            Self::wait_for_more(&self.fs, &self.stop, self.poll_interval).await;
431        }
432    }
433
434    /// On-demand operation: park between triggers (no `take_files`), and on
435    /// a dump request drain only the segments whose `[creation, seal]` span
436    /// overlaps an active window. Segments outside every window stay in the
437    /// ring.
438    async fn run_triggered(&mut self, mut rx: crate::dump::DumpRx) {
439        let mut dumps: Vec<ActiveDump> = Vec::new();
440        let mut rx_open = true;
441
442        loop {
443            if !dumps.is_empty() {
444                let retry_hold = self.drain_matching(&mut dumps).await;
445                // Resolve every dump whose forward deadline elapsed (or that
446                // never had one) and that is not held open by a pending
447                // retry. A disk pass covers the whole backlog, so a retry
448                // there only holds the dumps the retrying segment matched;
449                // the memory pop dispenses one slot per pass, so a retry
450                // there keeps everything open until the head of the ring
451                // settles (budget-bounded, brief).
452                let exhaustive = self.fs.take_is_exhaustive();
453                let now = tokio::time::Instant::now();
454                let mut i = 0;
455                while i < dumps.len() {
456                    let held = !retry_hold.is_empty()
457                        && (!exhaustive || retry_hold.contains(&dumps[i].id));
458                    if dumps[i].due(now) && !held {
459                        self.resolve_dump(dumps.swap_remove(i)).await;
460                    } else {
461                        i += 1;
462                    }
463                }
464            }
465
466            if self.stop.is_cancelled() || self.fs.writer_done() {
467                // One final matching pass picks up segments sealed by writer
468                // finalization, then every open dump resolves with a
469                // truncated receipt covering what actually landed.
470                self.drain_matching(&mut dumps).await;
471                for dump in dumps.drain(..) {
472                    self.resolve_dump(dump).await;
473                }
474                // Requests that never registered fail explicitly.
475                rx.rx.close();
476                while let Ok(req) = rx.rx.try_recv() {
477                    let _ = req.receipt_tx.send(Err(DumpError::WorkerStopped));
478                }
479                tracing::debug!(target: "dial9_worker", "Exiting triggered run loop");
480                return;
481            }
482
483            let min_deadline = dumps.iter().filter_map(|d| d.deadline).min();
484            tokio::select! {
485                _ = self.stop.cancelled() => {}
486                req = rx.rx.recv(), if rx_open => {
487                    match req {
488                        Some(req) => dumps.push(ActiveDump::register(req)),
489                        // All `DumpTrigger`s dropped; disable the branch so
490                        // the closed channel does not spin the select.
491                        None => rx_open = false,
492                    }
493                }
494                _ = tokio::time::sleep_until(
495                    min_deadline.unwrap_or_else(tokio::time::Instant::now)
496                ), if min_deadline.is_some() => {}
497                _ = Self::wait_for_more(&self.fs, &self.stop, self.poll_interval),
498                    if !dumps.is_empty() => {}
499            }
500        }
501    }
502
503    /// Run matching passes until the active windows quiesce. Returns the ids
504    /// of dumps matched by segments that failed retryably (the caller bails
505    /// to its select instead of hot-looping the retry and keeps those dumps
506    /// open); empty means the windows quiesced.
507    async fn drain_matching(&mut self, dumps: &mut [ActiveDump]) -> Vec<crate::dump::DumpId> {
508        loop {
509            if dumps.is_empty() {
510                return Vec::new();
511            }
512            let windows: Vec<EpochWindow> = dumps.iter().map(|d| d.window).collect();
513            let mut taken = self.fs.take_files_matching(&windows);
514            // Prune cache entries for files no longer dispensed (disk
515            // dispenses every unclaimed file per pass, so absence means the
516            // writer evicted it).
517            if !self.epoch_cache.is_empty() {
518                let live: std::collections::HashSet<u32> =
519                    taken.segments.iter().map(|t| t.seg_ref.index()).collect();
520                self.epoch_cache.retain(|idx, _| live.contains(idx));
521            }
522            if taken.segments.is_empty() {
523                self.emit_cycle_metrics(&taken, 0);
524                return Vec::new();
525            }
526            let segments = std::mem::take(&mut taken.segments);
527            let stats = self.process_segments(segments, dumps).await;
528            // Out-of-window claims are released, not dispatched; only count
529            // segments that actually entered the pipeline.
530            self.emit_cycle_metrics(&taken, stats.entered_pipeline as u64);
531            if !stats.retry_dump_ids.is_empty() {
532                return stats.retry_dump_ids;
533            }
534            if stats.matched_done == 0 {
535                // Only out-of-window segments (disk): nothing matching left.
536                return Vec::new();
537            }
538        }
539    }
540
541    /// Resolve a finished dump: signal every stage in pipeline order so it
542    /// can flush per-dump state (the S3 stage writes the manifest here),
543    /// then send the receipt to whoever is awaiting it. Finalize runs for
544    /// every resolved dump — errored and empty ones included — so stages
545    /// always get to clear their per-dump bookkeeping.
546    async fn resolve_dump(&mut self, dump: ActiveDump) {
547        let completion = dump.completion();
548        let mut manifest_key = None;
549        for processor in &mut self.processors {
550            let processor_name = processor.name();
551            // Same panic discipline as `process()`: a panicking finalize is
552            // caught, logged, and the receipt still resolves.
553            let finalize_result = {
554                // `Option::take` moves the `&mut` out of the capture so the
555                // returned future borrows the processor, not the closure.
556                let mut slot = Some(&mut **processor);
557                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
558                    let p = slot.take().expect("closure called once");
559                    p.finalize_dump(&completion)
560                })) {
561                    Ok(fut) => std::panic::AssertUnwindSafe(fut).catch_unwind().await,
562                    Err(panic_payload) => Err(panic_payload),
563                }
564            };
565            match finalize_result {
566                Ok(Some(key)) => manifest_key = Some(key),
567                Ok(None) => {}
568                Err(_) => {
569                    rate_limited!(Duration::from_secs(60), {
570                        tracing::error!(
571                            target: "dial9_worker",
572                            processor = processor_name,
573                            dump_id = %completion.dump_id,
574                            "finalize_dump panicked"
575                        );
576                    });
577                }
578            }
579        }
580        let (tx, result) = dump.into_result(manifest_key);
581        // The caller may have dropped the handle; that does not cancel.
582        let _ = tx.send(result);
583    }
584
585    /// Park until new segments may be available or we're told to stop.
586    ///
587    /// Disk polls on `poll_interval`, memory awaits the ring's wakeup via [`Fs::wait_for_wakeup`].
588    ///
589    /// Borrows only `fs` and `stop` (both `Sync`) rather than `&self`, so the
590    /// `run()` future stays `Send` (`WorkerLoop` holds non-`Sync` processors).
591    async fn wait_for_more(
592        fs: &Fs,
593        stop: &tokio_util::sync::CancellationToken,
594        poll_interval: Duration,
595    ) {
596        if fs.is_disk() {
597            tokio::select! {
598                _ = stop.cancelled() => {}
599                _ = tokio::time::sleep(poll_interval) => {}
600            }
601        } else {
602            tokio::select! {
603                _ = stop.cancelled() => {}
604                _ = fs.wait_for_wakeup() => {}
605            }
606        }
607    }
608
609    // Prod drains via the `run()` shutdown loop (stop/writer_done ->
610    // drain-to-empty). This forces one synchronous drain cycle for unit tests.
611    #[cfg(test)]
612    async fn process_open_segments(&mut self) -> bool {
613        let taken = self.fs.take_files();
614        let found = !taken.segments.is_empty();
615        let dispatched = taken.segments.len() as u64;
616        self.emit_cycle_metrics(&taken, dispatched);
617        self.process_segments(taken.segments, &mut []).await;
618        found
619    }
620
621    async fn process_segments(
622        &mut self,
623        segments: Vec<TakenSegment>,
624        dumps: &mut [ActiveDump],
625    ) -> PassStats {
626        let mut stats = PassStats::default();
627        if self.processors.is_empty() {
628            return stats;
629        }
630
631        'next_segment: for (seg_idx, taken) in segments.into_iter().enumerate() {
632            // Cached-epoch fast path (triggered mode, disk): a segment
633            // already inspected and found out-of-window is released without
634            // re-reading its file.
635            if !dumps.is_empty()
636                && let Some(&(start, seal)) = self.epoch_cache.get(&taken.seg_ref.index())
637                && !dumps.iter().any(|d| d.window.overlaps(start, seal))
638            {
639                self.fs.release_claim(&taken.seg_ref);
640                continue;
641            }
642            // Snapshot memory-only retry state before `load()` consumes
643            // `taken`, so re-dispense on a retryable failure gets the same bytes as the first attempt.
644            let retry_count = taken.retry_count();
645            let original_bytes = taken.original_bytes();
646            let mem_epochs = taken.mem_epochs();
647            let (seg_ref, payload, accounting) = match taken.load() {
648                Ok(t) => t,
649                Err(e) if e.kind() == io::ErrorKind::NotFound => {
650                    rate_limited!(Duration::from_secs(60), {
651                        tracing::warn!(
652                            target: "dial9_worker",
653                            "segment vanished between scan and load, skipping"
654                        );
655                    });
656                    continue;
657                }
658                Err(e) => {
659                    rate_limited!(Duration::from_secs(60), {
660                        tracing::warn!(target: "dial9_worker", error = %e, "failed to load segment");
661                    });
662                    continue;
663                }
664            };
665
666            let uncompressed_size = payload.len() as u64;
667            let path_for_header = seg_ref.disk_path().unwrap_or_else(|| Path::new(""));
668            // A freshly loaded segment is always a single chunk holding the
669            // whole payload, so the first chunk is the full byte range the
670            // timestamp parser needs.
671            let header_bytes = payload.chunks().first().map_or(&[][..], |b| b.as_ref());
672            let (epoch_secs, header_valid) =
673                sealed::creation_epoch_secs(header_bytes, path_for_header);
674            // Seal epoch: memory slots carry it; disk derives it from the
675            // file's mtime (best-effort).
676            let seal_secs = match mem_epochs {
677                Some((_, seal)) => seal,
678                None => sealed::seal_epoch_secs(path_for_header),
679            };
680
681            // Triggered mode: match against every active dump window.
682            let matched: Vec<usize> = dumps
683                .iter()
684                .enumerate()
685                .filter(|(_, d)| d.window.overlaps(epoch_secs, seal_secs))
686                .map(|(i, _)| i)
687                .collect();
688            if !dumps.is_empty() && matched.is_empty() {
689                // Outside every window: leave it in place for later dumps.
690                match &seg_ref {
691                    SegmentRef::Disk(_) => {
692                        self.epoch_cache
693                            .insert(seg_ref.index(), (epoch_secs, seal_secs));
694                        self.fs.release_claim(&seg_ref);
695                    }
696                    SegmentRef::Memory(_) => {
697                        // Defensive: the windowed pop only dispenses matching
698                        // slots. Put the bytes back without burning a retry
699                        // attempt.
700                        if let (Some(count), Some(bytes)) = (retry_count, original_bytes.as_ref()) {
701                            self.fs.release_for_retry(
702                                &seg_ref,
703                                bytes.clone(),
704                                count,
705                                (epoch_secs, seal_secs),
706                            );
707                        }
708                    }
709                }
710                continue;
711            }
712            stats.entered_pipeline += 1;
713
714            let mut metrics = SegmentProcessMetrics {
715                operation: Operation::ProcessSegment,
716                total_time: Timer::start_now(),
717                status: None,
718                segment_index: seg_ref.index(),
719                uncompressed_size,
720                compressed_size: None,
721                invalid_file_header: !header_valid,
722                panicked: false,
723                panic_message: None,
724                pipeline: PipelineMetrics::default(),
725            }
726            .append_on_drop(self.metrics_sink.clone());
727
728            // Kept for metadata, metrics, and failure logging after `seg_ref`
729            // moves into `data` below.
730            let seg_ref_retained = seg_ref.clone();
731            let mut data = SegmentData::new(
732                seg_ref,
733                payload,
734                HashMap::from([
735                    ("epoch_secs".into(), epoch_secs.to_string()),
736                    ("segment_index".into(), seg_ref_retained.index().to_string()),
737                ]),
738                accounting,
739            );
740
741            if !matched.is_empty() {
742                // Every matched dump's id rides the segment, comma-joined;
743                // caller correlation pairs are namespaced `dump.{key}` and
744                // the first-registered dump wins on conflicts.
745                let ids: Vec<String> = matched.iter().map(|&i| dumps[i].id.to_string()).collect();
746                data.metadata_mut().insert("dump_id".into(), ids.join(","));
747                for &i in &matched {
748                    for (k, v) in &dumps[i].metadata {
749                        data.metadata_mut()
750                            .entry(format!("dump.{k}"))
751                            .or_insert_with(|| v.clone());
752                    }
753                }
754            }
755
756            for processor in &mut self.processors {
757                let mut stage = StageMetrics::start();
758                let proc_start = std::time::Instant::now();
759                tracing::debug!(target: "dial9_worker", processor = processor.name(), segment = seg_idx + 1, "running processor");
760                // Catch panics in both the synchronous `process()` call
761                // (which builds the future) and during `.await` (polling).
762                // AssertUnwindSafe: current processors are stateless or have
763                // trivially-recoverable state, so reuse after panic is safe.
764                let process_result = {
765                    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
766                        processor.process(data)
767                    })) {
768                        Ok(fut) => std::panic::AssertUnwindSafe(fut).catch_unwind().await,
769                        Err(panic_payload) => Err(panic_payload),
770                    }
771                };
772                match process_result {
773                    Ok(Ok(next)) => {
774                        tracing::debug!(target: "dial9_worker", processor = processor.name(), segment = seg_idx + 1, elapsed_ms = proc_start.elapsed().as_secs_f64() * 1000.0, "processor succeeded");
775                        data = next;
776                        data.adjust_accounting();
777                        stage.succeed();
778                        metrics.pipeline.push(processor.name(), stage);
779                    }
780                    Ok(Err(e)) => {
781                        tracing::debug!(target: "dial9_worker", processor = processor.name(), segment = seg_idx + 1, elapsed_ms = proc_start.elapsed().as_secs_f64() * 1000.0, error = %e.kind(), "processor failed");
782                        let (next_data, err_kind) = e.into_parts();
783                        data = next_data;
784                        let already_deleted = err_kind.already_deleted();
785                        let retryable = err_kind.retryable();
786                        let kind_msg = err_kind.to_string();
787                        stage.fail();
788                        metrics.pipeline.push(processor.name(), stage);
789                        metrics.status = Some(MetriqueResult::Failure);
790                        metrics.compressed_size = data.compressed_size();
791                        metrics.total_time.stop();
792                        if already_deleted {
793                            tracing::debug!(target: "dial9_worker", id = %data.segment(), "segment evicted during processing, skipping");
794                            // Best-effort: an evicted segment leaves the dump
795                            // silently uncounted.
796                            self.epoch_cache.remove(&seg_ref_retained.index());
797                            if !matched.is_empty() {
798                                stats.matched_done += 1;
799                            }
800                        } else if retryable {
801                            match data.segment() {
802                                // Memory segments always carry retry_count + a
803                                // byte snapshot (set in `TakenSegment::memory`).
804                                // If either is missing the invariant broke. In-flight
805                                // is released via `data`'s accounting on `continue`.
806                                SegmentRef::Memory(_) => {
807                                    match (retry_count, original_bytes.as_ref()) {
808                                        (Some(prev), Some(bytes)) => {
809                                            let attempt = prev + 1;
810                                            if attempt > crate::fs::MEMORY_RETRY_BUDGET {
811                                                rate_limited!(Duration::from_secs(60), {
812                                                    tracing::warn!(target: "dial9_worker", id = %data.segment(), err = %kind_msg, budget = crate::fs::MEMORY_RETRY_BUDGET, "memory retry budget exhausted, dropping segment");
813                                                });
814                                                // Budget spent: terminal for any
815                                                // matched dump, same as a
816                                                // non-retryable failure.
817                                                if !matched.is_empty() {
818                                                    stats.matched_done += 1;
819                                                    record_dump_error(dumps, &matched, err_kind);
820                                                }
821                                            } else {
822                                                tokio::time::sleep(self.poll_interval).await;
823                                                self.fs.release_for_retry(
824                                                    data.segment(),
825                                                    bytes.clone(),
826                                                    attempt,
827                                                    (epoch_secs, seal_secs),
828                                                );
829                                                stats
830                                                    .retry_dump_ids
831                                                    .extend(matched.iter().map(|&i| dumps[i].id));
832                                            }
833                                        }
834                                        _ => {
835                                            rate_limited!(Duration::from_secs(60), {
836                                                tracing::warn!(target: "dial9_worker", id = %data.segment(), "memory segment missing retry state, dropping");
837                                            });
838                                            if !matched.is_empty() {
839                                                stats.matched_done += 1;
840                                                record_dump_error(dumps, &matched, err_kind);
841                                            }
842                                        }
843                                    }
844                                }
845                                SegmentRef::Disk(_) => {
846                                    tracing::debug!(target: "dial9_worker", id = %data.segment(), err = %kind_msg, "retryable error");
847                                    self.fs.release_claim(data.segment());
848                                    stats
849                                        .retry_dump_ids
850                                        .extend(matched.iter().map(|&i| dumps[i].id));
851                                }
852                            }
853                        } else {
854                            self.fs
855                                .remove_sealed(data.segment(), RemoveReason::Terminal);
856                            rate_limited!(Duration::from_secs(60), {
857                                tracing::warn!(target: "dial9_worker", error = %kind_msg, id = %data.segment(), "processor failed, removing segment");
858                            });
859                            self.epoch_cache.remove(&seg_ref_retained.index());
860                            if !matched.is_empty() {
861                                stats.matched_done += 1;
862                                record_dump_error(dumps, &matched, err_kind);
863                            }
864                        }
865                        continue 'next_segment;
866                    }
867                    Err(panic_payload) => {
868                        let panic_msg = panic_payload
869                            .downcast_ref::<&str>()
870                            .copied()
871                            .or_else(|| panic_payload.downcast_ref::<String>().map(|s| s.as_str()))
872                            .unwrap_or("unknown panic");
873                        rate_limited!(
874                            Duration::from_secs(60),
875                            tracing::error!(
876                                target: "dial9_worker",
877                                processor = processor.name(),
878                                segment = seg_idx + 1,
879                                id = %seg_ref_retained,
880                                panic = panic_msg,
881                                "processor panicked, skipping segment"
882                            )
883                        );
884                        // `data` (and the future) were consumed by the panic.
885                        // The metrics guard is a separate local, so record the
886                        // panic on it directly. It flushes on drop below.
887                        metrics.status = Some(MetriqueResult::Failure);
888                        metrics.panicked = true;
889                        metrics.panic_message = Some(panic_msg.to_owned());
890                        metrics.total_time.stop();
891                        self.fs
892                            .remove_sealed(&seg_ref_retained, RemoveReason::Terminal);
893                        self.epoch_cache.remove(&seg_ref_retained.index());
894                        if !matched.is_empty() {
895                            stats.matched_done += 1;
896                            record_dump_error(
897                                dumps,
898                                &matched,
899                                ProcessErrorKind::Io(io::Error::other(format!(
900                                    "processor panicked: {panic_msg}"
901                                ))),
902                            );
903                        }
904                        continue 'next_segment;
905                    }
906                }
907            }
908
909            metrics.status = Some(MetriqueResult::Success);
910            metrics.compressed_size = data.compressed_size();
911            metrics.total_time.stop();
912            self.epoch_cache.remove(&seg_ref_retained.index());
913            if !matched.is_empty() {
914                stats.matched_done += 1;
915                for &i in &matched {
916                    let d = &mut dumps[i];
917                    d.segments_processed += 1;
918                    d.first_epoch = Some(d.first_epoch.map_or(epoch_secs, |e| e.min(epoch_secs)));
919                    d.last_epoch = Some(d.last_epoch.map_or(seal_secs, |e| e.max(seal_secs)));
920                }
921            }
922        }
923
924        stats
925    }
926
927    fn emit_cycle_metrics(&self, taken: &TakenFiles, segments_dispatched: u64) {
928        drop(
929            WorkerCycleMetrics {
930                operation: Operation::WorkerCycle,
931                memory_queued_segments: taken.queued_segments,
932                memory_queued_bytes: taken.queued_bytes,
933                in_flight_segments: taken.in_flight_segments,
934                in_flight_bytes: taken.in_flight_bytes,
935                memory_peak_in_flight_bytes: taken.in_flight_bytes_peak,
936                segments_evicted: taken.segments_dropped,
937                segments_dispatched,
938            }
939            .append_on_drop(self.metrics_sink.clone()),
940        );
941    }
942}
943
944#[cfg(test)]
945mod tests;