Skip to main content

qubit_progress/
progress.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Operation lifecycle, metric state and report scheduling.
9// qubit-style: allow multiple-public-types
10// qubit-style: allow coverage-cfg
11
12use std::sync::Arc;
13use std::sync::atomic::AtomicU64;
14use std::sync::atomic::Ordering;
15use std::time::Duration;
16use std::time::Instant;
17
18use crate::Event;
19use crate::Metric;
20use crate::MetricHandle;
21use crate::MetricSnapshot;
22#[cfg(coverage)]
23use crate::NoopReporter;
24use crate::OperationAttributes;
25use crate::Phase;
26use crate::Reporter;
27use crate::Stage;
28use crate::auto_reporter;
29use crate::auto_reporter::AutoReporter;
30use crate::error::CompletionError;
31use crate::error::ConfigurationError;
32use crate::error::DeliveryError;
33use crate::error::EmissionError;
34use crate::error::FinishError;
35use crate::error::RecoverableFinishError;
36#[cfg(coverage)]
37use crate::error::ReporterError;
38use crate::error::StartError;
39use crate::error::TerminalError;
40use crate::internal::OperationState;
41use crate::validation::validate_attributes;
42use crate::validation::validate_metrics;
43use crate::validation::validate_stage;
44
45/// Process-local source of nonzero operation identifiers.
46static NEXT_OPERATION_ID: AtomicU64 = AtomicU64::new(1);
47
48/// Reporter that fails only after the Started event.
49#[cfg(coverage)]
50struct CoverageTerminalReporter {
51    /// Number of delivery attempts observed by the reporter.
52    attempts: AtomicU64,
53}
54
55#[cfg(coverage)]
56impl Reporter for CoverageTerminalReporter {
57    /// Fails terminal delivery after allowing operation startup.
58    fn report(&self, _event: &Event) -> Result<(), ReporterError> {
59        if self.attempts.fetch_add(1, Ordering::Relaxed) == 0 {
60            Ok(())
61        } else {
62            Err(ReporterError::message("coverage terminal failure"))
63        }
64    }
65}
66
67/// Reporter retained by a progress operation.
68enum ReporterHandle<'reporter> {
69    Borrowed(&'reporter dyn Reporter),
70    Owned(Arc<dyn Reporter>),
71}
72
73impl ReporterHandle<'_> {
74    fn as_reporter(&self) -> &dyn Reporter {
75        match self {
76            Self::Borrowed(reporter) => *reporter,
77            Self::Owned(reporter) => reporter.as_ref(),
78        }
79    }
80}
81
82/// Configures one [`Progress`] operation before it starts.
83pub struct ProgressBuilder<'reporter> {
84    /// Reporter receiving complete events.
85    reporter: ReporterHandle<'reporter>,
86    /// Minimum interval between due-based running reports.
87    interval: Duration,
88    /// Stable operation metrics.
89    metrics: Vec<Metric>,
90    /// Optional initial stage.
91    stage: Option<Stage>,
92    /// Correlation attributes shared by all operation events.
93    attributes: OperationAttributes,
94}
95
96impl<'reporter> ProgressBuilder<'reporter> {
97    /// Sets the minimum interval between due-based running reports.
98    #[must_use]
99    pub const fn interval(mut self, interval: Duration) -> Self {
100        self.interval = interval;
101        self
102    }
103    /// Adds one stable metric to the operation.
104    #[must_use]
105    pub fn metric(mut self, metric: Metric) -> Self {
106        self.metrics.push(metric);
107        self
108    }
109    /// Adds stage metadata to the Started event and subsequent events.
110    #[must_use]
111    pub fn stage(mut self, stage: Stage) -> Self {
112        self.stage = Some(stage);
113        self
114    }
115    /// Adds or replaces one operation correlation attribute.
116    #[must_use]
117    pub fn attribute(mut self, key: &str, value: &str) -> Self {
118        self.attributes.insert(key, value);
119        self
120    }
121    /// Replaces all operation correlation attributes.
122    #[must_use]
123    pub fn attributes(mut self, attributes: OperationAttributes) -> Self {
124        self.attributes = attributes;
125        self
126    }
127    /// Validates configuration, samples enablement and emits Started when
128    /// enabled.
129    pub fn start(self) -> Result<Progress<'reporter>, StartError> {
130        validate_metrics(&self.metrics)?;
131        if let Some(stage) = &self.stage {
132            validate_stage(stage)?;
133        }
134        validate_attributes(&self.attributes)?;
135
136        let enabled = self.reporter.as_reporter().is_enabled();
137        let operation_state = OperationState::new();
138        let operation_id = enabled.then(allocate_operation_id).transpose()?;
139        let mut progress = Progress {
140            reporter: self.reporter,
141            enabled,
142            metrics: self
143                .metrics
144                .into_iter()
145                .map(|metric| {
146                    MetricHandle::new(metric, Arc::clone(&operation_state))
147                })
148                .collect(),
149            operation_state,
150            stage: self.stage,
151            attributes: Arc::new(self.attributes),
152            interval: self.interval,
153            started_at: Instant::now(),
154            next_due_elapsed: None,
155            operation_id,
156            next_sequence: 0,
157        };
158
159        if enabled {
160            let metrics = progress.metric_snapshots();
161            progress
162                .emit(Phase::Started, metrics, Duration::ZERO)
163                .map_err(StartError::from)?;
164            progress.next_due_elapsed = Some(progress.interval);
165            progress.started_at = Instant::now();
166        }
167        Ok(progress)
168    }
169}
170
171/// One started progress operation.
172///
173/// Terminal methods consume this value, preventing reports after a terminal
174/// phase and preventing duplicate terminal events in safe Rust.
175#[must_use]
176pub struct Progress<'reporter> {
177    /// Reporter selected by the builder.
178    reporter: ReporterHandle<'reporter>,
179    /// Stable enablement sampled once at start.
180    enabled: bool,
181    /// Live metrics carried by each event.
182    metrics: Vec<MetricHandle>,
183    /// Shared lifecycle and in-flight update gate.
184    operation_state: Arc<OperationState>,
185    /// Optional current stage.
186    stage: Option<Stage>,
187    /// Immutable correlation attributes shared by all events.
188    attributes: Arc<OperationAttributes>,
189    /// Minimum due-report spacing.
190    interval: Duration,
191    /// Monotonic operation start time.
192    started_at: Instant,
193    /// Next due elapsed deadline for a positive interval.
194    next_due_elapsed: Option<Duration>,
195    /// Nonzero identifier for enabled operations.
196    operation_id: Option<u64>,
197    /// Sequence reserved for the next event attempt.
198    next_sequence: u64,
199}
200
201impl<'reporter> Progress<'reporter> {
202    /// Creates a builder borrowing one reporter.
203    #[must_use]
204    pub fn builder(
205        reporter: &'reporter dyn Reporter,
206    ) -> ProgressBuilder<'reporter> {
207        ProgressBuilder {
208            reporter: ReporterHandle::Borrowed(reporter),
209            interval: Duration::ZERO,
210            metrics: Vec::new(),
211            stage: None,
212            attributes: OperationAttributes::new(),
213        }
214    }
215    /// Creates a builder that owns one shared reporter.
216    #[must_use]
217    pub fn builder_arc(
218        reporter: Arc<dyn Reporter>,
219    ) -> ProgressBuilder<'static> {
220        ProgressBuilder {
221            reporter: ReporterHandle::Owned(reporter),
222            interval: Duration::ZERO,
223            metrics: Vec::new(),
224            stage: None,
225            attributes: OperationAttributes::new(),
226        }
227    }
228    /// Returns enablement sampled when this operation started.
229    #[must_use]
230    pub const fn is_enabled(&self) -> bool {
231        self.enabled
232    }
233    /// Returns monotonic elapsed time since `start()`.
234    #[must_use]
235    pub fn elapsed(&self) -> Duration {
236        self.started_at.elapsed()
237    }
238    /// Returns a cloneable live metric selected by its stable ID.
239    pub fn metric(&self, metric_id: &str) -> Option<MetricHandle> {
240        self.metrics
241            .iter()
242            .find(|metric| metric.id() == metric_id)
243            .cloned()
244    }
245    /// Immediately emits a Running event from current metric state.
246    pub fn report(&mut self) -> Result<(), EmissionError> {
247        if !self.enabled {
248            return Ok(());
249        }
250        let metrics = self.metric_snapshots();
251        let elapsed = self.elapsed();
252        let result = self.emit(Phase::Running, metrics, elapsed);
253        self.reset_deadline();
254        result
255    }
256    /// Emits a Running event only when the configured interval is due.
257    pub fn report_if_due(&mut self) -> Result<(), EmissionError> {
258        if !self.enabled || !self.is_due() {
259            return Ok(());
260        }
261        self.report()
262    }
263    /// Replaces stage metadata attached to subsequent events.
264    pub fn set_stage(
265        &mut self,
266        stage: Stage,
267    ) -> Result<(), ConfigurationError> {
268        validate_stage(&stage)?;
269        self.stage = Some(stage);
270        Ok(())
271    }
272    /// Removes stage metadata from subsequent events.
273    pub fn clear_stage(&mut self) {
274        self.stage = None;
275    }
276    /// Consumes this operation and emits a successful terminal event without
277    /// checking whether metric work is complete.
278    pub fn finish_unchecked(self) -> Result<Duration, TerminalError> {
279        self.terminal(Phase::Succeeded)
280    }
281    /// Consumes this operation and emits a successful terminal event only when
282    /// no metric has active work and every known total has been completed.
283    #[allow(clippy::result_large_err)]
284    pub fn finish(mut self) -> Result<Duration, FinishError> {
285        let elapsed = self.elapsed();
286        let finish_guard = self.operation_state.begin_finish();
287        if let Err(source) = self.validate_finish() {
288            finish_guard.close();
289            return Err(FinishError::Incomplete { elapsed, source });
290        }
291        finish_guard.close();
292        if !self.enabled {
293            return Ok(elapsed);
294        }
295        self.emit(Phase::Succeeded, self.metric_snapshots(), elapsed)
296            .map(|()| elapsed)
297            .map_err(|source| {
298                FinishError::Terminal(TerminalError::new(elapsed, source))
299            })
300    }
301    /// Consumes this operation and emits a successful terminal event while
302    /// preserving the operation when completion validation fails.
303    #[allow(clippy::result_large_err)]
304    pub fn finish_recoverable(
305        mut self,
306    ) -> Result<Duration, RecoverableFinishError<'reporter>> {
307        let elapsed = self.elapsed();
308        let finish_guard = self.operation_state.begin_finish();
309        if let Err(source) = self.validate_finish() {
310            finish_guard.reopen();
311            return Err(RecoverableFinishError::Incomplete {
312                progress: self,
313                source,
314            });
315        }
316        finish_guard.close();
317        if !self.enabled {
318            return Ok(elapsed);
319        }
320        self.emit(Phase::Succeeded, self.metric_snapshots(), elapsed)
321            .map(|()| elapsed)
322            .map_err(|source| {
323                RecoverableFinishError::Terminal(TerminalError::new(
324                    elapsed, source,
325                ))
326            })
327    }
328    /// Consumes this operation and emits a failed terminal event.
329    pub fn fail(self) -> Result<Duration, TerminalError> {
330        self.terminal(Phase::Failed)
331    }
332    /// Consumes this operation and emits a cancelled terminal event.
333    pub fn cancel(self) -> Result<Duration, TerminalError> {
334        self.terminal(Phase::Cancelled)
335    }
336    /// Spawns a scoped automatic Running reporter that exclusively borrows this
337    /// operation.
338    pub fn spawn_auto_reporter<'scope, 'env>(
339        &'scope mut self,
340        scope: &'scope std::thread::Scope<'scope, 'env>,
341    ) -> AutoReporter<'scope, 'reporter>
342    where
343        'reporter: 'scope,
344    {
345        auto_reporter::spawn(self, scope)
346    }
347    /// Copies each metric into one independently consistent event snapshot.
348    fn metric_snapshots(&self) -> Vec<MetricSnapshot> {
349        self.metrics.iter().map(MetricHandle::snapshot).collect()
350    }
351    /// Validates the metric invariants required for successful finish.
352    fn validate_finish(&self) -> Result<(), CompletionError> {
353        for metric in &self.metrics {
354            let snapshot = metric.snapshot();
355            if snapshot.active() != 0 {
356                return Err(CompletionError::ActiveWork {
357                    metric_id: snapshot.id().to_owned(),
358                    active: snapshot.active(),
359                });
360            }
361            if let Some(total) = snapshot.total()
362                && snapshot.completed() != total
363            {
364                return Err(CompletionError::IncompleteTotal {
365                    metric_id: snapshot.id().to_owned(),
366                    completed: snapshot.completed(),
367                    total,
368                });
369            }
370        }
371        Ok(())
372    }
373    /// Delivers one complete event after reserving its delivery sequence.
374    fn emit(
375        &mut self,
376        phase: Phase,
377        metrics: Vec<MetricSnapshot>,
378        elapsed: Duration,
379    ) -> Result<(), EmissionError> {
380        let operation_id =
381            self.operation_id.ok_or(EmissionError::SequenceExhausted)?;
382        let sequence = self.next_sequence;
383        self.next_sequence = sequence
384            .checked_add(1)
385            .ok_or(EmissionError::SequenceExhausted)?;
386        let event = Event::new(
387            operation_id,
388            sequence,
389            phase,
390            self.stage.clone(),
391            Arc::clone(&self.attributes),
392            metrics,
393            elapsed,
394        );
395        match self.reporter.as_reporter().report(&event) {
396            Ok(()) => Ok(()),
397            Err(source) => {
398                Err(EmissionError::Delivery(DeliveryError::new(event, source)))
399            }
400        }
401    }
402    /// Tests whether a due-based running report can run now.
403    fn is_due(&self) -> bool {
404        self.interval.is_zero()
405            || self
406                .next_due_elapsed
407                .is_some_and(|deadline| self.elapsed() >= deadline)
408    }
409    /// Returns the configured interval to the crate-private background loop.
410    pub(crate) const fn report_interval(&self) -> Duration {
411        self.interval
412    }
413    /// Returns how long the background loop should wait for the next deadline.
414    pub(crate) fn time_until_due(&self) -> Duration {
415        self.next_due_elapsed
416            .map(|deadline| deadline.saturating_sub(self.elapsed()))
417            .unwrap_or(Duration::MAX)
418    }
419    /// Pushes the next positive-interval deadline after a running attempt.
420    fn reset_deadline(&mut self) {
421        self.next_due_elapsed = self.elapsed().checked_add(self.interval);
422    }
423    /// Emits one terminal phase while retaining elapsed time on failure.
424    #[inline(never)]
425    fn terminal(mut self, phase: Phase) -> Result<Duration, TerminalError> {
426        let elapsed = self.elapsed();
427        let finish_guard = self.operation_state.begin_finish();
428        finish_guard.close();
429        if !self.enabled {
430            return Ok(elapsed);
431        }
432        self.emit(phase, self.metric_snapshots(), elapsed)
433            .map(|()| elapsed)
434            .map_err(|source| TerminalError::new(elapsed, source))
435    }
436}
437
438impl Drop for Progress<'_> {
439    /// Closes live handles when a caller abandons an unfinished operation.
440    #[inline(never)]
441    fn drop(&mut self) {
442        self.operation_state.close();
443    }
444}
445
446/// Allocates a nonzero operation ID without wrapping or reuse.
447#[inline(never)]
448fn allocate_operation_id() -> Result<u64, StartError> {
449    loop {
450        let current = NEXT_OPERATION_ID.load(Ordering::Relaxed);
451        if current == 0 {
452            return Err(StartError::OperationIdExhausted);
453        }
454        let next = current.checked_add(1).unwrap_or(0);
455        if NEXT_OPERATION_ID
456            .compare_exchange_weak(
457                current,
458                next,
459                Ordering::Relaxed,
460                Ordering::Relaxed,
461            )
462            .is_ok()
463        {
464            return Ok(current);
465        }
466    }
467}
468
469/// Exercises progress-only edge paths from the instrumented library build.
470#[cfg(coverage)]
471#[doc(hidden)]
472pub fn __coverage_progress_edges() {
473    let previous = NEXT_OPERATION_ID.swap(0, Ordering::Relaxed);
474    assert!(matches!(
475        allocate_operation_id(),
476        Err(StartError::OperationIdExhausted)
477    ));
478    NEXT_OPERATION_ID.store(previous.max(1), Ordering::Relaxed);
479
480    let progress = Progress::builder(&NoopReporter)
481        .metric(Metric::new("coverage", "Coverage"))
482        .start()
483        .expect("coverage progress must start");
484    progress.cancel().expect("coverage progress must cancel");
485
486    let reporter = CoverageTerminalReporter {
487        attempts: AtomicU64::new(0),
488    };
489    let progress = Progress::builder_arc(Arc::new(reporter))
490        .metric(Metric::new("coverage-terminal", "Coverage terminal"))
491        .start()
492        .expect("coverage terminal progress must start");
493    assert!(progress.cancel().is_err());
494}