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::task::{Context, Poll};
47use std::time::{Duration, Instant, SystemTime};
48
49use tokio::sync::{mpsc, oneshot};
50
51use crate::pipeline::ProcessErrorKind;
52use crate::primitives::sync::Arc;
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
153impl DumpRequest {
154    pub(crate) fn elapsed_since_trigger(&self) -> Duration {
155        crate::primitives::time::elapsed_since(self.triggered_at)
156    }
157}
158
159/// Leading-edge debounce gate shared across [`DumpTrigger`] clones.
160///
161/// Records when the last accepted request was *built* and the [`DumpId`] it
162/// was given. The gate is armed in [`DumpTrigger::request`] (the coalescing
163/// decision has to be synchronous there so a folded trigger can return a
164/// [`DumpError::Coalesced`] run), not at the later drop/await that actually
165/// sends the request; the two coincide in the common temporary-statement
166/// usage. A request arriving within `window` of that instant coalesces into
167/// that id rather than starting a new dump. The window is measured from the
168/// last accepted request and is not extended by coalesced requests, so a
169/// burst all folds into the first dump and the effective rate is at most one
170/// dump per `window`.
171#[derive(Debug)]
172struct Debounce {
173    window: Duration,
174    last: crate::primitives::sync::Mutex<Option<(Instant, DumpId)>>,
175}
176
177/// Sending half of the trigger channel.
178///
179/// Cloneable; reach it from any thread owned by the runtime via
180/// [`Dial9Handle::dump_trigger`](crate::handle::Dial9Handle::dump_trigger)
181/// and hand it to whatever subsystem decides when to dump (an idle-ratio
182/// watcher, a panic hook, a `/dump` HTTP handler, ...). Every clone shares the
183/// debounce gate configured by [`DumpTriggerConfig::debounce`].
184#[derive(Debug, Clone)]
185pub struct DumpTrigger {
186    tx: mpsc::UnboundedSender<DumpRequest>,
187    /// `Some` once a debounce window is configured via
188    /// [`DumpTriggerConfig::debounce`]; shared by reference so every clone honors one
189    /// gate.
190    debounce: Option<Arc<Debounce>>,
191}
192
193impl DumpTrigger {
194    /// Coalesce duplicate triggers within `window` into a single dump.
195    ///
196    /// Applied once at build time from [`DumpTriggerConfig::debounce`]; every
197    /// [`dump_trigger`](crate::handle::Dial9Handle::dump_trigger) clone
198    /// then shares one gate. The first trigger in a quiet period dispatches
199    /// normally; any trigger arriving within `window` of that dispatch resolves
200    /// [`DumpError::Coalesced`] (naming the dump it folded into) without
201    /// starting a new dump. Useful when a single source - a watcher that
202    /// re-trips every poll, a hot path that dumps per slow request - would
203    /// otherwise fire a burst of near-identical dumps.
204    pub fn with_debounce(mut self, window: Duration) -> Self {
205        self.debounce = Some(Arc::new(Debounce {
206            window,
207            last: crate::primitives::sync::Mutex::new(None),
208        }));
209        self
210    }
211
212    /// Capture everything the ring still holds, right now. No forward
213    /// window.
214    pub fn dump_current_data(&self) -> DumpRun<'_> {
215        self.request(Lookback::Unbounded, Duration::ZERO)
216    }
217
218    /// Capture the window `[trigger - lookback, trigger + lookforward]`.
219    /// Either side may be `Duration::ZERO`.
220    ///
221    /// `lookback` captures pre-trigger segments; you can look back only as
222    /// far as the ring still retains, so a `lookback` wider than the
223    /// retained history is best-effort and captures what survived.
224    /// `lookforward` keeps the dump open until `trigger + lookforward`,
225    /// attaching segments as they seal; it is bounded only by the process
226    /// lifetime and is best-effort under upload pressure. The actual
227    /// covered span is reported on [`DumpReceipt::time_range`]. This never
228    /// errors and never resizes or pins the ring.
229    pub fn dump_time_range(&self, lookback: Duration, lookforward: Duration) -> DumpRun<'_> {
230        self.request(Lookback::Window(lookback), lookforward)
231    }
232
233    fn request(&self, lookback: Lookback, lookforward: Duration) -> DumpRun<'_> {
234        let id = DumpId::new();
235
236        // Leading-edge debounce: a trigger within `window` of the last
237        // accepted request coalesces into it instead of starting a new one.
238        // Armed here at request-build time so a folded trigger can return a
239        // `Coalesced` run synchronously (see `Debounce`).
240        if let Some(debounce) = &self.debounce {
241            let now = Instant::now();
242            let mut last = debounce.last.lock().expect("debounce mutex poisoned");
243            match *last {
244                Some((at, into)) if now.duration_since(at) < debounce.window => {
245                    return DumpRun::preempted(&self.tx, DumpError::Coalesced { into });
246                }
247                _ => *last = Some((now, id)),
248            }
249        }
250
251        let (receipt_tx, receipt_rx) = oneshot::channel();
252        DumpRun {
253            request: Some(DumpRequest {
254                id,
255                triggered_at: SystemTime::now(),
256                lookback,
257                lookforward,
258                metadata: Vec::new(),
259                receipt_tx,
260            }),
261            tx: &self.tx,
262            receipt_rx: Some(receipt_rx),
263            preempt: None,
264        }
265    }
266}
267
268/// Receiving half of the trigger channel; the builder wires it into the worker.
269#[derive(Debug)]
270pub struct DumpRx {
271    pub(crate) rx: mpsc::UnboundedReceiver<DumpRequest>,
272}
273
274/// In-flight dump request.
275///
276/// The dump is dispatched within the statement that created it: either
277/// when this handle is awaited or when it is dropped, whichever comes
278/// first. The handle is only needed to retrieve the [`DumpReceipt`];
279/// dropping it does not cancel the dump. Chain
280/// [`with_metadata`](Self::with_metadata) before awaiting to attach
281/// correlation pairs.
282#[derive(Debug)]
283pub struct DumpRun<'a> {
284    request: Option<DumpRequest>,
285    tx: &'a mpsc::UnboundedSender<DumpRequest>,
286    receipt_rx: Option<oneshot::Receiver<Result<DumpReceipt, DumpError>>>,
287    /// Set when the run never dispatches (debounced): awaiting resolves this
288    /// error directly. `request` is `None` for a preempted run, so
289    /// [`dispatch`](Self::dispatch) and `Drop` are no-ops.
290    preempt: Option<DumpError>,
291}
292
293impl<'a> DumpRun<'a> {
294    /// A run that never dispatches; awaiting resolves `err`.
295    fn preempted(tx: &'a mpsc::UnboundedSender<DumpRequest>, err: DumpError) -> Self {
296        DumpRun {
297            request: None,
298            tx,
299            receipt_rx: None,
300            preempt: Some(err),
301        }
302    }
303
304    /// Attach a caller-supplied correlation pair. Chainable. Each pair is
305    /// stamped onto every captured segment's metadata (namespaced as
306    /// `dump.{key}`) before the pipeline runs; pipeline stages decide what
307    /// to do with them (the S3 stage surfaces them as additional user
308    /// metadata).
309    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
310        if let Some(req) = self.request.as_mut() {
311            req.metadata.push((key.into(), value.into()));
312        }
313        self
314    }
315
316    /// Send the request over the trigger channel, once. Returns `false`
317    /// when the worker is gone (channel closed).
318    fn dispatch(&mut self) -> bool {
319        match self.request.take() {
320            Some(req) => self.tx.send(req).is_ok(),
321            // Already dispatched.
322            None => true,
323        }
324    }
325}
326
327impl Drop for DumpRun<'_> {
328    fn drop(&mut self) {
329        // Dispatch even when the caller never awaits. A closed channel
330        // means the worker is gone; nothing to do.
331        let _ = self.dispatch();
332    }
333}
334
335impl<'a> IntoFuture for DumpRun<'a> {
336    type Output = Result<DumpReceipt, DumpError>;
337    type IntoFuture = DumpFuture;
338
339    fn into_future(mut self) -> Self::IntoFuture {
340        if let Some(err) = self.preempt.take() {
341            return DumpFuture {
342                inner: DumpFutureInner::Preempted(err),
343            };
344        }
345        let sent = self.dispatch();
346        let inner = match (sent, self.receipt_rx.take()) {
347            (true, Some(rx)) => DumpFutureInner::Waiting(rx),
348            _ => DumpFutureInner::Stopped,
349        };
350        DumpFuture { inner }
351    }
352}
353
354/// Future resolving to the dump's [`DumpReceipt`].
355///
356/// For a look-back-only dump it resolves once the last captured segment
357/// finishes the pipeline; for a dump with a look-forward it resolves after
358/// the forward deadline elapses and the last in-window segment finishes.
359#[derive(Debug)]
360pub struct DumpFuture {
361    inner: DumpFutureInner,
362}
363
364#[derive(Debug)]
365enum DumpFutureInner {
366    Waiting(oneshot::Receiver<Result<DumpReceipt, DumpError>>),
367    Stopped,
368    /// Debounced: never dispatched, resolves this error.
369    Preempted(DumpError),
370}
371
372impl Future for DumpFuture {
373    type Output = Result<DumpReceipt, DumpError>;
374
375    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
376        let inner = &mut self.get_mut().inner;
377        match inner {
378            DumpFutureInner::Waiting(rx) => match Pin::new(rx).poll(cx) {
379                Poll::Ready(Ok(result)) => Poll::Ready(result),
380                // Worker exited without resolving the receipt.
381                Poll::Ready(Err(_)) => Poll::Ready(Err(DumpError::WorkerStopped)),
382                Poll::Pending => Poll::Pending,
383            },
384            DumpFutureInner::Stopped => Poll::Ready(Err(DumpError::WorkerStopped)),
385            // Move the error out; the future is not polled again after Ready.
386            DumpFutureInner::Preempted(_) => {
387                match std::mem::replace(inner, DumpFutureInner::Stopped) {
388                    DumpFutureInner::Preempted(err) => Poll::Ready(Err(err)),
389                    _ => unreachable!("matched Preempted above"),
390                }
391            }
392        }
393    }
394}
395
396/// Worker → pipeline-stage signal that a dump finished; passed to
397/// [`SegmentProcessor::finalize_dump`](crate::pipeline::SegmentProcessor::finalize_dump)
398/// so stages can flush per-dump state (the S3 stage writes the dump's
399/// manifest from it).
400#[derive(Debug)]
401#[non_exhaustive]
402pub struct DumpCompletion {
403    /// Id of the dump that finished.
404    pub dump_id: DumpId,
405    /// When the dump was dispatched.
406    pub triggered_at: SystemTime,
407    /// Actual covered span (see [`DumpReceipt::time_range`]).
408    pub time_range: (SystemTime, SystemTime),
409    /// Count of segments that made it through the pipeline.
410    pub segments_processed: usize,
411    /// Caller correlation pairs from `with_metadata(...)`.
412    pub metadata: Vec<(String, String)>,
413    /// True when the dump resolves with [`DumpError::Pipeline`]: a captured
414    /// segment failed terminally and nothing made it through. Stages still
415    /// get to clear per-dump state, but should skip success artifacts (the
416    /// S3 stage writes no manifest for a failed dump).
417    pub failed: bool,
418}
419
420/// What a completed dump produced.
421///
422/// Best-effort: a dump where some matched segments make it through the
423/// pipeline and others fail terminally still resolves `Ok`, with
424/// [`segments_processed`](Self::segments_processed) counting only the
425/// survivors (the failures are dropped silently, exactly like a segment the
426/// ring evicted before the worker reached it). [`DumpError::Pipeline`] is
427/// reserved for total failure: every captured segment failed and nothing
428/// landed.
429#[derive(Debug)]
430#[non_exhaustive]
431pub struct DumpReceipt {
432    /// ULID minted when the dump was dispatched. Time-sortable; surfaces
433    /// as `dump-id` user metadata on each S3 object.
434    pub dump_id: DumpId,
435    /// Count of segments that made it through the pipeline.
436    pub segments_processed: usize,
437    /// When the last segment finished the pipeline.
438    pub finished_at: SystemTime,
439    /// Actual covered span. May be shorter than the requested window on
440    /// either side: look-back if the ring did not retain that much
441    /// history, look-forward if the dump stopped before the deadline.
442    pub time_range: (SystemTime, SystemTime),
443    /// `Some({prefix}/dumps/{dump_id}.json)` when the pipeline ends at S3;
444    /// `None` otherwise (no manifest is written off S3).
445    pub manifest_key: Option<String>,
446}
447
448/// Why a dump failed.
449#[derive(Debug)]
450#[non_exhaustive]
451pub enum DumpError {
452    /// The worker is shutting down or already stopped.
453    WorkerStopped,
454    /// Every captured segment failed in a pipeline stage.
455    Pipeline(ProcessErrorKind),
456    /// The trigger was coalesced into an in-flight dump by the debounce gate
457    /// (see [`DumpTrigger::with_debounce`]). No new dump ran; `into` names the
458    /// dump that covers this trigger.
459    Coalesced {
460        /// Id of the dump this trigger folded into.
461        into: DumpId,
462    },
463}
464
465impl std::fmt::Display for DumpError {
466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        match self {
468            Self::WorkerStopped => write!(f, "worker is shutting down or already stopped"),
469            Self::Pipeline(kind) => write!(f, "pipeline stage failed: {kind}"),
470            Self::Coalesced { into } => write!(f, "coalesced into dump {into}"),
471        }
472    }
473}
474
475impl std::error::Error for DumpError {
476    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
477        match self {
478            Self::WorkerStopped => None,
479            Self::Pipeline(ProcessErrorKind::Io(e)) => Some(e),
480            Self::Pipeline(ProcessErrorKind::Transfer { source, .. }) => Some(source.as_ref()),
481            Self::Coalesced { .. } => None,
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[tokio::test]
491    async fn dispatches_on_drop_without_await() {
492        let (trigger, mut rx) = channel();
493        {
494            let _run = trigger
495                .dump_time_range(Duration::from_secs(300), Duration::from_secs(60))
496                .with_metadata("reason", "test")
497                .with_metadata("incident", "i-123");
498            // Not awaited; dispatch happens on drop at end of scope.
499        }
500        let req = rx.rx.try_recv().expect("request dispatched on drop");
501        assert!(matches!(req.lookback, Lookback::Window(d) if d == Duration::from_secs(300)));
502        assert_eq!(req.lookforward, Duration::from_secs(60));
503        assert_eq!(
504            req.metadata,
505            vec![
506                ("reason".to_string(), "test".to_string()),
507                ("incident".to_string(), "i-123".to_string()),
508            ]
509        );
510    }
511
512    #[tokio::test]
513    async fn dump_current_data_is_unbounded_lookback() {
514        let (trigger, mut rx) = channel();
515        trigger.dump_current_data();
516        let req = rx.rx.try_recv().expect("dispatched");
517        assert!(matches!(req.lookback, Lookback::Unbounded));
518        assert_eq!(req.lookforward, Duration::ZERO);
519    }
520
521    #[tokio::test]
522    async fn awaiting_dispatches_exactly_once_and_resolves_receipt() {
523        let (trigger, mut rx) = channel();
524        let run = trigger.dump_current_data();
525
526        let worker = tokio::spawn(async move {
527            let req = rx.rx.recv().await.expect("one request");
528            assert!(rx.rx.try_recv().is_err(), "no second dispatch");
529            let receipt = DumpReceipt {
530                dump_id: req.id,
531                segments_processed: 3,
532                finished_at: SystemTime::now(),
533                time_range: (req.triggered_at, req.triggered_at),
534                manifest_key: None,
535            };
536            let _ = req.receipt_tx.send(Ok(receipt));
537        });
538
539        let receipt = run.await.expect("receipt");
540        assert_eq!(receipt.segments_processed, 3);
541        worker.await.unwrap();
542    }
543
544    #[tokio::test]
545    async fn closed_channel_resolves_worker_stopped() {
546        let (trigger, rx) = channel();
547        drop(rx);
548        let err = trigger.dump_current_data().await.unwrap_err();
549        assert!(matches!(err, DumpError::WorkerStopped));
550    }
551
552    #[tokio::test]
553    async fn dropped_receipt_sender_resolves_worker_stopped() {
554        use std::future::IntoFuture;
555
556        let (trigger, mut rx) = channel();
557        let fut = trigger.dump_current_data().into_future();
558        let req = rx.rx.try_recv().expect("dispatched at into_future");
559        // Worker exiting without resolving the receipt drops `receipt_tx`.
560        drop(req);
561        let err = fut.await.unwrap_err();
562        assert!(matches!(err, DumpError::WorkerStopped));
563    }
564
565    #[tokio::test]
566    async fn debounce_coalesces_into_the_first_dump() {
567        let (trigger, mut rx) = channel();
568        let trigger = trigger.with_debounce(Duration::from_secs(60));
569
570        // First trigger dispatches (drop dispatches the un-awaited run).
571        let _ = trigger.dump_current_data();
572        let first = rx.rx.try_recv().expect("first trigger dispatched");
573
574        // Second trigger within the window folds into the first.
575        let err = trigger.dump_current_data().await.unwrap_err();
576        assert!(matches!(err, DumpError::Coalesced { into } if into == first.id));
577        assert!(
578            rx.rx.try_recv().is_err(),
579            "coalesced trigger must not dispatch"
580        );
581    }
582
583    #[tokio::test]
584    async fn debounce_dispatches_again_after_window() {
585        let (trigger, mut rx) = channel();
586        let trigger = trigger.with_debounce(Duration::from_millis(30));
587
588        let _ = trigger.dump_current_data();
589        let first = rx.rx.try_recv().expect("first dispatched");
590
591        tokio::time::sleep(Duration::from_millis(80)).await;
592
593        let _ = trigger.dump_current_data();
594        let second = rx.rx.try_recv().expect("dispatched again after window");
595        assert_ne!(first.id, second.id, "post-window dump gets a fresh id");
596    }
597
598    #[tokio::test]
599    async fn debounce_gate_is_shared_across_clones() {
600        let (trigger, mut rx) = channel();
601        let trigger = trigger.with_debounce(Duration::from_secs(60));
602        let clone = trigger.clone();
603
604        let _ = trigger.dump_current_data();
605        let first = rx.rx.try_recv().expect("first dispatched");
606
607        // A clone honors the same gate, so its trigger coalesces too.
608        let err = clone.dump_current_data().await.unwrap_err();
609        assert!(matches!(err, DumpError::Coalesced { into } if into == first.id));
610    }
611
612    #[tokio::test]
613    async fn without_debounce_duplicate_triggers_both_dispatch() {
614        let (trigger, mut rx) = channel();
615        let _ = trigger.dump_current_data();
616        let _ = trigger.dump_current_data();
617        assert!(rx.rx.try_recv().is_ok(), "first dispatched");
618        assert!(
619            rx.rx.try_recv().is_ok(),
620            "second dispatched (no coordination)"
621        );
622    }
623
624    #[test]
625    fn dump_id_is_time_sorted_and_timestamp_round_trips() {
626        let before = SystemTime::now();
627        let a = DumpId::new();
628        std::thread::sleep(Duration::from_millis(2));
629        let b = DumpId::new();
630        let after = SystemTime::now();
631        assert!(a < b);
632        assert!(a.timestamp() >= before - Duration::from_millis(1));
633        assert!(b.timestamp() <= after + Duration::from_millis(1));
634
635        let parsed: DumpId = a.to_string().parse().expect("round-trip");
636        assert_eq!(parsed, a);
637    }
638
639    #[cfg(shuttle)]
640    mod shuttle_tests {
641        use super::*;
642
643        const CALLERS: usize = 4;
644
645        crate::shuttle_test! {
646            default;
647            // Multiple threads race a shared, debounced trigger; exactly one
648            // must dispatch a real request, the rest must coalesce into it.
649            fn shuttle_debounce_gate() {
650                let (trigger, mut rx) = channel();
651                let trigger = trigger.with_debounce(Duration::from_secs(60));
652
653                let handles: Vec<_> = (0..CALLERS)
654                    .map(|_| {
655                        let trigger = trigger.clone();
656                        crate::primitives::thread::spawn(move || {
657                            // Dropped without awaiting: per `DumpRun`'s `Drop`
658                            // impl, dispatch (or no-op, if coalesced) happens
659                            // right here.
660                            drop(trigger.dump_current_data());
661                        })
662                    })
663                    .collect();
664
665                for h in handles {
666                    h.join().unwrap();
667                }
668
669                let mut dispatched = 0;
670                while rx.rx.try_recv().is_ok() {
671                    dispatched += 1;
672                }
673                assert_eq!(
674                    dispatched, 1,
675                    "exactly one trigger within the debounce window must dispatch a real request"
676                );
677            }
678        }
679    }
680}