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