1use 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
45pub const INITIAL_BACKOFF: Duration = Duration::from_millis(50);
47
48pub const MAX_BACKOFF: Duration = Duration::from_secs(5);
50
51pub trait OutboxSink: Send + Sync + 'static {
57 type Item: Send + 'static;
59 type Error: Display + Send + 'static;
61 const NAME: &'static str;
63
64 fn write(
75 &self,
76 tenant: DataTenantId,
77 items: &[Self::Item],
78 ) -> impl Future<Output = Result<(), Self::Error>> + Send;
79}
80
81type QueueSender<S> = mpsc::UnboundedSender<(DataTenantId, <S as OutboxSink>::Item)>;
83
84pub struct Outbox<S: OutboxSink> {
86 queue: RwLock<Option<QueueSender<S>>>,
91 pending: Arc<AtomicUsize>,
93 idle: Arc<Notify>,
96 abandon: CancellationToken,
98 writer: TaskTracker,
100 sink: Arc<S>,
102}
103
104impl<S: OutboxSink> Outbox<S> {
105 #[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 #[must_use]
151 pub fn sink(&self) -> &S {
152 &self.sink
153 }
154
155 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 #[must_use]
178 pub fn pending(&self) -> usize {
179 self.pending.load(Ordering::Acquire)
180 }
181
182 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 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
242type Written<S> = (
245 DataTenantId,
246 Vec<<S as OutboxSink>::Item>,
247 Result<(), String>,
248);
249
250struct OutboxWriter<S: OutboxSink> {
255 sink: Arc<S>,
257 requests: mpsc::UnboundedReceiver<(DataTenantId, S::Item)>,
259 waiting: HashMap<DataTenantId, Vec<S::Item>>,
261 failed: HashMap<DataTenantId, Vec<S::Item>>,
264 writing: JoinSet<Written<S>>,
266 in_flight: HashMap<Id, (DataTenantId, usize)>,
268 retry_at: HashMap<DataTenantId, (Instant, Duration)>,
271 concurrency: usize,
273 pending: Arc<AtomicUsize>,
275 idle: Arc<Notify>,
277 abandon: CancellationToken,
279}
280
281impl<S: OutboxSink> OutboxWriter<S> {
282 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 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 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 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 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 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 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 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
461async 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
482fn 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
491pub 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 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 #[derive(Default)]
526 struct MemorySink {
527 state: Arc<Mutex<MemoryState>>,
529 hang: Arc<Notify>,
531 started: Arc<Notify>,
534 }
535
536 #[derive(Default)]
538 struct MemoryState {
539 written: HashMap<DataTenantId, Vec<u32>>,
541 failing: HashSet<DataTenantId>,
543 hanging: HashSet<DataTenantId>,
545 panicking: HashSet<DataTenantId>,
547 display_panicking: HashSet<DataTenantId>,
550 failures: usize,
552 attempts: Vec<(DataTenantId, Vec<u32>)>,
554 }
555
556 #[derive(Debug)]
558 struct MemoryError {
559 panics_on_display: bool,
562 }
563
564 impl Display for MemoryError {
565 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 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 fn lock(&self) -> MutexGuard<'_, MemoryState> {
641 self.state
642 .lock()
643 .expect("memory sink state is never poisoned")
644 }
645 }
646
647 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 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 #[derive(Default)]
674 struct TestMetrics {
675 values: Mutex<HashMap<String, Arc<AtomicU64>>>,
677 }
678
679 impl TestMetrics {
680 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 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 fn counter(&self, name: &str) -> u64 {
697 self.raw(name)
698 }
699
700 fn gauge(&self, name: &str) -> f64 {
702 f64::from_bits(self.raw(name))
703 }
704 }
705
706 impl Recorder for TestMetrics {
707 fn describe_counter(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}
710
711 fn describe_gauge(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}
713
714 fn describe_histogram(&self, _: KeyName, _: Option<Unit>, _: SharedString) {}
717
718 fn register_counter(&self, key: &Key, _: &Metadata<'_>) -> Counter {
721 Counter::from_arc(self.cell(key))
722 }
723
724 fn register_gauge(&self, key: &Key, _: &Metadata<'_>) -> Gauge {
727 Gauge::from_arc(self.cell(key))
728 }
729
730 fn register_histogram(&self, _: &Key, _: &Metadata<'_>) -> Histogram {
733 Histogram::noop()
734 }
735 }
736
737 fn within(seconds: u64) -> Instant {
739 Instant::now() + Duration::from_secs(seconds)
740 }
741
742 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}