Skip to main content

nautilus_event_store/capture/
adapter.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! The bus capture adapter.
17//!
18//! [`BusCaptureAdapter`] is the seam between the message bus and the
19//! [`EventStoreWriter`]. The kernel calls [`BusCaptureAdapter::capture`] inside its bus
20//! dispatch wrappers, immediately before the message reaches downstream handlers, so
21//! every captured entry is durably submitted *before* a subscriber observes it. The
22//! adapter consults the [`EncoderRegistry`] allow-list to decide whether to capture, and
23//! converts the typed message into an [`EntryDraft`] for the writer.
24//!
25//! No-drop contract: any [`SubmitError`] from the writer fires the adapter's halt
26//! callback exactly once (so kernel fail-stop runs even when the writer's own halt path
27//! has not, such as when a caller closes the writer externally) and surfaces as
28//! [`CaptureError::Submit`]. Subsequent capture calls short-circuit with
29//! [`CaptureError::Halted`] without re-entering the writer.
30//!
31//! Under `cfg(madsim)` the writer's `submit` is a synchronous in-thread commit, so the
32//! adapter exposes the same surface and no thread-scheduling differences leak into
33//! tests.
34
35use std::{
36    collections::VecDeque,
37    fmt::Debug,
38    sync::{
39        Arc, Mutex, PoisonError,
40        atomic::{AtomicBool, AtomicU64, Ordering},
41    },
42};
43
44use ahash::AHashSet;
45use nautilus_core::{UUID4, UnixNanos};
46
47use crate::{
48    capture::{encoder::EncodeError, registry::EncoderRegistry},
49    entry::Topic,
50    headers::Headers,
51    writer::{EntryDraft, EventStoreWriter, HaltCallback, HaltReason, SubmitError},
52};
53
54// Duplicate dispatches of the same message land within one engine cycle (endpoint send
55// followed by topic publish), so a small dedup window suffices and keeps memory flat.
56const RECENT_IDENTITY_CAPACITY: usize = 128;
57
58/// Errors returned by [`BusCaptureAdapter::capture`].
59///
60/// Each variant maps to a SPEC-named failure mode at the dispatch boundary; the kernel's
61/// fail-stop callback is the system response to [`CaptureError::Submit`] and
62/// [`CaptureError::Halted`].
63#[derive(Debug, thiserror::Error)]
64pub enum CaptureError {
65    /// The encoder rejected the message.
66    #[error("encode failure: {0}")]
67    Encode(#[from] EncodeError),
68    /// The writer rejected the submit.
69    ///
70    /// The adapter halt callback has fired before this error returns, so the kernel
71    /// fail-stop path is already in motion when the caller observes it.
72    #[error("writer submit failed: {0}")]
73    Submit(#[from] SubmitError),
74    /// A prior capture observed a writer failure and the adapter has fail-stopped.
75    ///
76    /// The halt callback fired on the original failure; subsequent captures short-circuit
77    /// without re-entering the writer to keep the no-drop contract intact (a stuck or
78    /// closed writer must not silently swallow captures).
79    #[error("capture adapter halted")]
80    Halted,
81}
82
83/// Captures bus traffic and forwards encoded entries to the [`EventStoreWriter`].
84///
85/// One adapter instance owns one writer; the kernel constructs the adapter after spawning
86/// the writer, then registers it with the bus dispatch wrappers. The adapter is `Send +
87/// Sync` so it can be shared between bus subscribers, but in practice the message bus is
88/// single-threaded and the adapter lives on the engine thread.
89pub struct BusCaptureAdapter {
90    writer: Arc<EventStoreWriter>,
91    registry: Arc<EncoderRegistry>,
92    halt: HaltCallback,
93    halted: AtomicBool,
94    submit_counter: Option<Arc<AtomicU64>>,
95    recent_identities: Mutex<RecentIdentities>,
96}
97
98// Insertion-ordered set of recently captured message identities with FIFO eviction.
99#[derive(Debug, Default)]
100struct RecentIdentities {
101    order: VecDeque<UUID4>,
102    seen: AHashSet<UUID4>,
103}
104
105impl RecentIdentities {
106    // Returns false when `identity` was already noted; records it otherwise.
107    fn note_fresh(&mut self, identity: UUID4) -> bool {
108        if self.seen.contains(&identity) {
109            return false;
110        }
111
112        if self.order.len() == RECENT_IDENTITY_CAPACITY
113            && let Some(evicted) = self.order.pop_front()
114        {
115            self.seen.remove(&evicted);
116        }
117        self.order.push_back(identity);
118        self.seen.insert(identity);
119        true
120    }
121}
122
123impl Debug for BusCaptureAdapter {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct(stringify!(BusCaptureAdapter))
126            .field("registered_encoders", &self.registry.len())
127            .field("halted", &self.halted.load(Ordering::Acquire))
128            .finish_non_exhaustive()
129    }
130}
131
132impl BusCaptureAdapter {
133    /// Constructs a new adapter over `writer`, `registry`, and `halt`.
134    ///
135    /// `halt` is the adapter-level fail-stop callback. The writer carries its own halt
136    /// callback for backend and backpressure failures; the adapter callback fires on any
137    /// submit error so [`SubmitError::Closed`] (which can originate outside the writer's
138    /// own halt path, e.g. an external close) still reaches the kernel.
139    #[must_use]
140    pub fn new(
141        writer: Arc<EventStoreWriter>,
142        registry: Arc<EncoderRegistry>,
143        halt: HaltCallback,
144    ) -> Self {
145        Self {
146            writer,
147            registry,
148            halt,
149            halted: AtomicBool::new(false),
150            submit_counter: None,
151            recent_identities: Mutex::new(RecentIdentities::default()),
152        }
153    }
154
155    /// Shares an entry-submit counter with the data-marker capture path.
156    #[must_use]
157    pub fn with_submit_counter(mut self, submit_counter: Arc<AtomicU64>) -> Self {
158        self.submit_counter = Some(submit_counter);
159        self
160    }
161
162    /// Returns whether the adapter has fail-stopped.
163    #[must_use]
164    pub fn is_halted(&self) -> bool {
165        self.halted.load(Ordering::Acquire)
166    }
167
168    /// Returns the encoder allow-list this adapter consults.
169    #[must_use]
170    pub fn registry(&self) -> &EncoderRegistry {
171        &self.registry
172    }
173
174    /// Returns the wrapped writer's current durable high-watermark.
175    #[must_use]
176    pub fn high_watermark(&self) -> u64 {
177        self.writer.high_watermark()
178    }
179
180    /// Captures a state-affecting bus message.
181    ///
182    /// Looks up the encoder for `T`, builds an [`EntryDraft`], and forwards it to the
183    /// writer. Returns `Ok(false)` when the type has no registered encoder so the adapter
184    /// can be wired into bus dispatch paths that carry a mix of state-affecting and
185    /// non-state-affecting messages without surfacing per-message errors, and when the
186    /// message's registered identity was already captured on another dispatch hop (the
187    /// same order event reaches the tap via the portfolio endpoint send and the strategy
188    /// topic publish; the log records it once).
189    ///
190    /// `topic` is the bus topic the message was dispatched on, `headers` are the
191    /// dispatch-time correlation headers (defaulting to [`Headers::empty`] until header
192    /// propagation lands across all message types), and `ts_init` is the domain
193    /// timestamp from `AtomicTime` (typically the message's own `ts_init` field).
194    ///
195    /// # Errors
196    ///
197    /// Returns:
198    ///
199    /// - [`CaptureError::Halted`] when a prior capture already observed a writer failure
200    ///   and the adapter has fail-stopped.
201    /// - [`CaptureError::Encode`] when the registered encoder rejects the message.
202    /// - [`CaptureError::Submit`] when the writer rejects the submit; the adapter halt
203    ///   callback fires before this error returns.
204    pub fn capture<T: 'static>(
205        &self,
206        topic: Topic,
207        message: &T,
208        headers: Headers,
209        ts_init: UnixNanos,
210    ) -> Result<bool, CaptureError> {
211        self.capture_any(topic, message as &dyn std::any::Any, headers, ts_init)
212    }
213
214    /// Type-erased counterpart to [`Self::capture`].
215    ///
216    /// Bus dispatch hands messages to the tap as `&dyn Any` because the static type is
217    /// not in scope at the registration site. This method dispatches on the concrete
218    /// type behind the trait object and follows the same fail-stop semantics as
219    /// [`Self::capture`].
220    ///
221    /// # Errors
222    ///
223    /// See [`Self::capture`].
224    pub fn capture_any(
225        &self,
226        topic: Topic,
227        message: &dyn std::any::Any,
228        headers: Headers,
229        ts_init: UnixNanos,
230    ) -> Result<bool, CaptureError> {
231        if self.halted.load(Ordering::Acquire) {
232            return Err(CaptureError::Halted);
233        }
234
235        if let Some(identity) = self.registry.identity_for_any(message)
236            && !self.note_fresh_identity(identity)
237        {
238            return Ok(false);
239        }
240
241        let Some((payload_type, encoded)) = self.registry.encode_any(message)? else {
242            return Ok(false);
243        };
244
245        let draft = EntryDraft {
246            headers,
247            topic,
248            payload_type,
249            payload: encoded.payload,
250            ts_init,
251            index_keys: encoded.index_keys,
252        };
253
254        match self.writer.submit(draft) {
255            Ok(()) => {
256                if let Some(submit_counter) = self.submit_counter.as_ref() {
257                    submit_counter.fetch_add(1, Ordering::AcqRel);
258                }
259                Ok(true)
260            }
261            Err(e) => {
262                self.fail_stop(&e);
263                Err(CaptureError::Submit(e))
264            }
265        }
266    }
267
268    fn note_fresh_identity(&self, identity: UUID4) -> bool {
269        // The dedup window is a cache: on the (panic-only) poisoned path the prior state
270        // is still internally consistent, so recover the guard rather than propagate.
271        self.recent_identities
272            .lock()
273            .unwrap_or_else(PoisonError::into_inner)
274            .note_fresh(identity)
275    }
276
277    fn fail_stop(&self, err: &SubmitError) {
278        if self
279            .halted
280            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
281            .is_ok()
282        {
283            (self.halt)(halt_reason_from_submit(err));
284        }
285    }
286}
287
288/// Maps a [`SubmitError`] onto the [`HaltReason`] the adapter signals to its kernel.
289///
290/// [`SubmitError::HaltSignaled`] preserves the writer-side stall measurement so the
291/// kernel sees the same backpressure context the writer's own halt callback would carry.
292/// [`SubmitError::Closed`] surfaces as a backend error since the writer is no longer
293/// accepting work and the cause is opaque to the adapter (could be external close,
294/// crashed writer thread, or a terminal disk error already reported separately).
295fn halt_reason_from_submit(err: &SubmitError) -> HaltReason {
296    match err {
297        SubmitError::HaltSignaled {
298            stalled_for,
299            threshold,
300        } => HaltReason::BackpressureStall {
301            stalled_for: *stalled_for,
302            threshold: *threshold,
303        },
304        SubmitError::Closed => HaltReason::BackendError("event store writer closed".to_string()),
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use std::{
311        sync::{
312            Arc, Mutex,
313            atomic::{AtomicU64, Ordering},
314        },
315        time::Duration,
316    };
317
318    use bytes::Bytes;
319    use indexmap::IndexMap;
320    use nautilus_core::{UnixNanos, time::get_atomic_clock_static};
321    use rstest::{fixture, rstest};
322    use ustr::Ustr;
323
324    use super::*;
325    use crate::{
326        backend::{AppendEntry, EventStore, IndexKey, IndexKind, MemoryBackend, ScanDirection},
327        capture::encoder::EncodedPayload,
328        entry::EventStoreEntry,
329        error::EventStoreError,
330        manifest::{RegisteredComponents, RunManifest, RunStatus},
331        writer::WriterConfig,
332    };
333
334    #[derive(Debug)]
335    struct StubCommand {
336        client_order_id: String,
337    }
338
339    #[derive(Debug)]
340    struct StubEvent {
341        client_order_id: String,
342        venue_order_id: String,
343    }
344
345    #[derive(Debug)]
346    struct UnknownMessage;
347
348    #[derive(Debug)]
349    struct FailingMessage;
350
351    fn manifest(run_id: &str) -> RunManifest {
352        RunManifest {
353            run_id: run_id.to_string(),
354            parent_run_id: None,
355            instance_id: "trader-001".to_string(),
356            binary_hash: "deadbeef".to_string(),
357            schema_version: 1,
358            crate_versions: "feedface".to_string(),
359            feature_flags: Vec::new(),
360            adapter_versions: IndexMap::new(),
361            config_hash: "cafebabe".to_string(),
362            registered_components: RegisteredComponents::default(),
363            seed: None,
364            start_ts_init: UnixNanos::from(0),
365            end_ts_init: None,
366            high_watermark: 0,
367            status: RunStatus::Running,
368        }
369    }
370
371    fn stub_registry() -> Arc<EncoderRegistry> {
372        let mut registry = EncoderRegistry::new();
373        registry.register::<StubCommand, _>(Ustr::from("StubCommand"), |c| {
374            Ok(EncodedPayload::new(
375                Bytes::copy_from_slice(c.client_order_id.as_bytes()),
376                vec![IndexKey::new(
377                    IndexKind::ClientOrderId,
378                    c.client_order_id.clone(),
379                )],
380            ))
381        });
382        registry.register::<StubEvent, _>(Ustr::from("StubEvent"), |e| {
383            Ok(EncodedPayload::new(
384                Bytes::copy_from_slice(e.client_order_id.as_bytes()),
385                vec![
386                    IndexKey::new(IndexKind::ClientOrderId, e.client_order_id.clone()),
387                    IndexKey::new(IndexKind::VenueOrderId, e.venue_order_id.clone()),
388                ],
389            ))
390        });
391        registry.register::<FailingMessage, _>(Ustr::from("FailingMessage"), |_| {
392            Err(EncodeError::Serialize(
393                "encoder rejected message".to_string(),
394            ))
395        });
396        Arc::new(registry)
397    }
398
399    #[fixture]
400    fn captured_halt() -> (HaltCallback, Arc<Mutex<Vec<HaltReason>>>) {
401        let captured: Arc<Mutex<Vec<HaltReason>>> = Arc::new(Mutex::new(Vec::new()));
402        let captured_for_cb = Arc::clone(&captured);
403        let halt: HaltCallback = Arc::new(move |reason| {
404            captured_for_cb
405                .lock()
406                .expect("captured halt poisoned")
407                .push(reason);
408        });
409        (halt, captured)
410    }
411
412    fn writer_with_open_run(
413        run_id: &str,
414        halt: HaltCallback,
415    ) -> (Arc<EventStoreWriter>, Arc<Mutex<MemoryBackend>>) {
416        let backend_arc: Arc<Mutex<MemoryBackend>> = Arc::new(Mutex::new(MemoryBackend::new()));
417        backend_arc
418            .lock()
419            .expect("inner")
420            .open_run(manifest(run_id))
421            .expect("open run");
422
423        let wrapper = SharedMemory(Arc::clone(&backend_arc));
424        let writer = EventStoreWriter::spawn(
425            Box::new(wrapper),
426            get_atomic_clock_static(),
427            halt,
428            WriterConfig::default(),
429        )
430        .expect("spawn");
431        (Arc::new(writer), backend_arc)
432    }
433
434    /// Wraps a shared `MemoryBackend` so the writer thread can append while the test
435    /// reads the same instance from the engine thread.
436    #[derive(Debug)]
437    struct SharedMemory(Arc<Mutex<MemoryBackend>>);
438
439    impl EventStore for SharedMemory {
440        fn open_run(&mut self, _: RunManifest) -> Result<(), EventStoreError> {
441            unreachable!("test wrapper does not forward open_run")
442        }
443
444        fn append_batch(&mut self, entries: &[AppendEntry]) -> Result<u64, EventStoreError> {
445            self.0.lock().expect("shared").append_batch(entries)
446        }
447
448        fn scan_range(
449            &self,
450            from: u64,
451            to: u64,
452            direction: ScanDirection,
453        ) -> Result<Vec<EventStoreEntry>, EventStoreError> {
454            self.0
455                .lock()
456                .expect("shared")
457                .scan_range(from, to, direction)
458        }
459
460        fn scan_seq(&self, seq: u64) -> Result<Option<EventStoreEntry>, EventStoreError> {
461            self.0.lock().expect("shared").scan_seq(seq)
462        }
463
464        fn lookup(&self, kind: IndexKind, key: &str) -> Result<Option<u64>, EventStoreError> {
465            self.0.lock().expect("shared").lookup(kind, key)
466        }
467
468        fn iter_index_keys(&self, kind: IndexKind) -> Result<Vec<(String, u64)>, EventStoreError> {
469            self.0.lock().expect("shared").iter_index_keys(kind)
470        }
471
472        fn seal(&mut self, status: RunStatus) -> Result<(), EventStoreError> {
473            self.0.lock().expect("shared").seal(status)
474        }
475
476        fn manifest(&self) -> Result<RunManifest, EventStoreError> {
477            self.0.lock().expect("shared").manifest()
478        }
479
480        fn high_watermark(&self) -> Result<u64, EventStoreError> {
481            self.0.lock().expect("shared").high_watermark()
482        }
483    }
484
485    fn drain(writer: &Arc<EventStoreWriter>, target_hwm: u64) {
486        let mut waited = Duration::ZERO;
487        let deadline = Duration::from_secs(2);
488        while writer.high_watermark() < target_hwm && waited < deadline {
489            std::thread::sleep(Duration::from_millis(5));
490            waited += Duration::from_millis(5);
491        }
492        assert!(
493            writer.high_watermark() >= target_hwm,
494            "writer high_watermark {} did not reach {target_hwm} within {:?}",
495            writer.high_watermark(),
496            deadline,
497        );
498    }
499
500    #[rstest]
501    fn capture_records_registered_command_and_returns_true(
502        captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
503    ) {
504        let (halt, captured) = captured_halt;
505        let (writer, backend) = writer_with_open_run("run-cmd", Arc::clone(&halt));
506        let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt);
507
508        let cmd = StubCommand {
509            client_order_id: "O-1".to_string(),
510        };
511        let captured_flag = adapter
512            .capture::<StubCommand>(
513                Topic::from("exec.command.SubmitOrder"),
514                &cmd,
515                Headers::empty(),
516                UnixNanos::from(100),
517            )
518            .expect("capture");
519
520        assert!(captured_flag);
521        drain(&writer, 1);
522
523        let backend = backend.lock().expect("backend");
524        let entry = backend.scan_seq(1).expect("scan").expect("present");
525        assert_eq!(entry.payload_type.as_str(), "StubCommand");
526        assert_eq!(entry.topic.as_ref(), "exec.command.SubmitOrder");
527        assert_eq!(entry.payload.as_ref(), b"O-1");
528
529        let seq = backend
530            .lookup(IndexKind::ClientOrderId, "O-1")
531            .expect("lookup")
532            .expect("indexed");
533        assert_eq!(seq, 1);
534
535        assert!(captured.lock().expect("captured").is_empty());
536        assert!(!adapter.is_halted());
537    }
538
539    #[rstest]
540    fn capture_returns_false_for_unknown_type(
541        captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
542    ) {
543        let (halt, _captured) = captured_halt;
544        let (writer, _backend) = writer_with_open_run("run-unknown", Arc::clone(&halt));
545        let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt);
546
547        let captured_flag = adapter
548            .capture::<UnknownMessage>(
549                Topic::from("data.market.unknown"),
550                &UnknownMessage,
551                Headers::empty(),
552                UnixNanos::from(50),
553            )
554            .expect("capture");
555
556        assert!(!captured_flag);
557        assert_eq!(writer.high_watermark(), 0);
558        assert!(!adapter.is_halted());
559    }
560
561    #[rstest]
562    fn submit_counter_increments_on_each_captured_entry(
563        captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
564    ) {
565        let (halt, _captured) = captured_halt;
566        let (writer, _backend) = writer_with_open_run("run-submit-counter", Arc::clone(&halt));
567        let submit_counter = Arc::new(AtomicU64::new(1));
568        let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt)
569            .with_submit_counter(Arc::clone(&submit_counter));
570
571        adapter
572            .capture::<StubCommand>(
573                Topic::from("exec.command.SubmitOrder"),
574                &StubCommand {
575                    client_order_id: "O-counter-1".to_string(),
576                },
577                Headers::empty(),
578                UnixNanos::from(100),
579            )
580            .expect("first capture");
581        adapter
582            .capture::<UnknownMessage>(
583                Topic::from("data.market.unknown"),
584                &UnknownMessage,
585                Headers::empty(),
586                UnixNanos::from(101),
587            )
588            .expect("unknown type");
589        adapter
590            .capture::<StubEvent>(
591                Topic::from("exec.event.OrderFilled"),
592                &StubEvent {
593                    client_order_id: "O-counter-1".to_string(),
594                    venue_order_id: "V-counter-1".to_string(),
595                },
596                Headers::empty(),
597                UnixNanos::from(102),
598            )
599            .expect("second capture");
600
601        assert_eq!(submit_counter.load(Ordering::Acquire), 3);
602    }
603
604    #[rstest]
605    fn capture_records_event_indices_atomically(
606        captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
607    ) {
608        let (halt, _captured) = captured_halt;
609        let (writer, backend) = writer_with_open_run("run-event", Arc::clone(&halt));
610        let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt);
611
612        let event = StubEvent {
613            client_order_id: "O-2".to_string(),
614            venue_order_id: "V-9".to_string(),
615        };
616        adapter
617            .capture::<StubEvent>(
618                Topic::from("exec.event.OrderFilled"),
619                &event,
620                Headers::empty(),
621                UnixNanos::from(200),
622            )
623            .expect("capture");
624        drain(&writer, 1);
625
626        let backend = backend.lock().expect("backend");
627        let by_client = backend
628            .lookup(IndexKind::ClientOrderId, "O-2")
629            .expect("lookup")
630            .expect("indexed");
631        let by_venue = backend
632            .lookup(IndexKind::VenueOrderId, "V-9")
633            .expect("lookup")
634            .expect("indexed");
635        assert_eq!(by_client, 1);
636        assert_eq!(by_venue, 1);
637    }
638
639    #[rstest]
640    fn capture_propagates_encoder_error_without_halting(
641        captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
642    ) {
643        // An encoder failure is the encoder's contract violation, not a writer fail-stop:
644        // the caller should see CaptureError::Encode but the adapter must stay live so a
645        // subsequent capture for an allow-listed type still goes through.
646        let (halt, captured) = captured_halt;
647        let (writer, backend) = writer_with_open_run("run-encode-err", Arc::clone(&halt));
648        let adapter = BusCaptureAdapter::new(Arc::clone(&writer), stub_registry(), halt);
649
650        let err = adapter
651            .capture::<FailingMessage>(
652                Topic::from("exec.command.Failing"),
653                &FailingMessage,
654                Headers::empty(),
655                UnixNanos::from(500),
656            )
657            .expect_err("encoder must reject");
658
659        match err {
660            CaptureError::Encode(EncodeError::Serialize(msg)) => {
661                assert!(msg.contains("rejected"), "msg was: {msg}");
662            }
663            other => panic!("expected Encode(Serialize), was {other:?}"),
664        }
665        assert!(
666            !adapter.is_halted(),
667            "encoder failure must not fail-stop the adapter",
668        );
669        assert!(captured.lock().expect("captured").is_empty());
670
671        // Subsequent capture for a registered type still works.
672        adapter
673            .capture::<StubCommand>(
674                Topic::from("exec.command.SubmitOrder"),
675                &StubCommand {
676                    client_order_id: "O-after-encode-err".to_string(),
677                },
678                Headers::empty(),
679                UnixNanos::from(501),
680            )
681            .expect("capture after encoder error");
682        drain(&writer, 1);
683        let backend = backend.lock().expect("backend");
684        assert_eq!(backend.high_watermark().expect("hwm"), 1);
685    }
686
687    #[rstest]
688    #[case::backpressure(
689        SubmitError::HaltSignaled {
690            stalled_for: Duration::from_millis(750),
691            threshold: Duration::from_millis(250),
692        },
693        HaltReason::BackpressureStall {
694            stalled_for: Duration::from_millis(750),
695            threshold: Duration::from_millis(250),
696        },
697    )]
698    #[case::closed(
699        SubmitError::Closed,
700        HaltReason::BackendError("event store writer closed".to_string()),
701    )]
702    fn halt_reason_from_submit_preserves_failure_context(
703        #[case] err: SubmitError,
704        #[case] expected: HaltReason,
705    ) {
706        let actual = halt_reason_from_submit(&err);
707
708        match (actual, expected) {
709            (
710                HaltReason::BackpressureStall {
711                    stalled_for: a_s,
712                    threshold: a_t,
713                },
714                HaltReason::BackpressureStall {
715                    stalled_for: e_s,
716                    threshold: e_t,
717                },
718            ) => {
719                assert_eq!(a_s, e_s);
720                assert_eq!(a_t, e_t);
721            }
722            (HaltReason::BackendError(a), HaltReason::BackendError(e)) => {
723                assert_eq!(a, e);
724            }
725            (actual, expected) => {
726                panic!("variant mismatch: actual={actual:?} expected={expected:?}")
727            }
728        }
729    }
730
731    #[rstest]
732    fn submit_failure_halts_adapter_and_fires_callback_once(
733        captured_halt: (HaltCallback, Arc<Mutex<Vec<HaltReason>>>),
734    ) {
735        // A halted writer surfaces SubmitError::Closed; the adapter must mirror that
736        // into a single halt-callback firing and then short-circuit subsequent captures
737        // without forwarding further submits.
738        let (halt, captured) = captured_halt;
739        let (writer, _backend) = writer_with_open_run("run-halt", Arc::clone(&halt));
740
741        // Close the writer behind the adapter's back so the next submit returns Closed.
742        let writer_clone = Arc::clone(&writer);
743        // Build the adapter before the close so it owns a strong ref the close path
744        // doesn't see.
745        let adapter = BusCaptureAdapter::new(writer_clone, stub_registry(), halt);
746
747        // Drop one of the outer Arc clones, then force a graceful close on the writer
748        // by unwrapping. We can't unwrap because the adapter holds a clone, so emulate a
749        // closed writer with a separate test that simulates the failure path through
750        // a stub. We use a stub writer adapter instead to keep this test deterministic.
751        drop(writer);
752
753        // Build a fresh adapter wired to a stub that always returns SubmitError::Closed
754        // so we exercise the halt path without depending on writer-internal lifecycle.
755        let halt_for_stub: HaltCallback = adapter_halt_for(&captured);
756        let stub_adapter = StubFailAdapter::new(halt_for_stub);
757
758        let err = stub_adapter
759            .capture::<StubCommand>(
760                Topic::from("exec.command.SubmitOrder"),
761                &StubCommand {
762                    client_order_id: "O-fail".to_string(),
763                },
764                Headers::empty(),
765                UnixNanos::from(1),
766            )
767            .expect_err("first submit fails");
768        assert!(matches!(err, CaptureError::Submit(SubmitError::Closed)));
769        assert!(stub_adapter.is_halted());
770        assert_eq!(captured.lock().expect("captured").len(), 1);
771
772        let err2 = stub_adapter
773            .capture::<StubCommand>(
774                Topic::from("exec.command.SubmitOrder"),
775                &StubCommand {
776                    client_order_id: "O-fail-2".to_string(),
777                },
778                Headers::empty(),
779                UnixNanos::from(2),
780            )
781            .expect_err("second submit short-circuits");
782        assert!(matches!(err2, CaptureError::Halted));
783        assert_eq!(
784            captured.lock().expect("captured").len(),
785            1,
786            "halt callback must not refire after the first failure",
787        );
788
789        // Drop the adapter so its writer Arc is released.
790        drop(adapter);
791    }
792
793    fn adapter_halt_for(captured: &Arc<Mutex<Vec<HaltReason>>>) -> HaltCallback {
794        let captured_for_cb = Arc::clone(captured);
795        Arc::new(move |reason| {
796            captured_for_cb
797                .lock()
798                .expect("captured halt poisoned")
799                .push(reason);
800        })
801    }
802
803    /// Stand-in for [`BusCaptureAdapter`] that mirrors its halt-state machine but
804    /// always sees [`SubmitError::Closed`] from a synthetic writer. Lets the halt-path
805    /// test stay deterministic without racing against a real writer's shutdown sequence.
806    struct StubFailAdapter {
807        registry: Arc<EncoderRegistry>,
808        halt: HaltCallback,
809        halted: AtomicBool,
810    }
811
812    impl StubFailAdapter {
813        fn new(halt: HaltCallback) -> Self {
814            Self {
815                registry: stub_registry(),
816                halt,
817                halted: AtomicBool::new(false),
818            }
819        }
820
821        fn is_halted(&self) -> bool {
822            self.halted.load(Ordering::Acquire)
823        }
824
825        fn capture<T: 'static>(
826            &self,
827            _topic: Topic,
828            message: &T,
829            _headers: Headers,
830            _ts_init: UnixNanos,
831        ) -> Result<bool, CaptureError> {
832            if self.halted.load(Ordering::Acquire) {
833                return Err(CaptureError::Halted);
834            }
835            let Some((_pt, _encoded)) = self.registry.encode(message)? else {
836                return Ok(false);
837            };
838            let err = SubmitError::Closed;
839
840            if self
841                .halted
842                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
843                .is_ok()
844            {
845                (self.halt)(super::halt_reason_from_submit(&err));
846            }
847            Err(CaptureError::Submit(err))
848        }
849    }
850}