1use std::{
36 collections::VecDeque,
37 fmt::Debug,
38 sync::{
39 Arc,
40 atomic::{AtomicBool, AtomicU64, Ordering},
41 },
42};
43
44use ahash::AHashSet;
45use nautilus_core::{UUID4, UnixNanos};
46use parking_lot::Mutex;
47
48use crate::{
49 capture::{encoder::EncodeError, registry::EncoderRegistry},
50 entry::Topic,
51 headers::Headers,
52 writer::{EntryDraft, EventStoreWriter, HaltCallback, HaltReason, SubmitError},
53};
54
55const RECENT_IDENTITY_CAPACITY: usize = 128;
58
59#[derive(Debug, thiserror::Error)]
65pub enum CaptureError {
66 #[error("encode failure: {0}")]
68 Encode(#[from] EncodeError),
69 #[error("writer submit failed: {0}")]
74 Submit(#[from] SubmitError),
75 #[error("capture adapter halted")]
81 Halted,
82}
83
84pub struct BusCaptureAdapter {
91 writer: Arc<EventStoreWriter>,
92 registry: Arc<EncoderRegistry>,
93 halt: HaltCallback,
94 halted: AtomicBool,
95 submit_counter: Option<Arc<AtomicU64>>,
96 recent_identities: Mutex<RecentIdentities>,
97}
98
99#[derive(Debug, Default)]
101struct RecentIdentities {
102 order: VecDeque<UUID4>,
103 seen: AHashSet<UUID4>,
104}
105
106impl RecentIdentities {
107 fn note_fresh(&mut self, identity: UUID4) -> bool {
109 if self.seen.contains(&identity) {
110 return false;
111 }
112
113 if self.order.len() == RECENT_IDENTITY_CAPACITY
114 && let Some(evicted) = self.order.pop_front()
115 {
116 self.seen.remove(&evicted);
117 }
118 self.order.push_back(identity);
119 self.seen.insert(identity);
120 true
121 }
122}
123
124impl Debug for BusCaptureAdapter {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct(stringify!(BusCaptureAdapter))
127 .field("registered_encoders", &self.registry.len())
128 .field("halted", &self.halted.load(Ordering::Acquire))
129 .finish_non_exhaustive()
130 }
131}
132
133impl BusCaptureAdapter {
134 #[must_use]
141 pub fn new(
142 writer: Arc<EventStoreWriter>,
143 registry: Arc<EncoderRegistry>,
144 halt: HaltCallback,
145 ) -> Self {
146 Self {
147 writer,
148 registry,
149 halt,
150 halted: AtomicBool::new(false),
151 submit_counter: None,
152 recent_identities: Mutex::new(RecentIdentities::default()),
153 }
154 }
155
156 #[must_use]
158 pub fn with_submit_counter(mut self, submit_counter: Arc<AtomicU64>) -> Self {
159 self.submit_counter = Some(submit_counter);
160 self
161 }
162
163 #[must_use]
165 pub fn is_halted(&self) -> bool {
166 self.halted.load(Ordering::Acquire)
167 }
168
169 #[must_use]
171 pub fn registry(&self) -> &EncoderRegistry {
172 &self.registry
173 }
174
175 #[must_use]
177 pub fn high_watermark(&self) -> u64 {
178 self.writer.high_watermark()
179 }
180
181 pub fn capture<T: 'static>(
206 &self,
207 topic: Topic,
208 message: &T,
209 headers: Headers,
210 ts_init: UnixNanos,
211 ) -> Result<bool, CaptureError> {
212 self.capture_any(topic, message as &dyn std::any::Any, headers, ts_init)
213 }
214
215 pub fn capture_any(
226 &self,
227 topic: Topic,
228 message: &dyn std::any::Any,
229 headers: Headers,
230 ts_init: UnixNanos,
231 ) -> Result<bool, CaptureError> {
232 if self.halted.load(Ordering::Acquire) {
233 return Err(CaptureError::Halted);
234 }
235
236 let Some((payload_type, encoded)) = self.registry.encode_any(message)? else {
239 return Ok(false);
240 };
241
242 if let Some(identity) = self.registry.identity_for_any(message)
243 && !self.note_fresh_identity(identity)
244 {
245 return Ok(false);
246 }
247
248 let draft = EntryDraft {
249 headers,
250 topic,
251 payload_type,
252 payload: encoded.payload,
253 ts_init,
254 index_keys: encoded.index_keys,
255 };
256
257 match self.writer.submit(draft) {
258 Ok(()) => {
259 if let Some(submit_counter) = self.submit_counter.as_ref() {
260 submit_counter.fetch_add(1, Ordering::AcqRel);
261 }
262 Ok(true)
263 }
264 Err(e) => {
265 self.fail_stop(&e);
266 Err(CaptureError::Submit(e))
267 }
268 }
269 }
270
271 fn note_fresh_identity(&self, identity: UUID4) -> bool {
272 self.recent_identities.lock().note_fresh(identity)
273 }
274
275 fn fail_stop(&self, err: &SubmitError) {
276 if self
277 .halted
278 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
279 .is_ok()
280 {
281 (self.halt)(halt_reason_from_submit(err));
282 }
283 }
284}
285
286fn halt_reason_from_submit(err: &SubmitError) -> HaltReason {
294 match err {
295 SubmitError::HaltSignaled {
296 stalled_for,
297 threshold,
298 } => HaltReason::BackpressureStall {
299 stalled_for: *stalled_for,
300 threshold: *threshold,
301 },
302 SubmitError::Closed => HaltReason::BackendError("event store writer closed".to_string()),
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use std::{
309 sync::{
310 Arc,
311 atomic::{AtomicU64, AtomicUsize, Ordering},
312 },
313 time::Duration,
314 };
315
316 use bytes::Bytes;
317 use indexmap::IndexMap;
318 use nautilus_core::{UUID4, UnixNanos, time::get_atomic_clock_static};
319 use parking_lot::Mutex;
320 use rstest::{fixture, rstest};
321 use ustr::Ustr;
322
323 use super::*;
324 use crate::{
325 backend::{AppendEntry, EventStore, IndexKey, IndexKind, MemoryBackend, ScanDirection},
326 capture::encoder::EncodedPayload,
327 entry::EventStoreEntry,
328 error::EventStoreError,
329 manifest::{RegisteredComponents, RunManifest, RunStatus},
330 writer::WriterConfig,
331 };
332
333 #[derive(Debug)]
334 struct StubCommand {
335 client_order_id: String,
336 }
337
338 #[derive(Debug)]
339 struct StubEvent {
340 client_order_id: String,
341 venue_order_id: String,
342 }
343
344 #[derive(Debug)]
345 struct UnknownMessage;
346
347 #[derive(Debug)]
348 struct FailingMessage;
349
350 #[derive(Debug)]
351 struct StubIdentifiedCommand {
352 id: UUID4,
353 payload: String,
354 }
355
356 fn manifest(run_id: &str) -> RunManifest {
357 RunManifest {
358 run_id: run_id.to_string(),
359 parent_run_id: None,
360 instance_id: "trader-001".to_string(),
361 binary_hash: "deadbeef".to_string(),
362 schema_version: 1,
363 crate_versions: "feedface".to_string(),
364 feature_flags: Vec::new(),
365 adapter_versions: IndexMap::new(),
366 config_hash: "cafebabe".to_string(),
367 registered_components: RegisteredComponents::default(),
368 seed: None,
369 start_ts_init: UnixNanos::from(0),
370 end_ts_init: None,
371 high_watermark: 0,
372 status: RunStatus::Running,
373 }
374 }
375
376 fn stub_registry() -> Arc<EncoderRegistry> {
377 let mut registry = EncoderRegistry::new();
378 registry.register::<StubCommand, _>(Ustr::from("StubCommand"), |c| {
379 Ok(EncodedPayload::new(
380 Bytes::copy_from_slice(c.client_order_id.as_bytes()),
381 vec![IndexKey::new(
382 IndexKind::ClientOrderId,
383 c.client_order_id.clone(),
384 )],
385 ))
386 });
387 registry.register::<StubEvent, _>(Ustr::from("StubEvent"), |e| {
388 Ok(EncodedPayload::new(
389 Bytes::copy_from_slice(e.client_order_id.as_bytes()),
390 vec![
391 IndexKey::new(IndexKind::ClientOrderId, e.client_order_id.clone()),
392 IndexKey::new(IndexKind::VenueOrderId, e.venue_order_id.clone()),
393 ],
394 ))
395 });
396 registry.register::<FailingMessage, _>(Ustr::from("FailingMessage"), |_| {
397 Err(EncodeError::Serialize(
398 "encoder rejected message".to_string(),
399 ))
400 });
401 Arc::new(registry)
402 }
403
404 #[fixture]
405 fn captured_halt() -> (HaltCallback, Arc<Mutex<Vec<HaltReason>>>) {
406 let captured: Arc<Mutex<Vec<HaltReason>>> = Arc::new(Mutex::new(Vec::new()));
407 let captured_for_cb = Arc::clone(&captured);
408 let halt: HaltCallback = Arc::new(move |reason| {
409 captured_for_cb.lock().push(reason);
410 });
411 (halt, captured)
412 }
413
414 fn writer_with_open_run(
415 run_id: &str,
416 halt: HaltCallback,
417 ) -> (Arc<EventStoreWriter>, Arc<Mutex<MemoryBackend>>) {
418 let backend_arc: Arc<Mutex<MemoryBackend>> = Arc::new(Mutex::new(MemoryBackend::new()));
419 backend_arc
420 .lock()
421 .open_run(manifest(run_id))
422 .expect("open run");
423
424 let wrapper = SharedMemory(Arc::clone(&backend_arc));
425 let writer = EventStoreWriter::spawn(
426 Box::new(wrapper),
427 get_atomic_clock_static(),
428 halt,
429 WriterConfig::default(),
430 )
431 .expect("spawn");
432 (Arc::new(writer), backend_arc)
433 }
434
435 #[derive(Debug)]
438 struct SharedMemory(Arc<Mutex<MemoryBackend>>);
439
440 impl EventStore for SharedMemory {
441 fn open_run(&mut self, _: RunManifest) -> Result<(), EventStoreError> {
442 unreachable!("test wrapper does not forward open_run")
443 }
444
445 fn append_batch(&mut self, entries: &[AppendEntry]) -> Result<u64, EventStoreError> {
446 self.0.lock().append_batch(entries)
447 }
448
449 fn scan_range(
450 &self,
451 from: u64,
452 to: u64,
453 direction: ScanDirection,
454 ) -> Result<Vec<EventStoreEntry>, EventStoreError> {
455 self.0.lock().scan_range(from, to, direction)
456 }
457
458 fn scan_seq(&self, seq: u64) -> Result<Option<EventStoreEntry>, EventStoreError> {
459 self.0.lock().scan_seq(seq)
460 }
461
462 fn lookup(&self, kind: IndexKind, key: &str) -> Result<Option<u64>, EventStoreError> {
463 self.0.lock().lookup(kind, key)
464 }
465
466 fn iter_index_keys(&self, kind: IndexKind) -> Result<Vec<(String, u64)>, EventStoreError> {
467 self.0.lock().iter_index_keys(kind)
468 }
469
470 fn seal(&mut self, status: RunStatus) -> Result<(), EventStoreError> {
471 self.0.lock().seal(status)
472 }
473
474 fn manifest(&self) -> Result<RunManifest, EventStoreError> {
475 self.0.lock().manifest()
476 }
477
478 fn high_watermark(&self) -> Result<u64, EventStoreError> {
479 self.0.lock().high_watermark()
480 }
481 }
482
483 fn drain(writer: &Arc<EventStoreWriter>, target_hwm: u64) {
484 let mut waited = Duration::ZERO;
485 let deadline = Duration::from_secs(2);
486 while writer.high_watermark() < target_hwm && waited < deadline {
487 std::thread::sleep(Duration::from_millis(5));
488 waited += Duration::from_millis(5);
489 }
490 assert!(
491 writer.high_watermark() >= target_hwm,
492 "writer high_watermark {} did not reach {target_hwm} within {:?}",
493 writer.high_watermark(),
494 deadline,
495 );
496 }
497
498 #[rstest]
499 fn capture_records_registered_command_and_returns_true(
500 captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
501 ) {
502 let (halt, captured) = captured_halt;
503 let (writer, backend) = writer_with_open_run("run-cmd", Arc::clone(&halt));
504 let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt);
505
506 let cmd = StubCommand {
507 client_order_id: "O-1".to_string(),
508 };
509 let captured_flag = adapter
510 .capture::<StubCommand>(
511 Topic::from("exec.command.SubmitOrder"),
512 &cmd,
513 Headers::empty(),
514 UnixNanos::from(100),
515 )
516 .expect("capture");
517
518 assert!(captured_flag);
519 drain(&writer, 1);
520
521 let backend = backend.lock();
522 let entry = backend.scan_seq(1).expect("scan").expect("present");
523 assert_eq!(entry.payload_type.as_str(), "StubCommand");
524 assert_eq!(entry.topic.as_ref(), "exec.command.SubmitOrder");
525 assert_eq!(entry.payload.as_ref(), b"O-1");
526
527 let seq = backend
528 .lookup(IndexKind::ClientOrderId, "O-1")
529 .expect("lookup")
530 .expect("indexed");
531 assert_eq!(seq, 1);
532
533 assert!(captured.lock().is_empty());
534 assert!(!adapter.is_halted());
535 }
536
537 #[rstest]
538 fn capture_returns_false_for_unknown_type(
539 captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
540 ) {
541 let (halt, _captured) = captured_halt;
542 let (writer, _backend) = writer_with_open_run("run-unknown", Arc::clone(&halt));
543 let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt);
544
545 let captured_flag = adapter
546 .capture::<UnknownMessage>(
547 Topic::from("data.market.unknown"),
548 &UnknownMessage,
549 Headers::empty(),
550 UnixNanos::from(50),
551 )
552 .expect("capture");
553
554 assert!(!captured_flag);
555 assert_eq!(writer.high_watermark(), 0);
556 assert!(!adapter.is_halted());
557 }
558
559 #[rstest]
560 fn submit_counter_increments_on_each_captured_entry(
561 captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
562 ) {
563 let (halt, _captured) = captured_halt;
564 let (writer, _backend) = writer_with_open_run("run-submit-counter", Arc::clone(&halt));
565 let submit_counter = Arc::new(AtomicU64::new(1));
566 let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt)
567 .with_submit_counter(Arc::clone(&submit_counter));
568
569 adapter
570 .capture::<StubCommand>(
571 Topic::from("exec.command.SubmitOrder"),
572 &StubCommand {
573 client_order_id: "O-counter-1".to_string(),
574 },
575 Headers::empty(),
576 UnixNanos::from(100),
577 )
578 .expect("first capture");
579 adapter
580 .capture::<UnknownMessage>(
581 Topic::from("data.market.unknown"),
582 &UnknownMessage,
583 Headers::empty(),
584 UnixNanos::from(101),
585 )
586 .expect("unknown type");
587 adapter
588 .capture::<StubEvent>(
589 Topic::from("exec.event.OrderFilled"),
590 &StubEvent {
591 client_order_id: "O-counter-1".to_string(),
592 venue_order_id: "V-counter-1".to_string(),
593 },
594 Headers::empty(),
595 UnixNanos::from(102),
596 )
597 .expect("second capture");
598
599 assert_eq!(submit_counter.load(Ordering::Acquire), 3);
600 }
601
602 #[rstest]
603 fn capture_records_event_indices_atomically(
604 captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
605 ) {
606 let (halt, _captured) = captured_halt;
607 let (writer, backend) = writer_with_open_run("run-event", Arc::clone(&halt));
608 let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt);
609
610 let event = StubEvent {
611 client_order_id: "O-2".to_string(),
612 venue_order_id: "V-9".to_string(),
613 };
614 adapter
615 .capture::<StubEvent>(
616 Topic::from("exec.event.OrderFilled"),
617 &event,
618 Headers::empty(),
619 UnixNanos::from(200),
620 )
621 .expect("capture");
622 drain(&writer, 1);
623
624 let backend = backend.lock();
625 let by_client = backend
626 .lookup(IndexKind::ClientOrderId, "O-2")
627 .expect("lookup")
628 .expect("indexed");
629 let by_venue = backend
630 .lookup(IndexKind::VenueOrderId, "V-9")
631 .expect("lookup")
632 .expect("indexed");
633 assert_eq!(by_client, 1);
634 assert_eq!(by_venue, 1);
635 }
636
637 #[rstest]
638 fn capture_propagates_encoder_error_without_halting(
639 captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
640 ) {
641 let (halt, captured) = captured_halt;
645 let (writer, backend) = writer_with_open_run("run-encode-err", Arc::clone(&halt));
646 let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt);
647
648 let err = adapter
649 .capture::<FailingMessage>(
650 Topic::from("exec.command.Failing"),
651 &FailingMessage,
652 Headers::empty(),
653 UnixNanos::from(500),
654 )
655 .expect_err("encoder must reject");
656
657 match err {
658 CaptureError::Encode(EncodeError::Serialize(msg)) => {
659 assert!(msg.contains("rejected"), "msg was: {msg}");
660 }
661 other => panic!("expected Encode(Serialize), was {other:?}"),
662 }
663 assert!(
664 !adapter.is_halted(),
665 "encoder failure must not fail-stop the adapter",
666 );
667 assert!(captured.lock().is_empty());
668
669 adapter
671 .capture::<StubCommand>(
672 Topic::from("exec.command.SubmitOrder"),
673 &StubCommand {
674 client_order_id: "O-after-encode-err".to_string(),
675 },
676 Headers::empty(),
677 UnixNanos::from(501),
678 )
679 .expect("capture after encoder error");
680 drain(&writer, 1);
681 let backend = backend.lock();
682 assert_eq!(backend.high_watermark().expect("hwm"), 1);
683 }
684
685 #[rstest]
686 fn capture_dedupes_second_dispatch_hop_by_identity(
687 captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
688 ) {
689 let (halt, _captured) = captured_halt;
690 let (writer, backend) = writer_with_open_run("run-dedup", Arc::clone(&halt));
691
692 let mut registry = EncoderRegistry::new();
693 registry.register::<StubIdentifiedCommand, _>(Ustr::from("StubIdentified"), |c| {
694 Ok(EncodedPayload::new(
695 Bytes::copy_from_slice(c.payload.as_bytes()),
696 Vec::new(),
697 ))
698 });
699 registry.register_identity::<StubIdentifiedCommand, _>(|c| Some(c.id));
700 let adapter = BusCaptureAdapter::new(Arc::clone(&writer), Arc::new(registry), halt);
701
702 let command = StubIdentifiedCommand {
703 id: UUID4::new(),
704 payload: "queued-command".to_string(),
705 };
706 let first = adapter
707 .capture::<StubIdentifiedCommand>(
708 Topic::from("DataEngine.queue_execute"),
709 &command,
710 Headers::empty(),
711 UnixNanos::from(100),
712 )
713 .expect("first hop");
714 let second = adapter
715 .capture::<StubIdentifiedCommand>(
716 Topic::from("DataEngine.execute"),
717 &command,
718 Headers::empty(),
719 UnixNanos::from(101),
720 )
721 .expect("second hop");
722
723 assert!(first, "first dispatch hop must capture");
724 assert!(
725 !second,
726 "second dispatch hop of the same identity must dedupe"
727 );
728 drain(&writer, 1);
729 let backend = backend.lock();
730 assert_eq!(backend.high_watermark().expect("hwm"), 1);
731 }
732
733 #[rstest]
734 fn capture_retries_encode_on_next_hop_after_encoder_failure(
735 captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
736 ) {
737 let (halt, _captured) = captured_halt;
740 let (writer, backend) = writer_with_open_run("run-encode-retry", Arc::clone(&halt));
741
742 let attempts = Arc::new(AtomicUsize::new(0));
743 let attempts_for_encoder = Arc::clone(&attempts);
744 let mut registry = EncoderRegistry::new();
745 registry.register::<StubIdentifiedCommand, _>(Ustr::from("StubIdentified"), move |c| {
746 let attempt = attempts_for_encoder.fetch_add(1, Ordering::AcqRel);
747 if attempt == 0 {
748 return Err(EncodeError::Serialize(
749 "transient encoder failure".to_string(),
750 ));
751 }
752 Ok(EncodedPayload::new(
753 Bytes::copy_from_slice(c.payload.as_bytes()),
754 Vec::new(),
755 ))
756 });
757 registry.register_identity::<StubIdentifiedCommand, _>(|c| Some(c.id));
758 let adapter = BusCaptureAdapter::new(Arc::clone(&writer), Arc::new(registry), halt);
759
760 let command = StubIdentifiedCommand {
761 id: UUID4::new(),
762 payload: "retry-me".to_string(),
763 };
764 let err = adapter
765 .capture::<StubIdentifiedCommand>(
766 Topic::from("DataEngine.queue_execute"),
767 &command,
768 Headers::empty(),
769 UnixNanos::from(100),
770 )
771 .expect_err("first encode must fail");
772 assert!(matches!(err, CaptureError::Encode(_)));
773
774 let retried = adapter
775 .capture::<StubIdentifiedCommand>(
776 Topic::from("DataEngine.execute"),
777 &command,
778 Headers::empty(),
779 UnixNanos::from(101),
780 )
781 .expect("second hop re-attempts encode");
782
783 assert!(retried, "encode retry must capture, was deduped");
784 assert_eq!(attempts.load(Ordering::Acquire), 2);
785 drain(&writer, 1);
786 let backend = backend.lock();
787 assert_eq!(backend.high_watermark().expect("hwm"), 1);
788 }
789
790 #[rstest]
791 #[case::backpressure(
792 SubmitError::HaltSignaled {
793 stalled_for: Duration::from_millis(750),
794 threshold: Duration::from_millis(250),
795 },
796 HaltReason::BackpressureStall {
797 stalled_for: Duration::from_millis(750),
798 threshold: Duration::from_millis(250),
799 },
800 )]
801 #[case::closed(
802 SubmitError::Closed,
803 HaltReason::BackendError("event store writer closed".to_string()),
804 )]
805 fn halt_reason_from_submit_preserves_failure_context(
806 #[case] err: SubmitError,
807 #[case] expected: HaltReason,
808 ) {
809 let actual = halt_reason_from_submit(&err);
810
811 match (actual, expected) {
812 (
813 HaltReason::BackpressureStall {
814 stalled_for: a_s,
815 threshold: a_t,
816 },
817 HaltReason::BackpressureStall {
818 stalled_for: e_s,
819 threshold: e_t,
820 },
821 ) => {
822 assert_eq!(a_s, e_s);
823 assert_eq!(a_t, e_t);
824 }
825 (HaltReason::BackendError(a), HaltReason::BackendError(e)) => {
826 assert_eq!(a, e);
827 }
828 (actual, expected) => {
829 panic!("variant mismatch: actual={actual:?} expected={expected:?}")
830 }
831 }
832 }
833
834 #[rstest]
835 fn submit_failure_halts_adapter_and_fires_callback_once(
836 captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
837 ) {
838 let (halt, captured) = captured_halt;
842 let (writer, _backend) = writer_with_open_run("run-halt", Arc::clone(&halt));
843
844 let writer_clone = Arc::clone(&writer);
846 let adapter = BusCaptureAdapter::new(writer_clone, stub_registry(), halt);
849
850 drop(writer);
855
856 let halt_for_stub: HaltCallback = adapter_halt_for(&captured);
859 let stub_adapter = StubFailAdapter::new(halt_for_stub);
860
861 let err = stub_adapter
862 .capture::<StubCommand>(
863 Topic::from("exec.command.SubmitOrder"),
864 &StubCommand {
865 client_order_id: "O-fail".to_string(),
866 },
867 Headers::empty(),
868 UnixNanos::from(1),
869 )
870 .expect_err("first submit fails");
871 assert!(matches!(err, CaptureError::Submit(SubmitError::Closed)));
872 assert!(stub_adapter.is_halted());
873 assert_eq!(captured.lock().len(), 1);
874
875 let err2 = stub_adapter
876 .capture::<StubCommand>(
877 Topic::from("exec.command.SubmitOrder"),
878 &StubCommand {
879 client_order_id: "O-fail-2".to_string(),
880 },
881 Headers::empty(),
882 UnixNanos::from(2),
883 )
884 .expect_err("second submit short-circuits");
885 assert!(matches!(err2, CaptureError::Halted));
886 assert_eq!(
887 captured.lock().len(),
888 1,
889 "halt callback must not refire after the first failure",
890 );
891
892 drop(adapter);
894 }
895
896 fn adapter_halt_for(captured: &Arc<Mutex<Vec<HaltReason>>>) -> HaltCallback {
897 let captured_for_cb = Arc::clone(captured);
898 Arc::new(move |reason| {
899 captured_for_cb.lock().push(reason);
900 })
901 }
902
903 struct StubFailAdapter {
907 registry: Arc<EncoderRegistry>,
908 halt: HaltCallback,
909 halted: AtomicBool,
910 }
911
912 impl StubFailAdapter {
913 fn new(halt: HaltCallback) -> Self {
914 Self {
915 registry: stub_registry(),
916 halt,
917 halted: AtomicBool::new(false),
918 }
919 }
920
921 fn is_halted(&self) -> bool {
922 self.halted.load(Ordering::Acquire)
923 }
924
925 fn capture<T: 'static>(
926 &self,
927 _topic: Topic,
928 message: &T,
929 _headers: Headers,
930 _ts_init: UnixNanos,
931 ) -> Result<bool, CaptureError> {
932 if self.halted.load(Ordering::Acquire) {
933 return Err(CaptureError::Halted);
934 }
935 let Some((_pt, _encoded)) = self.registry.encode(message)? else {
936 return Ok(false);
937 };
938 let err = SubmitError::Closed;
939
940 if self
941 .halted
942 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
943 .is_ok()
944 {
945 (self.halt)(super::halt_reason_from_submit(&err));
946 }
947 Err(CaptureError::Submit(err))
948 }
949 }
950}