1use std::{
19 sync::mpsc::{self, Receiver, Sender, SyncSender},
20 thread::{self, JoinHandle},
21};
22
23use ahash::AHashMap;
24use nautilus_core::{Params, UUID4, UnixNanos};
25use nautilus_model::{
26 data::{DataBatch, NautilusDataType},
27 instruments::InstrumentAny,
28};
29
30use super::{
31 traits::DataCatalog,
32 types::{CatalogInstrumentQuery, CatalogQuery},
33};
34use crate::{catalog::session::DataBatchQueryResult, common::coverage::CoverageIntervals};
35
36type Reply<T> = Sender<T>;
37type MissingIntervalsByIdentifier = AHashMap<String, Vec<(u64, u64)>>;
38type CoverageIntervalsByIdentifier = AHashMap<String, CoverageIntervals>;
39
40const COMMAND_QUEUE_CAPACITY: usize = 10;
46
47const MAX_OPEN_SESSIONS: usize = 64;
52
53#[derive(Debug)]
54pub struct CatalogWriteJob {
55 pub data: DataBatch,
56 pub start: Option<UnixNanos>,
57 pub end: Option<UnixNanos>,
58 pub params: Option<Params>,
59}
60
61pub enum CatalogCommand {
62 QueryLastTimestamp {
63 data_type: NautilusDataType,
64 identifier: Option<String>,
65 reply: Reply<anyhow::Result<Option<u64>>>,
66 },
67 GetMissingIntervals {
68 start: UnixNanos,
69 end: UnixNanos,
70 data_type: NautilusDataType,
71 identifier: Option<String>,
72 reply: Reply<anyhow::Result<Vec<(u64, u64)>>>,
73 },
74 GetMissingIntervalsForIdentifiers {
75 start: UnixNanos,
76 end: UnixNanos,
77 data_type: NautilusDataType,
78 identifiers: Vec<String>,
79 reply: Reply<anyhow::Result<MissingIntervalsByIdentifier>>,
80 },
81 GetCoverageIntervalsForIdentifiers {
82 start: UnixNanos,
83 end: UnixNanos,
84 data_type: NautilusDataType,
85 identifiers: Vec<String>,
86 reply: Reply<anyhow::Result<CoverageIntervalsByIdentifier>>,
87 },
88 QueryBatch {
89 query: CatalogQuery,
90 reply: Reply<anyhow::Result<DataBatch>>,
91 },
92 QueryBatchAsync {
93 query: CatalogQuery,
94 on_complete: Box<dyn FnOnce(anyhow::Result<DataBatch>) + Send>,
95 },
96 OpenSession {
97 query: CatalogQuery,
98 chunk_size: Option<usize>,
99 reply: Reply<anyhow::Result<UUID4>>,
100 },
101 PullSession {
102 session_id: UUID4,
103 reply: Reply<anyhow::Result<Option<DataBatch>>>,
104 },
105 PullSessionAsync {
106 session_id: UUID4,
107 on_complete: Box<dyn FnOnce(anyhow::Result<Option<DataBatch>>) + Send>,
108 },
109 CloseSession {
110 session_id: UUID4,
111 reply: Reply<anyhow::Result<bool>>,
112 },
113 QueryInstruments {
114 query: CatalogInstrumentQuery,
115 reply: Reply<anyhow::Result<Vec<InstrumentAny>>>,
116 },
117 Write {
118 job: CatalogWriteJob,
119 reply: Reply<anyhow::Result<()>>,
120 },
121 WriteAsync {
122 job: CatalogWriteJob,
123 },
124 WriteInstruments {
125 instruments: Vec<InstrumentAny>,
126 reply: Reply<anyhow::Result<()>>,
127 },
128 WriteInstrumentsAsync {
129 instruments: Vec<InstrumentAny>,
130 },
131 Flush {
132 reply: Reply<anyhow::Result<()>>,
133 },
134 Shutdown,
135}
136
137#[derive(Debug)]
143pub struct CatalogWorker {
144 sender: SyncSender<CatalogCommand>,
145 handle: Option<JoinHandle<()>>,
146}
147
148#[expect(
149 clippy::missing_errors_doc,
150 reason = "Worker methods forward channel and catalog errors directly"
151)]
152impl CatalogWorker {
153 #[must_use]
154 pub fn start(mut catalog: DataCatalog) -> Self {
155 let (sender, receiver) = mpsc::sync_channel(COMMAND_QUEUE_CAPACITY);
156 let handle = thread::spawn(move || run_catalog_worker(&mut catalog, receiver));
157
158 Self {
159 sender,
160 handle: Some(handle),
161 }
162 }
163
164 fn send(&self, command: CatalogCommand) -> anyhow::Result<()> {
165 self.sender.send(command).map_err(|_| {
166 anyhow::anyhow!("Catalog worker thread has stopped, so the command cannot be sent")
167 })
168 }
169
170 fn send_with_reply<T>(
171 &self,
172 command: impl FnOnce(Reply<anyhow::Result<T>>) -> CatalogCommand,
173 ) -> anyhow::Result<T> {
174 let (reply, receiver) = mpsc::channel();
175 self.send(command(reply))?;
176 receiver.recv().map_err(|_| {
177 anyhow::anyhow!("Catalog worker thread stopped before replying to the command")
178 })?
179 }
180
181 pub fn query_last_timestamp(
182 &self,
183 data_type: NautilusDataType,
184 identifier: Option<String>,
185 ) -> anyhow::Result<Option<u64>> {
186 self.send_with_reply(|reply| CatalogCommand::QueryLastTimestamp {
187 data_type,
188 identifier,
189 reply,
190 })
191 }
192
193 pub fn get_missing_intervals(
194 &self,
195 start: UnixNanos,
196 end: UnixNanos,
197 data_type: NautilusDataType,
198 identifier: Option<String>,
199 ) -> anyhow::Result<Vec<(u64, u64)>> {
200 self.send_with_reply(|reply| CatalogCommand::GetMissingIntervals {
201 start,
202 end,
203 data_type,
204 identifier,
205 reply,
206 })
207 }
208
209 pub fn get_missing_intervals_for_identifiers(
210 &self,
211 start: UnixNanos,
212 end: UnixNanos,
213 data_type: NautilusDataType,
214 identifiers: Vec<String>,
215 ) -> anyhow::Result<MissingIntervalsByIdentifier> {
216 self.send_with_reply(|reply| CatalogCommand::GetMissingIntervalsForIdentifiers {
217 start,
218 end,
219 data_type,
220 identifiers,
221 reply,
222 })
223 }
224
225 pub fn get_coverage_intervals_for_identifiers(
226 &self,
227 start: UnixNanos,
228 end: UnixNanos,
229 data_type: NautilusDataType,
230 identifiers: Vec<String>,
231 ) -> anyhow::Result<CoverageIntervalsByIdentifier> {
232 self.send_with_reply(|reply| CatalogCommand::GetCoverageIntervalsForIdentifiers {
233 start,
234 end,
235 data_type,
236 identifiers,
237 reply,
238 })
239 }
240
241 pub fn query_batch(&self, query: CatalogQuery) -> anyhow::Result<DataBatch> {
242 self.send_with_reply(|reply| CatalogCommand::QueryBatch { query, reply })
243 }
244
245 pub fn query_batch_async(
248 &self,
249 query: CatalogQuery,
250 on_complete: Box<dyn FnOnce(anyhow::Result<DataBatch>) + Send>,
251 ) -> anyhow::Result<()> {
252 self.send(CatalogCommand::QueryBatchAsync { query, on_complete })
253 }
254
255 pub fn open_session(
256 &self,
257 query: CatalogQuery,
258 chunk_size: Option<usize>,
259 ) -> anyhow::Result<UUID4> {
260 self.send_with_reply(|reply| CatalogCommand::OpenSession {
261 query,
262 chunk_size,
263 reply,
264 })
265 }
266
267 pub fn pull_session(&self, session_id: UUID4) -> anyhow::Result<Option<DataBatch>> {
268 self.send_with_reply(|reply| CatalogCommand::PullSession { session_id, reply })
269 }
270
271 pub fn pull_session_async(
273 &self,
274 session_id: UUID4,
275 on_complete: Box<dyn FnOnce(anyhow::Result<Option<DataBatch>>) + Send>,
276 ) -> anyhow::Result<()> {
277 self.send(CatalogCommand::PullSessionAsync {
278 session_id,
279 on_complete,
280 })
281 }
282
283 pub fn close_session(&self, session_id: UUID4) -> anyhow::Result<bool> {
285 self.send_with_reply(|reply| CatalogCommand::CloseSession { session_id, reply })
286 }
287
288 pub fn query_instruments(
289 &self,
290 query: CatalogInstrumentQuery,
291 ) -> anyhow::Result<Vec<InstrumentAny>> {
292 self.send_with_reply(|reply| CatalogCommand::QueryInstruments { query, reply })
293 }
294
295 pub fn write(&self, job: CatalogWriteJob) -> anyhow::Result<()> {
296 self.send_with_reply(|reply| CatalogCommand::Write { job, reply })
297 }
298
299 pub fn write_async(&self, job: CatalogWriteJob) -> anyhow::Result<()> {
300 self.send(CatalogCommand::WriteAsync { job })
301 }
302
303 pub fn write_instruments(&self, instruments: Vec<InstrumentAny>) -> anyhow::Result<()> {
304 self.send_with_reply(|reply| CatalogCommand::WriteInstruments { instruments, reply })
305 }
306
307 pub fn write_instruments_async(&self, instruments: Vec<InstrumentAny>) -> anyhow::Result<()> {
308 self.send(CatalogCommand::WriteInstrumentsAsync { instruments })
309 }
310
311 pub fn flush(&self) -> anyhow::Result<()> {
312 self.send_with_reply(|reply| CatalogCommand::Flush { reply })
313 }
314}
315
316impl Drop for CatalogWorker {
317 fn drop(&mut self) {
318 let _ = self.sender.send(CatalogCommand::Shutdown);
319
320 if let Some(handle) = self.handle.take() {
321 let _ = handle.join();
322 }
323 }
324}
325
326#[expect(
327 clippy::needless_pass_by_value,
328 clippy::too_many_lines,
329 reason = "The worker thread owns the receiver for its whole lifetime"
330)]
331fn run_catalog_worker(catalog: &mut DataCatalog, receiver: Receiver<CatalogCommand>) {
332 let mut async_errors: Vec<anyhow::Error> = Vec::new();
333 let mut sessions: AHashMap<UUID4, DataBatchQueryResult> = AHashMap::new();
334
335 while let Ok(command) = receiver.recv() {
336 match command {
337 CatalogCommand::QueryLastTimestamp {
338 data_type,
339 identifier,
340 reply,
341 } => {
342 let result = catalog.query_last_timestamp(data_type, identifier.as_deref());
343 let _ = reply.send(result);
344 }
345 CatalogCommand::GetMissingIntervals {
346 start,
347 end,
348 data_type,
349 identifier,
350 reply,
351 } => {
352 let result = catalog.get_missing_intervals_for_request(
353 start,
354 end,
355 data_type,
356 identifier.as_deref(),
357 );
358 let _ = reply.send(result);
359 }
360 CatalogCommand::GetMissingIntervalsForIdentifiers {
361 start,
362 end,
363 data_type,
364 identifiers,
365 reply,
366 } => {
367 let result = catalog.get_missing_intervals_for_identifiers(
368 start,
369 end,
370 data_type,
371 &identifiers,
372 );
373 let _ = reply.send(result);
374 }
375 CatalogCommand::GetCoverageIntervalsForIdentifiers {
376 start,
377 end,
378 data_type,
379 identifiers,
380 reply,
381 } => {
382 let result = catalog.get_coverage_intervals_for_identifiers(
383 start,
384 end,
385 data_type,
386 &identifiers,
387 );
388 let _ = reply.send(result);
389 }
390 CatalogCommand::QueryBatch { query, reply } => {
391 let result = catalog.query_batch(&query);
392 let _ = reply.send(result);
393 }
394 CatalogCommand::QueryBatchAsync { query, on_complete } => {
395 let result = catalog.query_batch(&query);
396 on_complete(result);
397 }
398 CatalogCommand::OpenSession {
399 query,
400 chunk_size,
401 reply,
402 } => {
403 let result = if sessions.len() >= MAX_OPEN_SESSIONS {
404 Err(anyhow::anyhow!(
405 "Catalog session limit of {MAX_OPEN_SESSIONS} reached; close finished sessions before opening more",
406 ))
407 } else {
408 open_query_session(catalog, &query, chunk_size).map(|session| {
409 let session_id = UUID4::new();
410 sessions.insert(session_id, session);
411 session_id
412 })
413 };
414
415 let _ = reply.send(result);
416 }
417 CatalogCommand::PullSession { session_id, reply } => {
418 let result = pull_session(&mut sessions, session_id);
419 let _ = reply.send(result);
420 }
421 CatalogCommand::PullSessionAsync {
422 session_id,
423 on_complete,
424 } => {
425 on_complete(pull_session(&mut sessions, session_id));
426 }
427 CatalogCommand::CloseSession { session_id, reply } => {
428 let _ = reply.send(Ok(sessions.remove(&session_id).is_some()));
429 }
430 CatalogCommand::QueryInstruments { query, reply } => {
431 let result = catalog.instruments(&query);
432 let _ = reply.send(result);
433 }
434 CatalogCommand::Write { job, reply } => {
435 let result = catalog.write_data_batch(&job.data, job.start, job.end, job.params);
436 let _ = reply.send(result);
437 }
438 CatalogCommand::WriteAsync { job } => {
439 let len = job.data.len();
440 match catalog.write_data_batch(&job.data, job.start, job.end, job.params) {
441 Ok(()) => log::info!("Catalog worker wrote {len} data rows"),
442 Err(e) => {
443 log::error!("Catalog worker failed to write {len} data rows: {e}");
444 async_errors.push(e);
445 }
446 }
447 }
448 CatalogCommand::WriteInstruments { instruments, reply } => {
449 let result = catalog.write_instruments(&instruments);
450 let _ = reply.send(result);
451 }
452 CatalogCommand::WriteInstrumentsAsync { instruments } => {
453 let len = instruments.len();
454 match catalog.write_instruments(&instruments) {
455 Ok(()) => log::info!("Catalog worker wrote {len} instruments"),
456 Err(e) => {
457 log::error!("Catalog worker failed to write {len} instruments: {e}");
458 async_errors.push(e);
459 }
460 }
461 }
462 CatalogCommand::Flush { reply } => {
463 let _ = reply.send(drain_async_errors(&mut async_errors));
464 }
465 CatalogCommand::Shutdown => break,
466 }
467 }
468}
469
470fn open_query_session(
471 catalog: &mut DataCatalog,
472 query: &CatalogQuery,
473 chunk_size: Option<usize>,
474) -> anyhow::Result<DataBatchQueryResult> {
475 if let Some(mut query_catalog) = catalog.fork_query_catalog()? {
476 query_catalog.reset_session();
477 return query_catalog.query_batch_session(query, chunk_size);
478 }
479
480 catalog.reset_session();
481 catalog.query_batch_session(query, chunk_size)
482}
483
484fn drain_async_errors(errors: &mut Vec<anyhow::Error>) -> anyhow::Result<()> {
485 let failures = errors.len();
486 let details = errors
487 .iter()
488 .map(|e| format!("{e:#}"))
489 .collect::<Vec<_>>()
490 .join("; ");
491
492 let Some(first) = errors.drain(..).next() else {
493 return Ok(());
494 };
495
496 if failures == 1 {
497 return Err(first);
498 }
499
500 Err(first.context(format!(
501 "{failures} asynchronous catalog writes failed: {details}"
502 )))
503}
504
505fn pull_session(
506 sessions: &mut AHashMap<UUID4, DataBatchQueryResult>,
507 session_id: UUID4,
508) -> anyhow::Result<Option<DataBatch>> {
509 let result = sessions
510 .get_mut(&session_id)
511 .ok_or_else(|| anyhow::anyhow!("Catalog session {session_id} is not open"))?
512 .next_batch();
513
514 if !matches!(result, Ok(Some(_))) {
515 sessions.remove(&session_id);
516 }
517
518 result
519}
520
521#[cfg(test)]
522mod tests {
523 use std::{
524 sync::{Arc, Mutex},
525 time::{Duration, Instant},
526 };
527
528 use nautilus_core::ClosedInterval;
529 use nautilus_model::{
530 data::{Data, NautilusRecordType, QuoteTick},
531 identifiers::InstrumentId,
532 instruments::stubs::audusd_sim,
533 types::{Price, Quantity},
534 };
535 use rstest::rstest;
536
537 use super::*;
538 use crate::catalog::{
539 session::{DataBatchQuery, TypedDataBatchSession},
540 traits::{CatalogReader, CatalogWriter, RecordBatch},
541 };
542
543 struct FailingSession;
544
545 impl DataBatchQuery for FailingSession {
546 fn next_batch(&mut self) -> anyhow::Result<Option<DataBatch>> {
547 anyhow::bail!("session decode failure")
548 }
549 }
550
551 fn stub_quote() -> QuoteTick {
552 QuoteTick::new(
553 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
554 Price::from("1987.0"),
555 Price::from("1988.0"),
556 Quantity::from("100"),
557 Quantity::from("100"),
558 UnixNanos::from(1),
559 UnixNanos::from(1),
560 )
561 }
562
563 #[derive(Debug)]
564 struct StubCatalog {
565 fail: bool,
566 query_thread: Arc<Mutex<Option<thread::ThreadId>>>,
567 }
568
569 impl CatalogReader for StubCatalog {
570 fn reset_session(&mut self) {}
571
572 fn instruments(
573 &mut self,
574 _query: &CatalogInstrumentQuery,
575 ) -> anyhow::Result<Vec<InstrumentAny>> {
576 Ok(Vec::new())
577 }
578
579 fn query_batch(&mut self, _query: &CatalogQuery) -> anyhow::Result<DataBatch> {
580 *self.query_thread.lock().unwrap() = Some(thread::current().id());
581
582 if self.fail {
583 anyhow::bail!("stub query failure");
584 }
585
586 Ok(DataBatch::Quote(vec![stub_quote()].into()))
587 }
588
589 fn query_batch_session(
590 &mut self,
591 _query: &CatalogQuery,
592 chunk_size: Option<usize>,
593 ) -> anyhow::Result<DataBatchQueryResult> {
594 *self.query_thread.lock().unwrap() = Some(thread::current().id());
595
596 if self.fail {
597 anyhow::bail!("stub query failure");
598 }
599
600 Ok(Box::new(TypedDataBatchSession::from_vec(
601 vec![stub_quote()],
602 chunk_size,
603 )))
604 }
605 }
606
607 impl CatalogWriter for StubCatalog {
608 fn write_instruments(&mut self, _instruments: &[InstrumentAny]) -> anyhow::Result<()> {
609 if self.fail {
610 anyhow::bail!("stub instrument write failure");
611 }
612
613 Ok(())
614 }
615
616 fn write_data(
617 &mut self,
618 _data: &[Data],
619 _start: Option<UnixNanos>,
620 _end: Option<UnixNanos>,
621 params: Option<Params>,
622 ) -> anyhow::Result<()> {
623 if self.fail {
624 let message = params
625 .as_ref()
626 .and_then(|params| params.get_str("test_error"))
627 .unwrap_or("stub write failure");
628 anyhow::bail!(message.to_string());
629 }
630
631 Ok(())
632 }
633
634 fn write_records(
635 &mut self,
636 _record_type: NautilusRecordType,
637 _batches: &[RecordBatch],
638 _params: Option<Params>,
639 ) -> anyhow::Result<()> {
640 Ok(())
641 }
642
643 fn record_empty_coverage(
644 &mut self,
645 _data_type: NautilusDataType,
646 _identifier: Option<&str>,
647 _start: UnixNanos,
648 _end: UnixNanos,
649 ) -> anyhow::Result<()> {
650 Ok(())
651 }
652 }
653
654 fn stub_query() -> CatalogQuery {
655 CatalogQuery::new(NautilusDataType::QuoteTick)
656 }
657
658 #[rstest]
659 fn test_query_batch_async_invokes_callback_on_worker_thread() {
660 let query_thread = Arc::new(Mutex::new(None));
661
662 let worker = CatalogWorker::start(Box::new(StubCatalog {
663 fail: false,
664 query_thread: query_thread.clone(),
665 }));
666
667 let (tx, rx) = mpsc::channel();
668 worker
669 .query_batch_async(
670 stub_query(),
671 Box::new(move |result| {
672 let _ = tx.send((thread::current().id(), result.map(|batch| batch.len())));
673 }),
674 )
675 .unwrap();
676
677 let (callback_thread, result) = rx
678 .recv_timeout(Duration::from_secs(5))
679 .expect("callback was not invoked");
680 assert_eq!(result.unwrap(), 1);
681 assert_ne!(callback_thread, thread::current().id());
682 assert_eq!(Some(callback_thread), *query_thread.lock().unwrap());
683 }
684
685 #[rstest]
686 fn test_query_batch_async_passes_query_error_to_callback() {
687 let worker = CatalogWorker::start(Box::new(StubCatalog {
688 fail: true,
689 query_thread: Arc::new(Mutex::new(None)),
690 }));
691
692 let (tx, rx) = mpsc::channel();
693 worker
694 .query_batch_async(
695 stub_query(),
696 Box::new(move |result| {
697 let _ = tx.send(result.map(|batch| batch.len()));
698 }),
699 )
700 .unwrap();
701
702 let result = rx
703 .recv_timeout(Duration::from_secs(5))
704 .expect("callback was not invoked");
705 assert_eq!(result.unwrap_err().to_string(), "stub query failure");
706 }
707
708 #[rstest]
709 fn test_worker_owns_and_pulls_catalog_session() {
710 let query_thread = Arc::new(Mutex::new(None));
711
712 let worker = CatalogWorker::start(Box::new(StubCatalog {
713 fail: false,
714 query_thread: query_thread.clone(),
715 }));
716
717 let session_id = worker.open_session(stub_query(), Some(1)).unwrap();
718 let batch = worker.pull_session(session_id).unwrap().unwrap();
719 let complete = worker.pull_session(session_id).unwrap();
720 let closed = worker.pull_session(session_id).unwrap_err();
721
722 assert_eq!(batch.len(), 1);
723 assert!(complete.is_none());
724 assert_eq!(
725 closed.to_string(),
726 format!("Catalog session {session_id} is not open")
727 );
728 assert_ne!(*query_thread.lock().unwrap(), Some(thread::current().id()));
729 }
730
731 #[rstest]
732 fn test_close_session_drops_open_session() {
733 let worker = CatalogWorker::start(Box::new(StubCatalog {
734 fail: false,
735 query_thread: Arc::new(Mutex::new(None)),
736 }));
737
738 let session_id = worker.open_session(stub_query(), Some(1)).unwrap();
739
740 let first_close = worker.close_session(session_id).unwrap();
741 let second_close = worker.close_session(session_id).unwrap();
742
743 assert!(first_close);
744 assert!(!second_close);
745 }
746
747 #[rstest]
748 fn errored_session_is_removed_after_pull() {
749 let session_id = UUID4::new();
750 let mut sessions = AHashMap::new();
751 sessions.insert(session_id, Box::new(FailingSession) as DataBatchQueryResult);
752
753 let first = pull_session(&mut sessions, session_id).unwrap_err();
754 let second = pull_session(&mut sessions, session_id).unwrap_err();
755
756 assert_eq!(first.to_string(), "session decode failure");
757 assert_eq!(
758 second.to_string(),
759 format!("Catalog session {session_id} is not open"),
760 );
761 assert!(sessions.is_empty());
762 }
763
764 #[rstest]
765 fn test_flush_reports_every_failed_async_write_then_clears() {
766 let worker = CatalogWorker::start(Box::new(StubCatalog {
767 fail: true,
768 query_thread: Arc::new(Mutex::new(None)),
769 }));
770
771 for message in [
772 "first write failed",
773 "second write failed",
774 "third write failed",
775 ] {
776 let params = Params::new([("test_error".to_string(), message.into())].into());
777 worker
778 .write_async(CatalogWriteJob {
779 data: DataBatch::Quote(vec![stub_quote()].into()),
780 start: None,
781 end: None,
782 params: Some(params),
783 })
784 .unwrap();
785 }
786
787 let error = worker.flush().unwrap_err();
788
789 assert_eq!(
790 error.to_string(),
791 "3 asynchronous catalog writes failed: first write failed; second write failed; \
792 third write failed",
793 );
794 assert_eq!(error.root_cause().to_string(), "first write failed");
795 assert!(worker.flush().is_ok(), "flush must clear reported failures");
796 }
797
798 #[rstest]
799 fn test_open_session_rejects_beyond_the_session_limit() {
800 let worker = CatalogWorker::start(Box::new(StubCatalog {
801 fail: false,
802 query_thread: Arc::new(Mutex::new(None)),
803 }));
804
805 let session_ids = (0..MAX_OPEN_SESSIONS)
806 .map(|_| worker.open_session(stub_query(), Some(1)).unwrap())
807 .collect::<Vec<_>>();
808
809 let error = worker.open_session(stub_query(), Some(1)).unwrap_err();
810 assert_eq!(
811 error.to_string(),
812 format!(
813 "Catalog session limit of {MAX_OPEN_SESSIONS} reached; close finished sessions before opening more"
814 ),
815 );
816
817 assert!(worker.close_session(session_ids[0]).unwrap());
818 assert!(
819 worker.open_session(stub_query(), Some(1)).is_ok(),
820 "closing a session must free a slot",
821 );
822 }
823
824 #[rstest]
825 fn test_pull_session_async_invokes_callback_on_worker_thread() {
826 let worker = CatalogWorker::start(Box::new(StubCatalog {
827 fail: false,
828 query_thread: Arc::new(Mutex::new(None)),
829 }));
830
831 let session_id = worker.open_session(stub_query(), Some(1)).unwrap();
832 let (tx, rx) = mpsc::channel();
833
834 worker
835 .pull_session_async(
836 session_id,
837 Box::new(move |result| {
838 let _ = tx.send((
839 thread::current().id(),
840 result.map(|batch| batch.unwrap().len()),
841 ));
842 }),
843 )
844 .unwrap();
845
846 let (callback_thread, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
847
848 assert_eq!(result.unwrap(), 1);
849 assert_ne!(callback_thread, thread::current().id());
850 }
851
852 #[rstest]
853 fn test_flush_returns_single_async_failure_without_context() {
854 let worker = CatalogWorker::start(Box::new(StubCatalog {
855 fail: true,
856 query_thread: Arc::new(Mutex::new(None)),
857 }));
858
859 worker
860 .write_instruments_async(vec![InstrumentAny::CurrencyPair(audusd_sim())])
861 .unwrap();
862 let error = worker.flush().unwrap_err();
863
864 assert_eq!(error.to_string(), "stub instrument write failure");
865 assert_eq!(error.chain().count(), 1);
866 }
867
868 #[derive(Debug, Default)]
869 struct RecordingCatalog {
870 label: &'static str,
871 calls: Arc<Mutex<Vec<String>>>,
872 write_gate: Option<Receiver<()>>,
873 fork: bool,
874 panic_on_query: bool,
875 }
876
877 impl RecordingCatalog {
878 fn record(&self, call: &str) {
879 self.calls
880 .lock()
881 .unwrap()
882 .push(format!("{}{call}", self.label));
883 }
884 }
885
886 impl CatalogReader for RecordingCatalog {
887 fn fork_query_catalog(&self) -> anyhow::Result<Option<DataCatalog>> {
888 if !self.fork {
889 return Ok(None);
890 }
891
892 Ok(Some(Box::new(Self {
893 label: "fork ",
894 calls: self.calls.clone(),
895 ..Self::default()
896 })))
897 }
898
899 fn reset_session(&mut self) {
900 self.record("reset_session");
901 }
902
903 fn instruments(
904 &mut self,
905 query: &CatalogInstrumentQuery,
906 ) -> anyhow::Result<Vec<InstrumentAny>> {
907 self.record(&format!("instruments {:?}", query.instrument_ids));
908 Ok(vec![InstrumentAny::CurrencyPair(audusd_sim())])
909 }
910
911 #[expect(
912 clippy::panic_in_result_fn,
913 reason = "the stub simulates a catalog backend that panics mid-query"
914 )]
915 fn query_batch(&mut self, _query: &CatalogQuery) -> anyhow::Result<DataBatch> {
916 assert!(!self.panic_on_query, "stub query panic");
917
918 Ok(DataBatch::Quote(vec![stub_quote()].into()))
919 }
920
921 fn query_batch_session(
922 &mut self,
923 _query: &CatalogQuery,
924 chunk_size: Option<usize>,
925 ) -> anyhow::Result<DataBatchQueryResult> {
926 self.record(&format!("query_batch_session {chunk_size:?}"));
927 Ok(Box::new(TypedDataBatchSession::from_vec(
928 vec![stub_quote()],
929 chunk_size,
930 )))
931 }
932
933 fn get_missing_intervals_for_request(
934 &mut self,
935 start: UnixNanos,
936 end: UnixNanos,
937 _data_type: NautilusDataType,
938 identifier: Option<&str>,
939 ) -> anyhow::Result<Vec<(u64, u64)>> {
940 if identifier == Some("covered") {
941 return Ok(Vec::new());
942 }
943
944 Ok(vec![(start.as_u64(), end.as_u64())])
945 }
946
947 fn query_last_timestamp(
948 &mut self,
949 data_type: NautilusDataType,
950 identifier: Option<&str>,
951 ) -> anyhow::Result<Option<u64>> {
952 self.record(&format!("query_last_timestamp {data_type} {identifier:?}"));
953 Ok(Some(42))
954 }
955 }
956
957 impl CatalogWriter for RecordingCatalog {
958 fn write_instruments(&mut self, instruments: &[InstrumentAny]) -> anyhow::Result<()> {
959 self.record(&format!("write_instruments {}", instruments.len()));
960 Ok(())
961 }
962
963 fn write_data(
964 &mut self,
965 data: &[Data],
966 start: Option<UnixNanos>,
967 end: Option<UnixNanos>,
968 _params: Option<Params>,
969 ) -> anyhow::Result<()> {
970 if let Some(gate) = &self.write_gate {
971 gate.recv()?;
972 }
973
974 self.record(&format!(
975 "write_data {} {:?} {:?}",
976 data.len(),
977 start.map(|ts| ts.as_u64()),
978 end.map(|ts| ts.as_u64()),
979 ));
980 Ok(())
981 }
982
983 fn write_records(
984 &mut self,
985 _record_type: NautilusRecordType,
986 _batches: &[RecordBatch],
987 _params: Option<Params>,
988 ) -> anyhow::Result<()> {
989 Ok(())
990 }
991
992 fn record_empty_coverage(
993 &mut self,
994 _data_type: NautilusDataType,
995 _identifier: Option<&str>,
996 _start: UnixNanos,
997 _end: UnixNanos,
998 ) -> anyhow::Result<()> {
999 Ok(())
1000 }
1001 }
1002
1003 fn quote_write_job() -> CatalogWriteJob {
1004 CatalogWriteJob {
1005 data: DataBatch::Quote(vec![stub_quote()].into()),
1006 start: Some(UnixNanos::from(1)),
1007 end: Some(UnixNanos::from(2)),
1008 params: None,
1009 }
1010 }
1011
1012 #[rstest]
1013 fn test_worker_forwards_queries_and_writes_to_catalog() {
1014 let calls = Arc::new(Mutex::new(Vec::new()));
1015
1016 let worker = CatalogWorker::start(Box::new(RecordingCatalog {
1017 calls: calls.clone(),
1018 ..RecordingCatalog::default()
1019 }));
1020
1021 let identifiers = vec!["missing".to_string(), "covered".to_string()];
1022
1023 let last_timestamp = worker
1024 .query_last_timestamp(NautilusDataType::QuoteTick, Some("missing".to_string()))
1025 .unwrap();
1026 let missing = worker
1027 .get_missing_intervals(
1028 UnixNanos::from(3),
1029 UnixNanos::from(9),
1030 NautilusDataType::QuoteTick,
1031 Some("missing".to_string()),
1032 )
1033 .unwrap();
1034 let missing_by_identifier = worker
1035 .get_missing_intervals_for_identifiers(
1036 UnixNanos::from(1),
1037 UnixNanos::from(10),
1038 NautilusDataType::QuoteTick,
1039 identifiers.clone(),
1040 )
1041 .unwrap();
1042 let coverage_by_identifier = worker
1043 .get_coverage_intervals_for_identifiers(
1044 UnixNanos::from(1),
1045 UnixNanos::from(10),
1046 NautilusDataType::QuoteTick,
1047 identifiers,
1048 )
1049 .unwrap();
1050 let batch = worker.query_batch(stub_query()).unwrap();
1051 let instruments = worker
1052 .query_instruments(
1053 CatalogInstrumentQuery::new()
1054 .with_instrument_ids(Some(vec!["AUD/USD.SIM".to_string()])),
1055 )
1056 .unwrap();
1057 worker.write(quote_write_job()).unwrap();
1058 worker
1059 .write_instruments(vec![InstrumentAny::CurrencyPair(audusd_sim())])
1060 .unwrap();
1061 worker
1062 .write_instruments_async(vec![
1063 InstrumentAny::CurrencyPair(audusd_sim()),
1064 InstrumentAny::CurrencyPair(audusd_sim()),
1065 ])
1066 .unwrap();
1067 worker.flush().unwrap();
1068
1069 assert_eq!(last_timestamp, Some(42));
1070 assert_eq!(missing, vec![(3, 9)]);
1071 assert_eq!(
1072 missing_by_identifier,
1073 AHashMap::from_iter([
1074 ("missing".to_string(), vec![(1, 10)]),
1075 ("covered".to_string(), Vec::new()),
1076 ]),
1077 );
1078 assert_eq!(
1079 coverage_by_identifier,
1080 AHashMap::from_iter([
1081 ("missing".to_string(), CoverageIntervals::default()),
1082 (
1083 "covered".to_string(),
1084 CoverageIntervals {
1085 data: vec![ClosedInterval::new(1, 10).unwrap()],
1086 empty: Vec::new(),
1087 },
1088 ),
1089 ]),
1090 );
1091 assert_eq!(batch.len(), 1);
1092 assert_eq!(instruments, vec![InstrumentAny::CurrencyPair(audusd_sim())]);
1093 assert_eq!(
1094 *calls.lock().unwrap(),
1095 vec![
1096 "query_last_timestamp QuoteTick Some(\"missing\")",
1097 "instruments Some([\"AUD/USD.SIM\"])",
1098 "write_data 1 Some(1) Some(2)",
1099 "write_instruments 1",
1100 "write_instruments 2",
1101 ],
1102 );
1103 }
1104
1105 #[rstest]
1106 fn test_drop_waits_for_queued_async_writes() {
1107 let calls = Arc::new(Mutex::new(Vec::new()));
1108 let (release, gate) = mpsc::channel();
1109
1110 let worker = CatalogWorker::start(Box::new(RecordingCatalog {
1111 calls: calls.clone(),
1112 write_gate: Some(gate),
1113 ..RecordingCatalog::default()
1114 }));
1115
1116 worker.write_async(quote_write_job()).unwrap();
1117
1118 let releaser = thread::spawn(move || {
1119 thread::sleep(Duration::from_millis(50));
1120 release.send(()).unwrap();
1121 });
1122
1123 drop(worker);
1124
1125 assert_eq!(*calls.lock().unwrap(), vec!["write_data 1 Some(1) Some(2)"]);
1126 releaser.join().unwrap();
1127 }
1128
1129 #[rstest]
1130 #[case::shared_catalog(false, &["reset_session", "query_batch_session Some(1)"])]
1131 #[case::forked_catalog(true, &["fork reset_session", "fork query_batch_session Some(1)"])]
1132 fn test_open_session_queries_forked_catalog_when_available(
1133 #[case] fork: bool,
1134 #[case] expected_calls: &[&str],
1135 ) {
1136 let calls = Arc::new(Mutex::new(Vec::new()));
1137
1138 let worker = CatalogWorker::start(Box::new(RecordingCatalog {
1139 calls: calls.clone(),
1140 fork,
1141 ..RecordingCatalog::default()
1142 }));
1143
1144 let session_id = worker.open_session(stub_query(), Some(1)).unwrap();
1145 let batch = worker.pull_session(session_id).unwrap().unwrap();
1146
1147 assert_eq!(batch.len(), 1);
1148 assert_eq!(*calls.lock().unwrap(), expected_calls);
1149 }
1150
1151 #[rstest]
1152 fn test_worker_reports_stopped_thread_after_catalog_panic() {
1153 let worker = CatalogWorker::start(Box::new(RecordingCatalog {
1154 panic_on_query: true,
1155 ..RecordingCatalog::default()
1156 }));
1157
1158 let reply_error = worker.query_batch(stub_query()).unwrap_err();
1159 let handle = worker.handle.as_ref().unwrap();
1160 let deadline = Instant::now() + Duration::from_secs(5);
1161
1162 while !handle.is_finished() {
1163 assert!(Instant::now() < deadline, "worker thread did not stop");
1164 thread::sleep(Duration::from_millis(1));
1165 }
1166
1167 let send_error = worker.flush().unwrap_err();
1168
1169 assert_eq!(
1170 reply_error.to_string(),
1171 "Catalog worker thread stopped before replying to the command",
1172 );
1173 assert_eq!(
1174 send_error.to_string(),
1175 "Catalog worker thread has stopped, so the command cannot be sent",
1176 );
1177 }
1178
1179 #[rstest]
1180 #[case(false)]
1181 #[case(true)]
1182 fn session_close_and_shutdown_wait_for_inflight_backend(#[case] shutdown: bool) {
1183 let query_thread = Arc::new(Mutex::new(None));
1184
1185 let worker = CatalogWorker::start(Box::new(StubCatalog {
1186 fail: false,
1187 query_thread: Arc::clone(&query_thread),
1188 }));
1189
1190 let session = worker.open_session(stub_query(), Some(1)).unwrap();
1191 let backend_gate = query_thread.lock().unwrap();
1192 let (query_tx, query_rx) = mpsc::channel();
1193 worker
1194 .query_batch_async(
1195 stub_query(),
1196 Box::new(move |result| {
1197 query_tx.send(result.map(|batch| batch.len())).unwrap();
1198 }),
1199 )
1200 .unwrap();
1201
1202 let (started_tx, started_rx) = mpsc::channel();
1203 let (closed_tx, closed_rx) = mpsc::channel();
1204
1205 let control = thread::spawn(move || {
1208 started_tx.send(()).unwrap();
1209
1210 if shutdown {
1211 drop(worker);
1212 closed_tx.send(None).unwrap();
1213 } else {
1214 closed_tx
1215 .send(Some(worker.close_session(session).unwrap()))
1216 .unwrap();
1217 }
1218 });
1219
1220 started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1221 let blocked = closed_rx.recv_timeout(Duration::from_millis(25));
1222 drop(backend_gate);
1223 let rows = query_rx
1224 .recv_timeout(Duration::from_secs(5))
1225 .unwrap()
1226 .unwrap();
1227 let closed = closed_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1228 control.join().unwrap();
1229 assert_eq!(blocked, Err(mpsc::RecvTimeoutError::Timeout));
1230 assert_eq!(rows, 1);
1231 assert_eq!(closed, (!shutdown).then_some(true));
1232 }
1233}