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}