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}