Skip to main content

dial9_core/
dump.rs

1//! On-trigger pipeline runs.
2//!
3//! By default the background worker processes sealed trace segments
4//! continuously. Wiring a trigger flips the same pipeline into on-demand
5//! operation: segments keep accumulating in the ring (memory or disk), and
6//! the pipeline only runs when the application explicitly requests a dump.
7//!
8//! A runtime wires a trigger at build time and reaches it through the ambient
9//! handle. See `with_dump_trigger` on the runtime builder for a worked example.
10//!
11//! [`DumpTrigger::dump_current_data`](crate::dump::DumpTrigger::dump_current_data) and
12//! [`DumpTrigger::dump_time_range`](crate::dump::DumpTrigger::dump_time_range) build a
13//! [`DumpRun`](crate::dump::DumpRun); the request is dispatched when that run is awaited
14//! or dropped, whichever comes first (so `.with_metadata(...)` can mutate it before it
15//! is sent). In the common temporary-statement form
16//! (`trigger.dump_current_data();`) the run drops at the end of the statement and
17//! dispatches right there; if you bind it to a variable, dispatch waits until that
18//! binding is awaited or goes out of scope. Awaiting is optional and only retrieves the
19//! [`DumpReceipt`](crate::dump::DumpReceipt).
20//! Dumps are strictly best-effort: a window wider than what the ring
21//! retained captures whatever survived, with no error and no effect on the
22//! live stream.
23//!
24//! # Concurrent dumps
25//!
26//! Dumps are independent: triggering two at once registers two dumps, each
27//! with its own [`DumpId`](crate::dump::DumpId) and (off S3) its own manifest. A segment whose
28//! span overlaps both windows is captured by both. There is no coordination
29//! by default - this is intentional, so unrelated subsystems can dump
30//! without stepping on each other.
31//!
32//! When a single source fires repeatedly (a watcher that re-trips every
33//! poll, a hot path that dumps on every slow request), configure
34//! [`DumpTriggerConfig::debounce`](crate::dump::DumpTriggerConfig::debounce) to coalesce a burst
35//! into one dump: triggers
36//! within the debounce window after a dump dispatched resolve
37//! [`DumpError::Coalesced`](crate::dump::DumpError::Coalesced), naming the dump they folded
38//! into instead of
39//! starting a new one. The gate lives on the trigger stored on the recorder,
40//! so every [`dump_trigger`](crate::handle::Dial9Handle::dump_trigger)
41//! clone shares it. (A *cooldown* that rejects extra triggers outright,
42//! rather than folding them, is a possible future addition.)
43
44use std::future::Future;
45use std::pin::Pin;
46use std::sync::{Arc, Mutex};
47use std::task::{Context, Poll};
48use std::time::{Duration, Instant, SystemTime};
49
50use tokio::sync::{mpsc, oneshot};
51
52use crate::pipeline::ProcessErrorKind;
53
54/// Mint a dump trigger + receiver pair. The builder wires the receiver into
55/// the worker and stashes the trigger in the recorder so it can be reached via
56/// [`Dial9Handle::dump_trigger`](crate::handle::Dial9Handle::dump_trigger).
57pub fn channel() -> (DumpTrigger, DumpRx) {
58    let (tx, rx) = mpsc::unbounded_channel();
59    (DumpTrigger { tx, debounce: None }, DumpRx { rx })
60}
61
62/// On-demand dump configuration, passed to
63/// `with_dump_trigger`.
64///
65/// Flips the worker from continuous processing into on-demand operation:
66/// segments keep accumulating in the ring and the pipeline only runs when the
67/// application requests a dump. Configure coalescing with
68/// [`debounce`](Self::debounce); the resulting [`DumpTrigger`] is then reached
69/// through any [`Dial9Handle`](crate::handle::Dial9Handle) for the runtime
70/// via [`dump_trigger`](crate::handle::Dial9Handle::dump_trigger).
71#[derive(Debug, Default, Clone)]
72pub struct DumpTriggerConfig {
73    debounce: Option<Duration>,
74}
75
76impl DumpTriggerConfig {
77    /// Default trigger: on-demand dumps with no debounce.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Coalesce duplicate triggers within `window` into a single dump.
83    ///
84    /// The first trigger in a quiet period dispatches normally; any trigger
85    /// arriving within `window` of that dispatch resolves
86    /// [`DumpError::Coalesced`] (naming the dump it folded into) without
87    /// starting a new dump. The gate is shared by every
88    /// [`dump_trigger`](crate::handle::Dial9Handle::dump_trigger) clone,
89    /// so the effective rate is at most one dump per `window` across all
90    /// callers.
91    pub fn debounce(&mut self, window: Duration) {
92        self.debounce = Some(window);
93    }
94
95    pub fn debounce_window(&self) -> Option<Duration> {
96        self.debounce
97    }
98}
99
100/// Identifier minted for each dump request.
101///
102/// A ULID: time-sortable, encoded as Crockford base32 in its `Display`
103/// form. Surfaces as `dump-id` user metadata on each S3 object the dump
104/// produces and names the dump's manifest.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
106pub struct DumpId(ulid::Ulid);
107
108impl DumpId {
109    pub(crate) fn new() -> Self {
110        Self(ulid::Ulid::new())
111    }
112
113    /// The instant the dump was triggered, embedded in the id.
114    pub fn timestamp(&self) -> SystemTime {
115        SystemTime::UNIX_EPOCH + Duration::from_millis(self.0.timestamp_ms())
116    }
117}
118
119impl std::fmt::Display for DumpId {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        self.0.fmt(f)
122    }
123}
124
125impl std::str::FromStr for DumpId {
126    type Err = ulid::DecodeError;
127
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        Ok(Self(s.parse()?))
130    }
131}
132
133/// How far back a dump looks from its trigger time.
134#[derive(Debug, Clone, Copy)]
135pub(crate) enum Lookback {
136    /// Everything the ring still holds (`dump_current_data`).
137    Unbounded,
138    /// Segments with creation epoch `>= trigger - window`.
139    Window(Duration),
140}
141
142/// A dump request as it travels over the trigger channel to the worker.
143#[derive(Debug)]
144pub(crate) struct DumpRequest {
145    pub(crate) id: DumpId,
146    pub(crate) triggered_at: SystemTime,
147    pub(crate) lookback: Lookback,
148    pub(crate) lookforward: Duration,
149    pub(crate) metadata: Vec<(String, String)>,
150    pub(crate) receipt_tx: oneshot::Sender<Result<DumpReceipt, DumpError>>,
151}
152
153/// Leading-edge debounce gate shared across [`DumpTrigger`] clones.
154///
155/// Records when the last accepted request was *built* and the [`DumpId`] it
156/// was given. The gate is armed in [`DumpTrigger::request`] (the coalescing
157/// decision has to be synchronous there so a folded trigger can return a
158/// [`DumpError::Coalesced`] run), not at the later drop/await that actually
159/// sends the request; the two coincide in the common temporary-statement
160/// usage. A request arriving within `window` of that instant coalesces into
161/// that id rather than starting a new dump. The window is measured from the
162/// last accepted request and is not extended by coalesced requests, so a
163/// burst all folds into the first dump and the effective rate is at most one
164/// dump per `window`.
165#[derive(Debug)]
166struct Debounce {
167    window: Duration,
168    last: Mutex<Option<(Instant, DumpId)>>,
169}
170
171/// Sending half of the trigger channel.
172///
173/// Cloneable; reach it from any thread owned by the runtime via
174/// [`Dial9Handle::dump_trigger`](crate::handle::Dial9Handle::dump_trigger)
175/// and hand it to whatever subsystem decides when to dump (an idle-ratio
176/// watcher, a panic hook, a `/dump` HTTP handler, ...). Every clone shares the
177/// debounce gate configured by [`DumpTriggerConfig::debounce`].
178#[derive(Debug, Clone)]
179pub struct DumpTrigger {
180    tx: mpsc::UnboundedSender<DumpRequest>,
181    /// `Some` once a debounce window is configured via
182    /// [`DumpTriggerConfig::debounce`]; shared by reference so every clone honors one
183    /// gate.
184    debounce: Option<Arc<Debounce>>,
185}
186
187impl DumpTrigger {
188    /// Coalesce duplicate triggers within `window` into a single dump.
189    ///
190    /// Applied once at build time from [`DumpTriggerConfig::debounce`]; every
191    /// [`dump_trigger`](crate::handle::Dial9Handle::dump_trigger) clone
192    /// then shares one gate. The first trigger in a quiet period dispatches
193    /// normally; any trigger arriving within `window` of that dispatch resolves
194    /// [`DumpError::Coalesced`] (naming the dump it folded into) without
195    /// starting a new dump. Useful when a single source - a watcher that
196    /// re-trips every poll, a hot path that dumps per slow request - would
197    /// otherwise fire a burst of near-identical dumps.
198    pub fn with_debounce(mut self, window: Duration) -> Self {
199        self.debounce = Some(Arc::new(Debounce {
200            window,
201            last: Mutex::new(None),
202        }));
203        self
204    }
205
206    /// Capture everything the ring still holds, right now. No forward
207    /// window.
208    pub fn dump_current_data(&self) -> DumpRun<'_> {
209        self.request(Lookback::Unbounded, Duration::ZERO)
210    }
211
212    /// Capture the window `[trigger - lookback, trigger + lookforward]`.
213    /// Either side may be `Duration::ZERO`.
214    ///
215    /// `lookback` captures pre-trigger segments; you can look back only as
216    /// far as the ring still retains, so a `lookback` wider than the
217    /// retained history is best-effort and captures what survived.
218    /// `lookforward` keeps the dump open until `trigger + lookforward`,
219    /// attaching segments as they seal; it is bounded only by the process
220    /// lifetime and is best-effort under upload pressure. The actual
221    /// covered span is reported on [`DumpReceipt::time_range`]. This never
222    /// errors and never resizes or pins the ring.
223    pub fn dump_time_range(&self, lookback: Duration, lookforward: Duration) -> DumpRun<'_> {
224        self.request(Lookback::Window(lookback), lookforward)
225    }
226
227    fn request(&self, lookback: Lookback, lookforward: Duration) -> DumpRun<'_> {
228        let id = DumpId::new();
229
230        // Leading-edge debounce: a trigger within `window` of the last
231        // accepted request coalesces into it instead of starting a new one.
232        // Armed here at request-build time so a folded trigger can return a
233        // `Coalesced` run synchronously (see `Debounce`).
234        if let Some(debounce) = &self.debounce {
235            let now = Instant::now();
236            let mut last = debounce.last.lock().expect("debounce mutex poisoned");
237            match *last {
238                Some((at, into)) if now.duration_since(at) < debounce.window => {
239                    return DumpRun::preempted(&self.tx, DumpError::Coalesced { into });
240                }
241                _ => *last = Some((now, id)),
242            }
243        }
244
245        let (receipt_tx, receipt_rx) = oneshot::channel();
246        DumpRun {
247            request: Some(DumpRequest {
248                id,
249                triggered_at: SystemTime::now(),
250                lookback,
251                lookforward,
252                metadata: Vec::new(),
253                receipt_tx,
254            }),
255            tx: &self.tx,
256            receipt_rx: Some(receipt_rx),
257            preempt: None,
258        }
259    }
260}
261
262/// Receiving half of the trigger channel; the builder wires it into the worker.
263#[derive(Debug)]
264pub struct DumpRx {
265    pub(crate) rx: mpsc::UnboundedReceiver<DumpRequest>,
266}
267
268/// In-flight dump request.
269///
270/// The dump is dispatched within the statement that created it: either
271/// when this handle is awaited or when it is dropped, whichever comes
272/// first. The handle is only needed to retrieve the [`DumpReceipt`];
273/// dropping it does not cancel the dump. Chain
274/// [`with_metadata`](Self::with_metadata) before awaiting to attach
275/// correlation pairs.
276#[derive(Debug)]
277pub struct DumpRun<'a> {
278    request: Option<DumpRequest>,
279    tx: &'a mpsc::UnboundedSender<DumpRequest>,
280    receipt_rx: Option<oneshot::Receiver<Result<DumpReceipt, DumpError>>>,
281    /// Set when the run never dispatches (debounced): awaiting resolves this
282    /// error directly. `request` is `None` for a preempted run, so
283    /// [`dispatch`](Self::dispatch) and `Drop` are no-ops.
284    preempt: Option<DumpError>,
285}
286
287impl<'a> DumpRun<'a> {
288    /// A run that never dispatches; awaiting resolves `err`.
289    fn preempted(tx: &'a mpsc::UnboundedSender<DumpRequest>, err: DumpError) -> Self {
290        DumpRun {
291            request: None,
292            tx,
293            receipt_rx: None,
294            preempt: Some(err),
295        }
296    }
297
298    /// Attach a caller-supplied correlation pair. Chainable. Each pair is
299    /// stamped onto every captured segment's metadata (namespaced as
300    /// `dump.{key}`) before the pipeline runs; pipeline stages decide what
301    /// to do with them (the S3 stage surfaces them as additional user
302    /// metadata).
303    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
304        if let Some(req) = self.request.as_mut() {
305            req.metadata.push((key.into(), value.into()));
306        }
307        self
308    }
309
310    /// Send the request over the trigger channel, once. Returns `false`
311    /// when the worker is gone (channel closed).
312    fn dispatch(&mut self) -> bool {
313        match self.request.take() {
314            Some(req) => self.tx.send(req).is_ok(),
315            // Already dispatched.
316            None => true,
317        }
318    }
319}
320
321impl Drop for DumpRun<'_> {
322    fn drop(&mut self) {
323        // Dispatch even when the caller never awaits. A closed channel
324        // means the worker is gone; nothing to do.
325        let _ = self.dispatch();
326    }
327}
328
329impl<'a> IntoFuture for DumpRun<'a> {
330    type Output = Result<DumpReceipt, DumpError>;
331    type IntoFuture = DumpFuture;
332
333    fn into_future(mut self) -> Self::IntoFuture {
334        if let Some(err) = self.preempt.take() {
335            return DumpFuture {
336                inner: DumpFutureInner::Preempted(err),
337            };
338        }
339        let sent = self.dispatch();
340        let inner = match (sent, self.receipt_rx.take()) {
341            (true, Some(rx)) => DumpFutureInner::Waiting(rx),
342            _ => DumpFutureInner::Stopped,
343        };
344        DumpFuture { inner }
345    }
346}
347
348/// Future resolving to the dump's [`DumpReceipt`].
349///
350/// For a look-back-only dump it resolves once the last captured segment
351/// finishes the pipeline; for a dump with a look-forward it resolves after
352/// the forward deadline elapses and the last in-window segment finishes.
353#[derive(Debug)]
354pub struct DumpFuture {
355    inner: DumpFutureInner,
356}
357
358#[derive(Debug)]
359enum DumpFutureInner {
360    Waiting(oneshot::Receiver<Result<DumpReceipt, DumpError>>),
361    Stopped,
362    /// Debounced: never dispatched, resolves this error.
363    Preempted(DumpError),
364}
365
366impl Future for DumpFuture {
367    type Output = Result<DumpReceipt, DumpError>;
368
369    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
370        let inner = &mut self.get_mut().inner;
371        match inner {
372            DumpFutureInner::Waiting(rx) => match Pin::new(rx).poll(cx) {
373                Poll::Ready(Ok(result)) => Poll::Ready(result),
374                // Worker exited without resolving the receipt.
375                Poll::Ready(Err(_)) => Poll::Ready(Err(DumpError::WorkerStopped)),
376                Poll::Pending => Poll::Pending,
377            },
378            DumpFutureInner::Stopped => Poll::Ready(Err(DumpError::WorkerStopped)),
379            // Move the error out; the future is not polled again after Ready.
380            DumpFutureInner::Preempted(_) => {
381                match std::mem::replace(inner, DumpFutureInner::Stopped) {
382                    DumpFutureInner::Preempted(err) => Poll::Ready(Err(err)),
383                    _ => unreachable!("matched Preempted above"),
384                }
385            }
386        }
387    }
388}
389
390/// Worker → pipeline-stage signal that a dump finished; passed to
391/// [`SegmentProcessor::finalize_dump`](crate::pipeline::SegmentProcessor::finalize_dump)
392/// so stages can flush per-dump state (the S3 stage writes the dump's
393/// manifest from it).
394#[derive(Debug)]
395#[non_exhaustive]
396pub struct DumpCompletion {
397    /// Id of the dump that finished.
398    pub dump_id: DumpId,
399    /// When the dump was dispatched.
400    pub triggered_at: SystemTime,
401    /// Actual covered span (see [`DumpReceipt::time_range`]).
402    pub time_range: (SystemTime, SystemTime),
403    /// Count of segments that made it through the pipeline.
404    pub segments_processed: usize,
405    /// Caller correlation pairs from `with_metadata(...)`.
406    pub metadata: Vec<(String, String)>,
407    /// True when the dump resolves with [`DumpError::Pipeline`]: a captured
408    /// segment failed terminally and nothing made it through. Stages still
409    /// get to clear per-dump state, but should skip success artifacts (the
410    /// S3 stage writes no manifest for a failed dump).
411    pub failed: bool,
412}
413
414/// What a completed dump produced.
415///
416/// Best-effort: a dump where some matched segments make it through the
417/// pipeline and others fail terminally still resolves `Ok`, with
418/// [`segments_processed`](Self::segments_processed) counting only the
419/// survivors (the failures are dropped silently, exactly like a segment the
420/// ring evicted before the worker reached it). [`DumpError::Pipeline`] is
421/// reserved for total failure: every captured segment failed and nothing
422/// landed.
423#[derive(Debug)]
424#[non_exhaustive]
425pub struct DumpReceipt {
426    /// ULID minted when the dump was dispatched. Time-sortable; surfaces
427    /// as `dump-id` user metadata on each S3 object.
428    pub dump_id: DumpId,
429    /// Count of segments that made it through the pipeline.
430    pub segments_processed: usize,
431    /// When the last segment finished the pipeline.
432    pub finished_at: SystemTime,
433    /// Actual covered span. May be shorter than the requested window on
434    /// either side: look-back if the ring did not retain that much
435    /// history, look-forward if the dump stopped before the deadline.
436    pub time_range: (SystemTime, SystemTime),
437    /// `Some({prefix}/dumps/{dump_id}.json)` when the pipeline ends at S3;
438    /// `None` otherwise (no manifest is written off S3).
439    pub manifest_key: Option<String>,
440}
441
442/// Why a dump failed.
443#[derive(Debug)]
444#[non_exhaustive]
445pub enum DumpError {
446    /// The worker is shutting down or already stopped.
447    WorkerStopped,
448    /// Every captured segment failed in a pipeline stage.
449    Pipeline(ProcessErrorKind),
450    /// The trigger was coalesced into an in-flight dump by the debounce gate
451    /// (see [`DumpTrigger::with_debounce`]). No new dump ran; `into` names the
452    /// dump that covers this trigger.
453    Coalesced {
454        /// Id of the dump this trigger folded into.
455        into: DumpId,
456    },
457}
458
459impl std::fmt::Display for DumpError {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        match self {
462            Self::WorkerStopped => write!(f, "worker is shutting down or already stopped"),
463            Self::Pipeline(kind) => write!(f, "pipeline stage failed: {kind}"),
464            Self::Coalesced { into } => write!(f, "coalesced into dump {into}"),
465        }
466    }
467}
468
469impl std::error::Error for DumpError {
470    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
471        match self {
472            Self::WorkerStopped => None,
473            Self::Pipeline(ProcessErrorKind::Io(e)) => Some(e),
474            Self::Pipeline(ProcessErrorKind::Transfer { source, .. }) => Some(source.as_ref()),
475            Self::Coalesced { .. } => None,
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[tokio::test]
485    async fn dispatches_on_drop_without_await() {
486        let (trigger, mut rx) = channel();
487        {
488            let _run = trigger
489                .dump_time_range(Duration::from_secs(300), Duration::from_secs(60))
490                .with_metadata("reason", "test")
491                .with_metadata("incident", "i-123");
492            // Not awaited; dispatch happens on drop at end of scope.
493        }
494        let req = rx.rx.try_recv().expect("request dispatched on drop");
495        assert!(matches!(req.lookback, Lookback::Window(d) if d == Duration::from_secs(300)));
496        assert_eq!(req.lookforward, Duration::from_secs(60));
497        assert_eq!(
498            req.metadata,
499            vec![
500                ("reason".to_string(), "test".to_string()),
501                ("incident".to_string(), "i-123".to_string()),
502            ]
503        );
504    }
505
506    #[tokio::test]
507    async fn dump_current_data_is_unbounded_lookback() {
508        let (trigger, mut rx) = channel();
509        trigger.dump_current_data();
510        let req = rx.rx.try_recv().expect("dispatched");
511        assert!(matches!(req.lookback, Lookback::Unbounded));
512        assert_eq!(req.lookforward, Duration::ZERO);
513    }
514
515    #[tokio::test]
516    async fn awaiting_dispatches_exactly_once_and_resolves_receipt() {
517        let (trigger, mut rx) = channel();
518        let run = trigger.dump_current_data();
519
520        let worker = tokio::spawn(async move {
521            let req = rx.rx.recv().await.expect("one request");
522            assert!(rx.rx.try_recv().is_err(), "no second dispatch");
523            let receipt = DumpReceipt {
524                dump_id: req.id,
525                segments_processed: 3,
526                finished_at: SystemTime::now(),
527                time_range: (req.triggered_at, req.triggered_at),
528                manifest_key: None,
529            };
530            let _ = req.receipt_tx.send(Ok(receipt));
531        });
532
533        let receipt = run.await.expect("receipt");
534        assert_eq!(receipt.segments_processed, 3);
535        worker.await.unwrap();
536    }
537
538    #[tokio::test]
539    async fn closed_channel_resolves_worker_stopped() {
540        let (trigger, rx) = channel();
541        drop(rx);
542        let err = trigger.dump_current_data().await.unwrap_err();
543        assert!(matches!(err, DumpError::WorkerStopped));
544    }
545
546    #[tokio::test]
547    async fn dropped_receipt_sender_resolves_worker_stopped() {
548        use std::future::IntoFuture;
549
550        let (trigger, mut rx) = channel();
551        let fut = trigger.dump_current_data().into_future();
552        let req = rx.rx.try_recv().expect("dispatched at into_future");
553        // Worker exiting without resolving the receipt drops `receipt_tx`.
554        drop(req);
555        let err = fut.await.unwrap_err();
556        assert!(matches!(err, DumpError::WorkerStopped));
557    }
558
559    #[tokio::test]
560    async fn debounce_coalesces_into_the_first_dump() {
561        let (trigger, mut rx) = channel();
562        let trigger = trigger.with_debounce(Duration::from_secs(60));
563
564        // First trigger dispatches (drop dispatches the un-awaited run).
565        let _ = trigger.dump_current_data();
566        let first = rx.rx.try_recv().expect("first trigger dispatched");
567
568        // Second trigger within the window folds into the first.
569        let err = trigger.dump_current_data().await.unwrap_err();
570        assert!(matches!(err, DumpError::Coalesced { into } if into == first.id));
571        assert!(
572            rx.rx.try_recv().is_err(),
573            "coalesced trigger must not dispatch"
574        );
575    }
576
577    #[tokio::test]
578    async fn debounce_dispatches_again_after_window() {
579        let (trigger, mut rx) = channel();
580        let trigger = trigger.with_debounce(Duration::from_millis(30));
581
582        let _ = trigger.dump_current_data();
583        let first = rx.rx.try_recv().expect("first dispatched");
584
585        tokio::time::sleep(Duration::from_millis(80)).await;
586
587        let _ = trigger.dump_current_data();
588        let second = rx.rx.try_recv().expect("dispatched again after window");
589        assert_ne!(first.id, second.id, "post-window dump gets a fresh id");
590    }
591
592    #[tokio::test]
593    async fn debounce_gate_is_shared_across_clones() {
594        let (trigger, mut rx) = channel();
595        let trigger = trigger.with_debounce(Duration::from_secs(60));
596        let clone = trigger.clone();
597
598        let _ = trigger.dump_current_data();
599        let first = rx.rx.try_recv().expect("first dispatched");
600
601        // A clone honors the same gate, so its trigger coalesces too.
602        let err = clone.dump_current_data().await.unwrap_err();
603        assert!(matches!(err, DumpError::Coalesced { into } if into == first.id));
604    }
605
606    #[tokio::test]
607    async fn without_debounce_duplicate_triggers_both_dispatch() {
608        let (trigger, mut rx) = channel();
609        let _ = trigger.dump_current_data();
610        let _ = trigger.dump_current_data();
611        assert!(rx.rx.try_recv().is_ok(), "first dispatched");
612        assert!(
613            rx.rx.try_recv().is_ok(),
614            "second dispatched (no coordination)"
615        );
616    }
617
618    #[test]
619    fn dump_id_is_time_sorted_and_timestamp_round_trips() {
620        let before = SystemTime::now();
621        let a = DumpId::new();
622        std::thread::sleep(Duration::from_millis(2));
623        let b = DumpId::new();
624        let after = SystemTime::now();
625        assert!(a < b);
626        assert!(a.timestamp() >= before - Duration::from_millis(1));
627        assert!(b.timestamp() <= after + Duration::from_millis(1));
628
629        let parsed: DumpId = a.to_string().parse().expect("round-trip");
630        assert_eq!(parsed, a);
631    }
632}