Skip to main content

wyrd_runtime/
outbox.rs

1//! Generic non-blocking, per-tenant batched outbox.
2//!
3//! A server surface that must record derived work without delaying the request
4//! that produced it hands each item to [`Outbox::stage`], which only enqueues
5//! it. One background writer per outbox groups queued items by tenant and
6//! writes each tenant's whole backlog through its [`OutboxSink`] in one
7//! all-or-nothing call. At most `concurrency` tenants are written at once and
8//! each tenant has at most one write in flight, so a slow tenant delays only
9//! its own items.
10//!
11//! A failed write is never dropped. A write that returns an error or panics
12//! keeps its exact items as that tenant's next write, apart from anything that
13//! arrived meanwhile, and the tenant is retried after a backoff that starts at
14//! [`INITIAL_BACKOFF`] and doubles up to [`MAX_BACKOFF`]; other tenants keep
15//! writing. Later arrivals wait for the write after the retry succeeds, so a
16//! sink deriving identity from its slice presents the same identity on every
17//! attempt. Because a write may be retried after an unknown commit outcome,
18//! every sink write must be safe to repeat.
19//!
20//! The queue has no count limit and nothing is preallocated. Items are lost
21//! only when the process stops abruptly, when graceful shutdown reaches its
22//! deadline, or when an item is staged after shutdown begins; shutdown and
23//! late staging count them in `outbox_events_lost_total{outbox}`. Every failed
24//! write attempt is counted in `outbox_write_failures_total{outbox}`, and the
25//! items not yet written are exported as the `outbox_pending{outbox}` gauge.
26
27use std::any::Any;
28use std::collections::HashMap;
29use std::fmt::Display;
30use std::future::{Future, pending, poll_fn};
31use std::panic::{AssertUnwindSafe, catch_unwind};
32use std::pin::pin;
33use std::sync::atomic::{AtomicUsize, Ordering};
34use std::sync::{Arc, PoisonError, RwLock};
35use std::task::Poll;
36use std::time::{Duration, Instant};
37
38use tokio::sync::{Notify, mpsc};
39use tokio::task::{Id, JoinError, JoinSet};
40use tokio::time::{sleep_until, timeout_at};
41use tokio_util::sync::CancellationToken;
42use tokio_util::task::TaskTracker;
43use wyrd_spec::DataTenantId;
44
45/// Delay before the first retry of a tenant whose write failed.
46pub const INITIAL_BACKOFF: Duration = Duration::from_millis(50);
47
48/// Longest delay between retries of a tenant whose writes keep failing.
49pub const MAX_BACKOFF: Duration = Duration::from_secs(5);
50
51/// One destination an outbox writes to.
52///
53/// Implementations write everything a tenant has queued in one call: in one
54/// transaction, or as idempotent writes whose repeat is absorbed. The outbox
55/// owns queueing, grouping, concurrency, retry, and shutdown.
56pub trait OutboxSink: Send + Sync + 'static {
57    /// The item callers stage.
58    type Item: Send + 'static;
59    /// The failure a write reports; logged, counted, and retried.
60    type Error: Display + Send + 'static;
61    /// Label for this outbox's metrics and logs.
62    const NAME: &'static str;
63
64    /// Writes one tenant's items, in order, in one transaction.
65    ///
66    /// A write may be retried after its commit outcome is unknown, so writing
67    /// the same items twice must leave the same durable state as writing them
68    /// once.
69    ///
70    /// # Errors
71    ///
72    /// Returns the sink's error when any part of the write fails; nothing of
73    /// the batch may then be durable unless a repeat would skip it.
74    fn write(
75        &self,
76        tenant: DataTenantId,
77        items: &[Self::Item],
78    ) -> impl Future<Output = Result<(), Self::Error>> + Send;
79}
80
81/// Sending half of an outbox queue: a tenant and one of its items.
82type QueueSender<S> = mpsc::UnboundedSender<(DataTenantId, <S as OutboxSink>::Item)>;
83
84/// The handle callers stage through; owns the queue and its writer task.
85pub struct Outbox<S: OutboxSink> {
86    /// Sending half of the unbounded queue the writer drains, taken by
87    /// shutdown. `stage` sends under the read lock and shutdown takes it under
88    /// the write lock, so every item is either queued before the fence and
89    /// drained, or refused after it.
90    queue: RwLock<Option<QueueSender<S>>>,
91    /// Items queued, waiting, or being written, not yet written or lost.
92    pending: Arc<AtomicUsize>,
93    /// Signalled whenever a settled write or abandonment leaves nothing
94    /// pending.
95    idle: Arc<Notify>,
96    /// Asks the writer to give up at the shutdown deadline.
97    abandon: CancellationToken,
98    /// Tracks the writer so shutdown can wait for it.
99    writer: TaskTracker,
100    /// The sink the writer submits through, shared for inspection.
101    sink: Arc<S>,
102}
103
104impl<S: OutboxSink> Outbox<S> {
105    /// Creates the outbox around `sink` and starts its writer.
106    ///
107    /// `concurrency` is the most tenants written at once, which bounds the
108    /// connections the sink may hold.
109    ///
110    /// # Panics
111    ///
112    /// Panics when called outside a Tokio runtime, because the writer task is
113    /// spawned immediately.
114    #[must_use]
115    pub fn new(sink: S, concurrency: usize) -> Arc<Self> {
116        let (queue, requests) = mpsc::unbounded_channel();
117        let pending = Arc::new(AtomicUsize::new(0));
118        let idle = Arc::new(Notify::new());
119        let abandon = CancellationToken::new();
120        let writer = TaskTracker::new();
121        let sink = Arc::new(sink);
122        writer.spawn(
123            OutboxWriter {
124                sink: Arc::clone(&sink),
125                requests,
126                waiting: HashMap::new(),
127                failed: HashMap::new(),
128                writing: JoinSet::new(),
129                in_flight: HashMap::new(),
130                retry_at: HashMap::new(),
131                concurrency: concurrency.max(1),
132                pending: Arc::clone(&pending),
133                idle: Arc::clone(&idle),
134                abandon: abandon.clone(),
135            }
136            .run(),
137        );
138        writer.close();
139        Arc::new(Self {
140            queue: RwLock::new(Some(queue)),
141            pending,
142            idle,
143            abandon,
144            writer,
145            sink,
146        })
147    }
148
149    /// Borrows the sink this outbox writes through.
150    #[must_use]
151    pub fn sink(&self) -> &S {
152        &self.sink
153    }
154
155    /// Queues one item for `tenant` without waiting and never fails.
156    ///
157    /// The item is counted pending before it is sent, so the writer can never
158    /// release it first. Once shutdown has begun, or if the writer is gone,
159    /// the item never enters the queue; it is counted lost and logged instead.
160    pub fn stage(&self, tenant: DataTenantId, item: impl Into<S::Item>) {
161        let queue = self.queue.read().unwrap_or_else(PoisonError::into_inner);
162        if let Some(queue) = queue.as_ref() {
163            let gauge = metrics::gauge!("outbox_pending", "outbox" => S::NAME);
164            self.pending.fetch_add(1, Ordering::AcqRel);
165            gauge.increment(1.0);
166            if queue.send((tenant, item.into())).is_ok() {
167                return;
168            }
169            self.pending.fetch_sub(1, Ordering::AcqRel);
170            gauge.decrement(1.0);
171        }
172        count_lost::<S>(1);
173        tracing::error!(outbox = S::NAME, %tenant, "outbox item staged after shutdown was lost");
174    }
175
176    /// Returns the number of items not yet written.
177    #[must_use]
178    pub fn pending(&self) -> usize {
179        self.pending.load(Ordering::Acquire)
180    }
181
182    /// Waits until `deadline` for everything staged so far to be written,
183    /// without closing the outbox, and returns how many items remain.
184    ///
185    /// The outbox keeps accepting items throughout; callers that read the
186    /// sink's destination right after staging use this to observe their items.
187    pub async fn settle(&self, deadline: Instant) -> usize {
188        loop {
189            let idle = self.idle.notified();
190            tokio::pin!(idle);
191            idle.as_mut().enable();
192            if self.pending() == 0 {
193                return 0;
194            }
195            if timeout_at(deadline.into(), idle).await.is_err() {
196                return self.pending();
197            }
198        }
199    }
200
201    /// Stops taking items, keeps writing and retrying until `deadline`, and
202    /// returns how many items were lost at the deadline.
203    ///
204    /// The staging sender is dropped before the first await, which is a
205    /// one-way fence: everything staged before it drains, and every later
206    /// [`Outbox::stage`] is refused and counted lost without entering the
207    /// queue. Items still unwritten at the deadline are abandoned with the
208    /// writer, which cancels any in-flight write; once the writer is gone
209    /// they are counted in `outbox_events_lost_total`, logged, and released
210    /// from `pending()` and `outbox_pending`, which both end at zero. When
211    /// shutdown is called concurrently, exactly one caller reports the loss
212    /// and the others report zero.
213    pub async fn shutdown(&self, deadline: Instant) -> usize {
214        drop(
215            self.queue
216                .write()
217                .unwrap_or_else(PoisonError::into_inner)
218                .take(),
219        );
220        if timeout_at(deadline.into(), self.writer.wait())
221            .await
222            .is_err()
223        {
224            self.abandon.cancel();
225            self.writer.wait().await;
226        }
227        let lost = self.pending.swap(0, Ordering::AcqRel);
228        if lost > 0 {
229            count_lost::<S>(lost);
230            metrics::gauge!("outbox_pending", "outbox" => S::NAME).decrement(lost as f64);
231            tracing::error!(
232                outbox = S::NAME,
233                lost,
234                "outbox shutdown deadline passed with items unwritten"
235            );
236            self.idle.notify_waiters();
237        }
238        lost
239    }
240}
241
242/// Outcome of one spawned tenant write: the tenant, its items, and the result,
243/// whose error is the sink's error or the message of a contained panic.
244type Written<S> = (
245    DataTenantId,
246    Vec<<S as OutboxSink>::Item>,
247    Result<(), String>,
248);
249
250/// The writer task behind one [`Outbox`].
251///
252/// Moved into its task by [`Outbox::new`]; it owns the receiving half of the
253/// queue, the per-tenant backlogs, the in-flight writes, and the retry state.
254struct OutboxWriter<S: OutboxSink> {
255    /// Destination every write goes to.
256    sink: Arc<S>,
257    /// Receiving half of the queue.
258    requests: mpsc::UnboundedReceiver<(DataTenantId, S::Item)>,
259    /// Received items per tenant, in arrival order, not being written.
260    waiting: HashMap<DataTenantId, Vec<S::Item>>,
261    /// The exact slice of each tenant whose last write failed; it is that
262    /// tenant's next write, unmerged with `waiting`.
263    failed: HashMap<DataTenantId, Vec<S::Item>>,
264    /// In-flight tenant writes.
265    writing: JoinSet<Written<S>>,
266    /// Tenant and item count of each in-flight write, keyed by its task.
267    in_flight: HashMap<Id, (DataTenantId, usize)>,
268    /// Earliest retry time and current backoff of each tenant whose last
269    /// write failed.
270    retry_at: HashMap<DataTenantId, (Instant, Duration)>,
271    /// Most tenants written at once.
272    concurrency: usize,
273    /// Count shared with the handle; decremented once items are written.
274    pending: Arc<AtomicUsize>,
275    /// Woken whenever a settled write leaves nothing pending.
276    idle: Arc<Notify>,
277    /// Cancelled when shutdown reaches its deadline.
278    abandon: CancellationToken,
279}
280
281impl<S: OutboxSink> OutboxWriter<S> {
282    /// The writer loop: receives items, dispatches tenant writes, settles
283    /// finished ones, and wakes for retries, until shutdown has dropped the
284    /// sender and nothing is left, or shutdown abandons it at the deadline.
285    ///
286    /// Abandoning returns at once; dropping the writer cancels its in-flight
287    /// writes and discards their items, which shutdown then counts lost.
288    async fn run(mut self) {
289        let mut received = Vec::new();
290        let mut open = true;
291        loop {
292            self.dispatch();
293            if !open && self.waiting.is_empty() && self.failed.is_empty() && self.writing.is_empty()
294            {
295                return;
296            }
297            let retry = self.next_retry();
298            tokio::select! {
299                biased;
300                () = self.abandon.cancelled() => return,
301                Some(done) = self.writing.join_next_with_id(), if !self.writing.is_empty() => {
302                    self.finish(done);
303                }
304                count = self.requests.recv_many(&mut received, usize::MAX), if open => {
305                    open = count != 0;
306                    for (tenant, item) in received.drain(..) {
307                        self.waiting.entry(tenant).or_default().push(item);
308                    }
309                }
310                () = async {
311                    match retry {
312                        Some(at) => sleep_until(at.into()).await,
313                        None => pending().await,
314                    }
315                } => {}
316            }
317        }
318    }
319
320    /// Starts one write for each tenant that has items waiting, has no write
321    /// in flight, and is not backing off, while fewer than `concurrency` writes
322    /// run. A tenant with a failed slice writes exactly that slice again;
323    /// otherwise a write takes the tenant's whole backlog.
324    ///
325    /// The write task keeps its items through a panicking sink, including a
326    /// panic while rendering the sink's error, and returns them with the panic
327    /// as the error, so they are retried like any failed write.
328    fn dispatch(&mut self) {
329        let now = Instant::now();
330        let ready: Vec<DataTenantId> = self
331            .waiting
332            .keys()
333            .chain(
334                self.failed
335                    .keys()
336                    .filter(|tenant| !self.waiting.contains_key(*tenant)),
337            )
338            .filter(|tenant| self.is_ready(**tenant, now))
339            .copied()
340            .collect();
341        for tenant in ready {
342            if self.writing.len() >= self.concurrency {
343                return;
344            }
345            let Some(items) = self
346                .failed
347                .remove(&tenant)
348                .or_else(|| self.waiting.remove(&tenant))
349            else {
350                continue;
351            };
352            let count = items.len();
353            let sink = Arc::clone(&self.sink);
354            let task = self.writing.spawn(async move {
355                // The constructing closure borrows `items`, so it is dropped
356                // before awaiting, and the write before `items` is returned.
357                let outcome = {
358                    let write = catch_unwind(AssertUnwindSafe(|| sink.write(tenant, &items)));
359                    match write {
360                        Ok(write) => contain_panic(write).await,
361                        Err(panic) => Err(panic),
362                    }
363                };
364                // Rendering a sink error runs sink code too, so it is
365                // contained as well and `items` is still returned.
366                let result = catch_unwind(AssertUnwindSafe(|| match outcome {
367                    Ok(written) => written.map_err(|error| error.to_string()),
368                    Err(panic) => Err(format!("sink write panicked: {}", panic_message(&*panic))),
369                }))
370                .unwrap_or_else(|panic| {
371                    Err(format!(
372                        "sink error rendering panicked: {}",
373                        panic_message(&*panic)
374                    ))
375                });
376                (tenant, items, result)
377            });
378            self.in_flight.insert(task.id(), (tenant, count));
379        }
380    }
381
382    /// Whether `tenant` has no write in flight and its backoff has elapsed.
383    fn is_ready(&self, tenant: DataTenantId, now: Instant) -> bool {
384        !self.in_flight.values().any(|(busy, _)| *busy == tenant)
385            && self.retry_at.get(&tenant).is_none_or(|(at, _)| *at <= now)
386    }
387
388    /// The earliest future retry time of a waiting tenant, or `None` when no
389    /// timer is needed because every write slot is busy or nobody backs off.
390    fn next_retry(&self) -> Option<Instant> {
391        if self.writing.len() >= self.concurrency {
392            return None;
393        }
394        let now = Instant::now();
395        self.retry_at
396            .iter()
397            .filter(|(tenant, (at, _))| *at > now && self.failed.contains_key(*tenant))
398            .map(|(_, (at, _))| *at)
399            .min()
400    }
401
402    /// Settles one finished write.
403    ///
404    /// Success clears the tenant's backoff and releases its items. Failure,
405    /// including a contained sink panic, keeps the items as the tenant's next
406    /// write, unmerged with later arrivals, doubles its backoff, and counts
407    /// and logs the attempt. A task that failed to join lost its items, which are counted
408    /// lost; with sink panics contained that needs a panic that escapes
409    /// containment, such as one raised while dropping a panic payload.
410    fn finish(&mut self, done: Result<(Id, Written<S>), JoinError>) {
411        let (tenant, items, result) = match done {
412            Ok((id, written)) => {
413                self.in_flight.remove(&id);
414                written
415            }
416            Err(error) => {
417                if let Some((tenant, count)) = self.in_flight.remove(&error.id()) {
418                    count_lost::<S>(count);
419                    tracing::error!(outbox = S::NAME, %tenant, %error, lost = count, "outbox write task failed");
420                    self.release(count);
421                }
422                return;
423            }
424        };
425        let error = match result {
426            Ok(()) => {
427                self.retry_at.remove(&tenant);
428                self.release(items.len());
429                return;
430            }
431            Err(error) => error,
432        };
433        let backoff = self
434            .retry_at
435            .get(&tenant)
436            .map_or(INITIAL_BACKOFF, |(_, last)| (*last * 2).min(MAX_BACKOFF));
437        self.retry_at
438            .insert(tenant, (Instant::now() + backoff, backoff));
439        metrics::counter!("outbox_write_failures_total", "outbox" => S::NAME).increment(1);
440        tracing::error!(
441            outbox = S::NAME,
442            %tenant,
443            %error,
444            items = items.len(),
445            retry_in_ms = backoff.as_millis(),
446            "outbox write failed; retrying"
447        );
448        self.failed.insert(tenant, items);
449    }
450
451    /// Releases `count` written or lost items from the pending total and wakes
452    /// settlers when nothing remains.
453    fn release(&self, count: usize) {
454        metrics::gauge!("outbox_pending", "outbox" => S::NAME).decrement(count as f64);
455        if self.pending.fetch_sub(count, Ordering::AcqRel) == count {
456            self.idle.notify_waiters();
457        }
458    }
459}
460
461/// Polls `future` to completion and returns its output, or the payload of a
462/// panic raised while polling it.
463///
464/// Containing the panic inside the polled task, rather than letting it unwind
465/// the task, keeps the caller's owned state, such as a write's items, intact.
466///
467/// # Errors
468///
469/// Returns the panic payload when polling `future` panics; the future is then
470/// dropped without being polled again.
471async fn contain_panic<F: Future>(future: F) -> Result<F::Output, Box<dyn Any + Send>> {
472    let mut future = pin!(future);
473    poll_fn(
474        |context| match catch_unwind(AssertUnwindSafe(|| future.as_mut().poll(context))) {
475            Ok(poll) => poll.map(Ok),
476            Err(panic) => Poll::Ready(Err(panic)),
477        },
478    )
479    .await
480}
481
482/// The message of a panic payload, when it is a string.
483fn panic_message(panic: &(dyn Any + Send)) -> &str {
484    panic
485        .downcast_ref::<&str>()
486        .copied()
487        .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
488        .unwrap_or("non-string panic payload")
489}
490
491/// Counts `count` items of outbox `S` as lost in
492/// `outbox_events_lost_total{outbox}`.
493///
494/// The outbox counts shutdown and late-staging loss itself; a sink calls this
495/// for items it consumes without writing, such as a terminal rejection that a
496/// retry could never cure.
497pub fn count_lost<S: OutboxSink>(count: usize) {
498    metrics::counter!("outbox_events_lost_total", "outbox" => S::NAME).increment(count as u64);
499}
500
501#[cfg(test)]
502mod tests {
503    //! The generic outbox against an in-memory sink whose tenants can be made
504    //! to fail on demand.
505
506    use std::collections::{HashMap, HashSet};
507    use std::fmt::{self, Display, Formatter};
508    use std::future::{Future, poll_fn};
509    use std::pin::pin;
510    use std::sync::atomic::Ordering;
511    use std::sync::{Arc, Mutex, MutexGuard};
512    use std::task::Poll;
513    use std::time::{Duration, Instant};
514
515    use metrics::atomics::AtomicU64;
516    use metrics::{
517        Counter, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit,
518    };
519    use tokio::sync::Notify;
520    use wyrd_spec::DataTenantId;
521
522    use super::{Outbox, OutboxSink};
523
524    /// In-memory sink recording each tenant's written items in order.
525    #[derive(Default)]
526    struct MemorySink {
527        /// Shared record of written items and failing tenants.
528        state: Arc<Mutex<MemoryState>>,
529        /// Writes of hanging tenants block until notified, simulating a hang.
530        hang: Arc<Notify>,
531        /// Notified as each write starts, so a test can wait for a dispatch
532        /// without polling.
533        started: Arc<Notify>,
534    }
535
536    /// What the memory sink has written and which tenants fail or hang.
537    #[derive(Default)]
538    struct MemoryState {
539        /// Written items per tenant, in write order.
540        written: HashMap<DataTenantId, Vec<u32>>,
541        /// Tenants whose writes fail.
542        failing: HashSet<DataTenantId>,
543        /// Tenants whose writes hang.
544        hanging: HashSet<DataTenantId>,
545        /// Tenants whose next write panics once.
546        panicking: HashSet<DataTenantId>,
547        /// Tenants whose next write fails once with an error whose `Display`
548        /// panics.
549        display_panicking: HashSet<DataTenantId>,
550        /// Failed write attempts.
551        failures: usize,
552        /// Every write attempt's tenant and slice, in attempt order.
553        attempts: Vec<(DataTenantId, Vec<u32>)>,
554    }
555
556    /// Failure the memory sink reports for an injected write failure.
557    #[derive(Debug)]
558    struct MemoryError {
559        /// Whether rendering this error panics, simulating a sink error type
560        /// whose `Display` implementation is faulty.
561        panics_on_display: bool,
562    }
563
564    impl Display for MemoryError {
565        /// Renders the injected failure message.
566        ///
567        /// # Panics
568        ///
569        /// Panics when `panics_on_display` is set, so a test can prove the
570        /// writer contains a panic raised while rendering a sink error.
571        fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
572            assert!(!self.panics_on_display, "injected error display panic");
573            formatter.write_str("injected failure")
574        }
575    }
576
577    impl OutboxSink for MemorySink {
578        type Item = u32;
579        type Error = MemoryError;
580        const NAME: &'static str = "test";
581
582        /// Writes `items` for `tenant`, applying the injections configured in
583        /// [`MemoryState`] in a fixed order the tests depend on.
584        ///
585        /// The order is: notify `started` that a write was dispatched; if the
586        /// tenant is hanging, wait for `hang`; if the tenant has a one-shot
587        /// panic injected, consume it and panic; if the tenant has a one-shot
588        /// panicking-display failure injected, consume it, count a failure,
589        /// and fail; if the tenant is failing, count a failure and fail;
590        /// otherwise append `items` to the tenant's written record.
591        ///
592        /// # Errors
593        ///
594        /// Returns a [`MemoryError`] when the tenant is failing or has a
595        /// panicking-display failure injected; that error's `Display` panics
596        /// only in the latter case.
597        ///
598        /// # Panics
599        ///
600        /// Panics once, after the hang stage, when a panic is injected for the
601        /// tenant.
602        ///
603        /// # Cancellation
604        ///
605        /// Dropping the future while it waits for `hang` leaves the state
606        /// unchanged, so the batch is neither written nor counted as failed.
607        async fn write(&self, tenant: DataTenantId, items: &[u32]) -> Result<(), MemoryError> {
608            self.started.notify_one();
609            let hangs = self.lock().hanging.contains(&tenant);
610            if hangs {
611                self.hang.notified().await;
612            }
613            let panics = self.lock().panicking.remove(&tenant);
614            assert!(!panics, "injected sink panic");
615            let mut state = self.lock();
616            state.attempts.push((tenant, items.to_vec()));
617            if state.display_panicking.remove(&tenant) {
618                state.failures += 1;
619                return Err(MemoryError {
620                    panics_on_display: true,
621                });
622            }
623            if state.failing.contains(&tenant) {
624                state.failures += 1;
625                return Err(MemoryError {
626                    panics_on_display: false,
627                });
628            }
629            state
630                .written
631                .entry(tenant)
632                .or_default()
633                .extend_from_slice(items);
634            Ok(())
635        }
636    }
637
638    impl MemorySink {
639        /// Locks the shared state, which no test holds across an await.
640        fn lock(&self) -> MutexGuard<'_, MemoryState> {
641            self.state
642                .lock()
643                .expect("memory sink state is never poisoned")
644        }
645    }
646
647    /// Starts an outbox over a fresh memory sink and returns both.
648    fn outbox(concurrency: usize) -> (Arc<Outbox<MemorySink>>, Arc<Mutex<MemoryState>>) {
649        let sink = MemorySink::default();
650        let state = Arc::clone(&sink.state);
651        (Outbox::new(sink, concurrency), state)
652    }
653
654    /// Starts an outbox over `sink` and returns it with the sink's state and
655    /// write-start signal.
656    fn outbox_over(
657        sink: MemorySink,
658    ) -> (
659        Arc<Outbox<MemorySink>>,
660        Arc<Mutex<MemoryState>>,
661        Arc<Notify>,
662    ) {
663        let state = Arc::clone(&sink.state);
664        let started = Arc::clone(&sink.started);
665        (Outbox::new(sink, 4), state, started)
666    }
667
668    /// Thread-local metrics recorder keeping every counter and gauge by name.
669    ///
670    /// Installed with `metrics::set_default_local_recorder` in a
671    /// current-thread test, it also sees the outbox writer's updates because
672    /// every task runs on the test thread.
673    #[derive(Default)]
674    struct TestMetrics {
675        /// Raw value of each metric; gauges store `f64` bits.
676        values: Mutex<HashMap<String, Arc<AtomicU64>>>,
677    }
678
679    impl TestMetrics {
680        /// The shared value cell of the metric named by `key`.
681        fn cell(&self, key: &Key) -> Arc<AtomicU64> {
682            let mut values = self.values.lock().expect("test metrics are never poisoned");
683            Arc::clone(values.entry(key.name().to_owned()).or_default())
684        }
685
686        /// The raw value of metric `name`, zero when it was never touched.
687        fn raw(&self, name: &str) -> u64 {
688            self.values
689                .lock()
690                .expect("test metrics are never poisoned")
691                .get(name)
692                .map_or(0, |value| value.load(Ordering::Acquire))
693        }
694
695        /// The value of counter `name`.
696        fn counter(&self, name: &str) -> u64 {
697            self.raw(name)
698        }
699
700        /// The value of gauge `name`.
701        fn gauge(&self, name: &str) -> f64 {
702            f64::from_bits(self.raw(name))
703        }
704    }
705
706    impl Recorder for TestMetrics {
707        /// Intentionally ignores counter descriptions; tests assert values
708        /// only.
709        fn describe_counter(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}
710
711        /// Intentionally ignores gauge descriptions; tests assert values only.
712        fn describe_gauge(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}
713
714        /// Intentionally ignores histogram descriptions; histograms are not
715        /// recorded.
716        fn describe_histogram(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}
717
718        /// Records the counter by metric name, ignoring labels, so every
719        /// registration of one name shares a single value cell.
720        fn register_counter(&self, key: &Key, _: &Metadata<'_>) -> Counter {
721            Counter::from_arc(self.cell(key))
722        }
723
724        /// Records the gauge by metric name, ignoring labels, storing its
725        /// `f64` value as bits in a shared cell.
726        fn register_gauge(&self, key: &Key, _: &Metadata<'_>) -> Gauge {
727            Gauge::from_arc(self.cell(key))
728        }
729
730        /// Intentionally records nothing: the outbox emits no histogram the
731        /// tests assert on.
732        fn register_histogram(&self, _: &Key, _: &Metadata<'_>) -> Histogram {
733            Histogram::noop()
734        }
735    }
736
737    /// A deadline `seconds` from now.
738    fn within(seconds: u64) -> Instant {
739        Instant::now() + Duration::from_secs(seconds)
740    }
741
742    /// Waits until the sink has recorded at least `failures` failed writes.
743    async fn await_failures(state: &Mutex<MemoryState>, failures: usize) {
744        let deadline = within(10);
745        while state.lock().expect("unpoisoned").failures < failures {
746            assert!(Instant::now() < deadline, "the write never failed");
747            tokio::time::sleep(Duration::from_millis(5)).await;
748        }
749    }
750
751    /// A failed write is retried and committed exactly once, in order, ahead of
752    /// the tenant's later items, and `pending` returns to zero.
753    #[tokio::test]
754    async fn a_failed_write_is_retried_once_in_order_ahead_of_later_items() {
755        let (outbox, state) = outbox(4);
756        let tenant = DataTenantId::new_v7();
757        state.lock().expect("unpoisoned").failing.insert(tenant);
758        outbox.stage(tenant, 1_u32);
759        outbox.stage(tenant, 2_u32);
760        await_failures(&state, 2).await;
761        outbox.stage(tenant, 3_u32);
762        assert_eq!(outbox.pending(), 3, "failed items stay pending");
763        state.lock().expect("unpoisoned").failing.clear();
764
765        assert_eq!(outbox.settle(within(10)).await, 0, "the retry drains");
766        assert_eq!(
767            state.lock().expect("unpoisoned").written.get(&tenant),
768            Some(&vec![1, 2, 3]),
769            "retried items commit once, ahead of later items"
770        );
771        assert_eq!(outbox.pending(), 0);
772    }
773
774    /// A retry writes exactly the failed slice: every attempt sees the same
775    /// items, and an item staged after the failure is written separately once
776    /// the retry succeeds, so a sink deriving identity from its slice presents
777    /// one identity across attempts.
778    #[tokio::test]
779    async fn a_retry_writes_the_identical_failed_slice_without_later_items() {
780        let (outbox, state) = outbox(4);
781        let tenant = DataTenantId::new_v7();
782        state.lock().expect("unpoisoned").failing.insert(tenant);
783        outbox.stage(tenant, 1_u32);
784        outbox.stage(tenant, 2_u32);
785        await_failures(&state, 1).await;
786        outbox.stage(tenant, 3_u32);
787        await_failures(&state, 2).await;
788        state.lock().expect("unpoisoned").failing.clear();
789
790        assert_eq!(outbox.settle(within(10)).await, 0, "the retry drains");
791        let state = state.lock().expect("unpoisoned");
792        let (last, retries) = state
793            .attempts
794            .split_last()
795            .expect("the later item was written");
796        assert_eq!(last, &(tenant, vec![3]), "the later item is its own write");
797        assert!(retries.len() >= 3, "two failures and one successful retry");
798        assert!(
799            retries
800                .iter()
801                .all(|attempt| attempt == &(tenant, vec![1, 2])),
802            "every retry is the identical failed slice: {retries:?}"
803        );
804        assert_eq!(state.written.get(&tenant), Some(&vec![1, 2, 3]));
805    }
806
807    /// A tenant whose writes keep failing does not delay another tenant.
808    #[tokio::test]
809    async fn a_failing_tenant_does_not_delay_another_tenant() {
810        let (outbox, state) = outbox(1);
811        let failing = DataTenantId::new_v7();
812        let healthy = DataTenantId::new_v7();
813        state.lock().expect("unpoisoned").failing.insert(failing);
814        outbox.stage(failing, 1_u32);
815        await_failures(&state, 1).await;
816        outbox.stage(healthy, 2_u32);
817
818        let deadline = within(10);
819        while !state
820            .lock()
821            .expect("unpoisoned")
822            .written
823            .contains_key(&healthy)
824        {
825            assert!(Instant::now() < deadline, "the healthy tenant was delayed");
826            tokio::time::sleep(Duration::from_millis(5)).await;
827        }
828        assert_eq!(outbox.pending(), 1, "only the failing tenant's item waits");
829        state.lock().expect("unpoisoned").failing.clear();
830        assert_eq!(outbox.settle(within(10)).await, 0);
831    }
832
833    /// The queue accepts far more items than any former limit, all written.
834    #[tokio::test]
835    async fn the_queue_has_no_count_limit() {
836        let (outbox, state) = outbox(4);
837        let tenant = DataTenantId::new_v7();
838        for item in 0..50_000_u32 {
839            outbox.stage(tenant, item);
840        }
841        assert_eq!(outbox.settle(within(30)).await, 0);
842        let written = state.lock().expect("unpoisoned").written[&tenant].clone();
843        assert_eq!(written, (0..50_000_u32).collect::<Vec<_>>());
844    }
845
846    /// Shutdown keeps retrying until the deadline and writes what recovers.
847    #[tokio::test]
848    async fn shutdown_flushes_items_that_recover_before_the_deadline() {
849        let (outbox, state) = outbox(4);
850        let tenant = DataTenantId::new_v7();
851        state.lock().expect("unpoisoned").failing.insert(tenant);
852        outbox.stage(tenant, 1_u32);
853        await_failures(&state, 1).await;
854        let shutdown = {
855            let outbox = Arc::clone(&outbox);
856            tokio::spawn(async move { outbox.shutdown(within(10)).await })
857        };
858        await_failures(&state, 2).await;
859        state.lock().expect("unpoisoned").failing.clear();
860
861        assert_eq!(shutdown.await.expect("shutdown joins"), 0, "nothing lost");
862        assert_eq!(
863            state.lock().expect("unpoisoned").written.get(&tenant),
864            Some(&vec![1])
865        );
866        outbox.stage(tenant, 2_u32);
867        assert_eq!(outbox.pending(), 0, "a shut-down outbox accepts nothing");
868    }
869
870    /// Shutdown gives up at its deadline, reports failing and hanging items as
871    /// lost exactly once, and leaves nothing pending in the count or gauge.
872    #[tokio::test(flavor = "current_thread")]
873    async fn shutdown_counts_items_unwritten_at_the_deadline_as_lost() {
874        let metrics = TestMetrics::default();
875        let _metrics = metrics::set_default_local_recorder(&metrics);
876        let (outbox, state) = outbox(4);
877        let failing = DataTenantId::new_v7();
878        let hanging = DataTenantId::new_v7();
879        {
880            let mut state = state.lock().expect("unpoisoned");
881            state.failing.insert(failing);
882            state.hanging.insert(hanging);
883        }
884        outbox.stage(failing, 1_u32);
885        outbox.stage(failing, 2_u32);
886        outbox.stage(hanging, 3_u32);
887        await_failures(&state, 1).await;
888
889        let lost = outbox
890            .shutdown(Instant::now() + Duration::from_millis(200))
891            .await;
892        assert_eq!(lost, 3, "every unwritten item is reported lost");
893        assert!(state.lock().expect("unpoisoned").written.is_empty());
894        assert_eq!(metrics.counter("outbox_events_lost_total"), 3, "lost once");
895        assert_eq!(outbox.pending(), 0, "abandoned items are not pending");
896        assert!(
897            metrics.gauge("outbox_pending").abs() < f64::EPSILON,
898            "the pending gauge returns to zero"
899        );
900    }
901
902    /// A sink panic is retried like a failed write: the batch stays pending,
903    /// commits once ahead of a later item, the writer keeps running, nothing is
904    /// counted lost, and `pending` returns to zero.
905    #[tokio::test(flavor = "current_thread")]
906    async fn a_panicking_write_is_retried_once_in_order_without_loss() {
907        let metrics = TestMetrics::default();
908        let _metrics = metrics::set_default_local_recorder(&metrics);
909        let (outbox, state, started) = outbox_over(MemorySink::default());
910        let tenant = DataTenantId::new_v7();
911        state.lock().expect("unpoisoned").panicking.insert(tenant);
912        outbox.stage(tenant, 1_u32);
913        started.notified().await;
914        outbox.stage(tenant, 2_u32);
915        assert_eq!(outbox.pending(), 2, "the panicked batch stays pending");
916
917        assert_eq!(outbox.settle(within(10)).await, 0, "the retry drains");
918        assert_eq!(
919            state.lock().expect("unpoisoned").written.get(&tenant),
920            Some(&vec![1, 2]),
921            "the panicked batch commits once, ahead of the later item"
922        );
923        assert_eq!(metrics.counter("outbox_write_failures_total"), 1);
924        assert_eq!(metrics.counter("outbox_events_lost_total"), 0);
925        assert!(metrics.gauge("outbox_pending").abs() < f64::EPSILON);
926    }
927
928    /// A sink error whose `Display` panics is retried like a failed write: the
929    /// batch stays pending, commits once ahead of a later item, the writer
930    /// keeps running, the failure is counted once, nothing is counted lost,
931    /// and `pending` and its gauge return to zero.
932    #[tokio::test(flavor = "current_thread")]
933    async fn a_panicking_error_display_is_retried_once_in_order_without_loss() {
934        let metrics = TestMetrics::default();
935        let _metrics = metrics::set_default_local_recorder(&metrics);
936        let (outbox, state, started) = outbox_over(MemorySink::default());
937        let tenant = DataTenantId::new_v7();
938        state
939            .lock()
940            .expect("unpoisoned")
941            .display_panicking
942            .insert(tenant);
943        outbox.stage(tenant, 1_u32);
944        started.notified().await;
945        outbox.stage(tenant, 2_u32);
946        assert_eq!(outbox.pending(), 2, "the failed batch stays pending");
947
948        assert_eq!(outbox.settle(within(10)).await, 0, "the retry drains");
949        assert_eq!(
950            state.lock().expect("unpoisoned").written.get(&tenant),
951            Some(&vec![1, 2]),
952            "the failed batch commits once, ahead of the later item"
953        );
954        assert_eq!(metrics.counter("outbox_write_failures_total"), 1);
955        assert_eq!(metrics.counter("outbox_events_lost_total"), 0);
956        assert!(metrics.gauge("outbox_pending").abs() < f64::EPSILON);
957    }
958
959    /// Shutdown fences admission when it begins: items staged before it drain
960    /// even behind a blocked write, and a later stage is refused and counted
961    /// lost without reaching the sink.
962    #[tokio::test(flavor = "current_thread")]
963    async fn shutdown_refuses_items_staged_after_it_begins() {
964        let metrics = TestMetrics::default();
965        let _metrics = metrics::set_default_local_recorder(&metrics);
966        let sink = MemorySink::default();
967        let hang = Arc::clone(&sink.hang);
968        let (outbox, state, started) = outbox_over(sink);
969        let tenant = DataTenantId::new_v7();
970        state.lock().expect("unpoisoned").hanging.insert(tenant);
971        outbox.stage(tenant, 1_u32);
972        started.notified().await;
973        outbox.stage(tenant, 2_u32);
974
975        let mut shutdown = pin!(outbox.shutdown(within(10)));
976        let begun = poll_fn(|context| Poll::Ready(shutdown.as_mut().poll(context).is_pending()));
977        assert!(begun.await, "shutdown waits for the blocked write");
978        outbox.stage(tenant, 3_u32);
979        assert_eq!(outbox.pending(), 2, "the late item never entered the queue");
980        assert_eq!(metrics.counter("outbox_events_lost_total"), 1);
981
982        state.lock().expect("unpoisoned").hanging.clear();
983        hang.notify_one();
984        assert_eq!(shutdown.await, 0, "pre-fence items drain");
985        assert_eq!(
986            state.lock().expect("unpoisoned").written.get(&tenant),
987            Some(&vec![1, 2]),
988            "only pre-fence items reach the sink"
989        );
990        assert_eq!(outbox.pending(), 0);
991        assert!(metrics.gauge("outbox_pending").abs() < f64::EPSILON);
992    }
993}