Skip to main content

qubit_progress/
event.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//! Immutable progress events and lifecycle phases.
9// qubit-style: allow multiple-public-types
10// qubit-style: allow coverage-cfg
11
12use std::sync::Arc;
13use std::time::Duration;
14
15#[cfg(feature = "serde")]
16use crate::Metric;
17use crate::MetricSnapshot;
18use crate::OperationAttributes;
19use crate::Stage;
20#[cfg(feature = "serde")]
21use crate::validation::validate_attributes;
22#[cfg(feature = "serde")]
23use crate::validation::validate_metrics;
24
25/// Lifecycle phase of one immutable progress event.
26#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
27#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum Phase {
30    /// The unique first event.
31    Started,
32    /// A non-terminal snapshot.
33    Running,
34    /// Successful terminal event.
35    Succeeded,
36    /// Failed terminal event.
37    Failed,
38    /// Cancelled terminal event.
39    Cancelled,
40}
41impl Phase {
42    /// Returns the stable lowercase wire name.
43    #[must_use]
44    pub const fn as_str(self) -> &'static str {
45        match self {
46            Self::Started => "started",
47            Self::Running => "running",
48            Self::Succeeded => "succeeded",
49            Self::Failed => "failed",
50            Self::Cancelled => "cancelled",
51        }
52    }
53}
54
55/// Complete immutable snapshot delivered to one reporter call.
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct Event {
58    operation_id: u64,
59    sequence: u64,
60    phase: Phase,
61    stage: Option<Stage>,
62    attributes: Arc<OperationAttributes>,
63    metrics: Vec<MetricSnapshot>,
64    elapsed: Duration,
65}
66impl Event {
67    /// Creates an event after the progress operation has validated state.
68    pub(crate) fn new(
69        operation_id: u64,
70        sequence: u64,
71        phase: Phase,
72        stage: Option<Stage>,
73        attributes: Arc<OperationAttributes>,
74        metrics: Vec<MetricSnapshot>,
75        elapsed: Duration,
76    ) -> Self {
77        Self {
78            operation_id,
79            sequence,
80            phase,
81            stage,
82            attributes,
83            metrics,
84            elapsed,
85        }
86    }
87    /// Returns the process-local operation identifier.
88    #[must_use]
89    pub const fn operation_id(&self) -> u64 {
90        self.operation_id
91    }
92    /// Returns the attempted-delivery sequence.
93    #[must_use]
94    pub const fn sequence(&self) -> u64 {
95        self.sequence
96    }
97    /// Returns the event lifecycle phase.
98    #[must_use]
99    pub const fn phase(&self) -> Phase {
100        self.phase
101    }
102    /// Returns optional stage metadata.
103    #[must_use]
104    pub const fn stage(&self) -> Option<&Stage> {
105        self.stage.as_ref()
106    }
107    /// Returns immutable operation correlation attributes.
108    #[must_use]
109    pub fn attributes(&self) -> &OperationAttributes {
110        &self.attributes
111    }
112    /// Returns one operation correlation attribute by key.
113    #[must_use]
114    pub fn attribute(&self, key: &str) -> Option<&str> {
115        self.attributes.get(key)
116    }
117    /// Returns all metric snapshots in declaration order.
118    #[must_use]
119    pub fn metrics(&self) -> &[MetricSnapshot] {
120        &self.metrics
121    }
122    /// Returns one metric snapshot by stable ID.
123    #[must_use]
124    pub fn metric(&self, metric_id: &str) -> Option<&MetricSnapshot> {
125        self.metrics.iter().find(|metric| metric.id() == metric_id)
126    }
127    /// Returns elapsed monotonic operation time.
128    #[must_use]
129    pub const fn elapsed(&self) -> Duration {
130        self.elapsed
131    }
132}
133
134/// Serializes an event with its canonical duration representation.
135#[cfg(feature = "serde")]
136impl serde::Serialize for Event {
137    /// Serializes one complete event without exposing internal representation.
138    #[cfg_attr(coverage, inline(never))]
139    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
140    where
141        S: serde::Serializer,
142    {
143        serde::Serialize::serialize(
144            &EventWireRef {
145                operation_id: self.operation_id,
146                sequence: self.sequence,
147                phase: self.phase,
148                stage: self.stage.as_ref(),
149                attributes: self.attributes.as_ref(),
150                metrics: &self.metrics,
151                elapsed: format_duration(self.elapsed),
152            },
153            serializer,
154        )
155    }
156}
157
158/// Deserializes and validates a complete event wire representation.
159#[cfg(feature = "serde")]
160impl<'de> serde::Deserialize<'de> for Event {
161    /// Rejects malformed durations and any event that violates public
162    /// invariants.
163    #[cfg_attr(coverage, inline(never))]
164    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
165    where
166        D: serde::Deserializer<'de>,
167    {
168        let wire =
169            <EventWire as serde::Deserialize>::deserialize(deserializer)?;
170        let elapsed =
171            parse_duration(&wire.elapsed).map_err(serde::de::Error::custom)?;
172        validate_wire_event(&wire, elapsed)
173            .map_err(serde::de::Error::custom)?;
174        Ok(Self::new(
175            wire.operation_id,
176            wire.sequence,
177            wire.phase,
178            wire.stage,
179            Arc::new(wire.attributes),
180            wire.metrics,
181            elapsed,
182        ))
183    }
184}
185
186/// Borrowed event representation used for serialization.
187#[cfg(feature = "serde")]
188#[derive(serde::Serialize)]
189struct EventWireRef<'a> {
190    /// Process-local operation identifier.
191    operation_id: u64,
192    /// Attempted-delivery sequence.
193    sequence: u64,
194    /// Event lifecycle phase.
195    phase: Phase,
196    /// Optional stage metadata.
197    stage: Option<&'a Stage>,
198    /// Stable operation correlation attributes.
199    #[serde(skip_serializing_if = "OperationAttributes::is_empty")]
200    attributes: &'a OperationAttributes,
201    /// Complete metric snapshots.
202    metrics: &'a [MetricSnapshot],
203    /// Canonical elapsed duration.
204    elapsed: String,
205}
206
207/// Owned event representation used before validation during deserialization.
208#[cfg(feature = "serde")]
209#[derive(serde::Deserialize)]
210struct EventWire {
211    /// Process-local operation identifier.
212    operation_id: u64,
213    /// Attempted-delivery sequence.
214    sequence: u64,
215    /// Event lifecycle phase.
216    phase: Phase,
217    /// Optional stage metadata.
218    stage: Option<Stage>,
219    /// Stable operation correlation attributes.
220    #[serde(default)]
221    attributes: OperationAttributes,
222    /// Complete metric snapshots.
223    metrics: Vec<MetricSnapshot>,
224    /// Canonical elapsed duration.
225    elapsed: String,
226}
227
228/// Produces the shortest exact unit representation used by Event JSON.
229#[cfg(feature = "serde")]
230fn format_duration(duration: Duration) -> String {
231    if duration.is_zero() {
232        return "0s".into();
233    }
234    let nanoseconds = duration.as_nanos();
235    for (unit, suffix) in [
236        (3_600_000_000_000_u128, "h"),
237        (60_000_000_000_u128, "m"),
238        (1_000_000_000_u128, "s"),
239        (1_000_000_u128, "ms"),
240        (1_000_u128, "us"),
241        (1_u128, "ns"),
242    ] {
243        if nanoseconds.is_multiple_of(unit) {
244            return format!("{}{}", nanoseconds / unit, suffix);
245        }
246    }
247    unreachable!("nanosecond duration is always divisible by one nanosecond")
248}
249
250/// Parses the strict integer-unit duration grammar used by Event JSON.
251#[cfg(feature = "serde")]
252fn parse_duration(text: &str) -> Result<Duration, String> {
253    let (amount, unit) = ["ms", "us", "ns", "h", "m", "s"]
254        .into_iter()
255        .find_map(|unit| text.strip_suffix(unit).map(|amount| (amount, unit)))
256        .ok_or_else(|| {
257            "elapsed must end in h, m, s, ms, us, or ns".to_owned()
258        })?;
259    if amount.is_empty() || !amount.bytes().all(|byte| byte.is_ascii_digit()) {
260        return Err("elapsed amount must be an unsigned integer".into());
261    }
262    let multiplier = match unit {
263        "h" => 3_600_000_000_000_u128,
264        "m" => 60_000_000_000_u128,
265        "s" => 1_000_000_000_u128,
266        "ms" => 1_000_000_u128,
267        "us" => 1_000_u128,
268        "ns" => 1_u128,
269        _ => unreachable!("unit list is exhaustive"),
270    };
271    let nanoseconds = amount
272        .parse::<u128>()
273        .map_err(|_| {
274            "elapsed amount is outside the supported range".to_owned()
275        })?
276        .checked_mul(multiplier)
277        .ok_or_else(|| "elapsed duration overflows".to_owned())?;
278    let seconds = nanoseconds / 1_000_000_000;
279    if seconds > u128::from(u64::MAX) {
280        return Err("elapsed duration overflows".into());
281    }
282    Ok(Duration::new(
283        seconds as u64,
284        (nanoseconds % 1_000_000_000) as u32,
285    ))
286}
287
288/// Validates fields that are only available after Event JSON deserialization.
289#[cfg(feature = "serde")]
290#[cfg_attr(coverage, inline(never))]
291fn validate_wire_event(
292    wire: &EventWire,
293    elapsed: Duration,
294) -> Result<(), String> {
295    if wire.operation_id == 0 {
296        return Err("operation_id must be nonzero".into());
297    }
298    let definitions = wire
299        .metrics
300        .iter()
301        .map(metric_definition)
302        .collect::<Vec<_>>();
303    validate_attributes(&wire.attributes).map_err(|error| error.to_string())?;
304    validate_metrics(&definitions).map_err(|error| error.to_string())?;
305    match wire.phase {
306        Phase::Started => {
307            if wire.sequence != 0
308                || !elapsed.is_zero()
309                || wire.metrics.iter().any(has_dynamic_counts)
310            {
311                return Err(
312                    "started event must have sequence 0, zero elapsed, and zero counts".into(),
313                );
314            }
315        }
316        Phase::Running
317        | Phase::Succeeded
318        | Phase::Failed
319        | Phase::Cancelled
320            if wire.sequence == 0 =>
321        {
322            return Err(
323                "non-started event must have a positive sequence".into()
324            );
325        }
326        Phase::Running
327        | Phase::Succeeded
328        | Phase::Failed
329        | Phase::Cancelled => {}
330    }
331    Ok(())
332}
333
334/// Reconstructs stable metric metadata from an immutable metric snapshot.
335#[cfg(feature = "serde")]
336fn metric_definition(snapshot: &MetricSnapshot) -> Metric {
337    let metric = Metric::new(snapshot.id(), snapshot.name());
338    match snapshot.total() {
339        Some(total) => metric.total(total),
340        None => metric,
341    }
342}
343
344/// Returns whether one metric snapshot carries any dynamic count.
345#[cfg(feature = "serde")]
346const fn has_dynamic_counts(snapshot: &MetricSnapshot) -> bool {
347    snapshot.completed() != 0
348        || snapshot.active() != 0
349        || snapshot.succeeded() != 0
350        || snapshot.failed() != 0
351        || snapshot.cancelled() != 0
352}
353
354/// Exercises serde entry points from the instrumented library build.
355#[cfg(all(feature = "json-lines", coverage))]
356#[doc(hidden)]
357pub fn __coverage_event_serde() {
358    let value = serde_json::json!({
359        "operation_id": 1,
360        "sequence": 0,
361        "phase": "started",
362        "stage": null,
363        "metrics": [{
364            "id": "tasks",
365            "name": "Tasks",
366            "total": null,
367            "completed": 0,
368            "active": 0,
369            "succeeded": 0,
370            "failed": 0,
371            "cancelled": 0
372        }],
373        "elapsed": "0s"
374    });
375    let text =
376        serde_json::to_string(&value).expect("coverage JSON must serialize");
377    let mut deserializer = serde_json::Deserializer::from_str(&text);
378    let event = <Event as serde::Deserialize>::deserialize(&mut deserializer)
379        .expect("coverage event must deserialize");
380    let wire = EventWire {
381        operation_id: event.operation_id(),
382        sequence: event.sequence(),
383        phase: event.phase(),
384        stage: event.stage().cloned(),
385        attributes: event.attributes().clone(),
386        metrics: event.metrics().to_vec(),
387        elapsed: "0s".into(),
388    };
389    validate_wire_event(&wire, event.elapsed())
390        .expect("coverage event validation must succeed");
391    let mut invalid_wire = wire;
392    invalid_wire.attributes.insert(" ", "invalid");
393    assert!(validate_wire_event(&invalid_wire, event.elapsed()).is_err());
394    let mut output = Vec::new();
395    let mut serializer = serde_json::Serializer::new(&mut output);
396    coverage_serialize_event(&event, &mut serializer)
397        .expect("coverage event must serialize");
398}
399
400#[cfg(all(feature = "json-lines", coverage))]
401#[inline(never)]
402fn coverage_serialize_event(
403    event: &Event,
404    serializer: &mut serde_json::Serializer<&mut Vec<u8>>,
405) -> Result<(), serde_json::Error> {
406    <Event as serde::Serialize>::serialize(event, serializer)
407}