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
47pub(crate) const 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)]
138pub struct CatalogWorker {
139 sender: SyncSender<CatalogCommand>,
140 handle: Option<JoinHandle<()>>,
141}
142
143#[expect(
144 clippy::missing_errors_doc,
145 reason = "Worker methods forward channel and catalog errors directly"
146)]
147impl CatalogWorker {
148 #[must_use]
149 pub fn start(mut catalog: DataCatalog) -> Self {
150 let (sender, receiver) = mpsc::sync_channel(COMMAND_QUEUE_CAPACITY);
151 let handle = thread::spawn(move || run_catalog_worker(&mut catalog, receiver));
152
153 Self {
154 sender,
155 handle: Some(handle),
156 }
157 }
158
159 fn send(&self, command: CatalogCommand) -> anyhow::Result<()> {
160 self.sender.send(command).map_err(|_| {
161 anyhow::anyhow!("Catalog worker thread has stopped, so the command cannot be sent")
162 })
163 }
164
165 fn send_with_reply<T>(
166 &self,
167 command: impl FnOnce(Reply<anyhow::Result<T>>) -> CatalogCommand,
168 ) -> anyhow::Result<T> {
169 let (reply, receiver) = mpsc::channel();
170 self.send(command(reply))?;
171 receiver.recv().map_err(|_| {
172 anyhow::anyhow!("Catalog worker thread stopped before replying to the command")
173 })?
174 }
175
176 pub fn query_last_timestamp(
177 &self,
178 data_type: NautilusDataType,
179 identifier: Option<String>,
180 ) -> anyhow::Result<Option<u64>> {
181 self.send_with_reply(|reply| CatalogCommand::QueryLastTimestamp {
182 data_type,
183 identifier,
184 reply,
185 })
186 }
187
188 pub fn get_missing_intervals(
189 &self,
190 start: UnixNanos,
191 end: UnixNanos,
192 data_type: NautilusDataType,
193 identifier: Option<String>,
194 ) -> anyhow::Result<Vec<(u64, u64)>> {
195 self.send_with_reply(|reply| CatalogCommand::GetMissingIntervals {
196 start,
197 end,
198 data_type,
199 identifier,
200 reply,
201 })
202 }
203
204 pub fn get_missing_intervals_for_identifiers(
205 &self,
206 start: UnixNanos,
207 end: UnixNanos,
208 data_type: NautilusDataType,
209 identifiers: Vec<String>,
210 ) -> anyhow::Result<MissingIntervalsByIdentifier> {
211 self.send_with_reply(|reply| CatalogCommand::GetMissingIntervalsForIdentifiers {
212 start,
213 end,
214 data_type,
215 identifiers,
216 reply,
217 })
218 }
219
220 pub fn get_coverage_intervals_for_identifiers(
221 &self,
222 start: UnixNanos,
223 end: UnixNanos,
224 data_type: NautilusDataType,
225 identifiers: Vec<String>,
226 ) -> anyhow::Result<CoverageIntervalsByIdentifier> {
227 self.send_with_reply(|reply| CatalogCommand::GetCoverageIntervalsForIdentifiers {
228 start,
229 end,
230 data_type,
231 identifiers,
232 reply,
233 })
234 }
235
236 pub fn query_batch(&self, query: CatalogQuery) -> anyhow::Result<DataBatch> {
237 self.send_with_reply(|reply| CatalogCommand::QueryBatch { query, reply })
238 }
239
240 pub fn query_batch_async(
243 &self,
244 query: CatalogQuery,
245 on_complete: Box<dyn FnOnce(anyhow::Result<DataBatch>) + Send>,
246 ) -> anyhow::Result<()> {
247 self.send(CatalogCommand::QueryBatchAsync { query, on_complete })
248 }
249
250 pub fn open_session(
251 &self,
252 query: CatalogQuery,
253 chunk_size: Option<usize>,
254 ) -> anyhow::Result<UUID4> {
255 self.send_with_reply(|reply| CatalogCommand::OpenSession {
256 query,
257 chunk_size,
258 reply,
259 })
260 }
261
262 pub fn pull_session(&self, session_id: UUID4) -> anyhow::Result<Option<DataBatch>> {
263 self.send_with_reply(|reply| CatalogCommand::PullSession { session_id, reply })
264 }
265
266 pub fn pull_session_async(
268 &self,
269 session_id: UUID4,
270 on_complete: Box<dyn FnOnce(anyhow::Result<Option<DataBatch>>) + Send>,
271 ) -> anyhow::Result<()> {
272 self.send(CatalogCommand::PullSessionAsync {
273 session_id,
274 on_complete,
275 })
276 }
277
278 pub fn close_session(&self, session_id: UUID4) -> anyhow::Result<bool> {
280 self.send_with_reply(|reply| CatalogCommand::CloseSession { session_id, reply })
281 }
282
283 pub fn query_instruments(
284 &self,
285 query: CatalogInstrumentQuery,
286 ) -> anyhow::Result<Vec<InstrumentAny>> {
287 self.send_with_reply(|reply| CatalogCommand::QueryInstruments { query, reply })
288 }
289
290 pub fn write(&self, job: CatalogWriteJob) -> anyhow::Result<()> {
291 self.send_with_reply(|reply| CatalogCommand::Write { job, reply })
292 }
293
294 pub fn write_async(&self, job: CatalogWriteJob) -> anyhow::Result<()> {
295 self.send(CatalogCommand::WriteAsync { job })
296 }
297
298 pub fn write_instruments(&self, instruments: Vec<InstrumentAny>) -> anyhow::Result<()> {
299 self.send_with_reply(|reply| CatalogCommand::WriteInstruments { instruments, reply })
300 }
301
302 pub fn write_instruments_async(&self, instruments: Vec<InstrumentAny>) -> anyhow::Result<()> {
303 self.send(CatalogCommand::WriteInstrumentsAsync { instruments })
304 }
305
306 pub fn flush(&self) -> anyhow::Result<()> {
307 self.send_with_reply(|reply| CatalogCommand::Flush { reply })
308 }
309}
310
311impl Drop for CatalogWorker {
312 fn drop(&mut self) {
313 let _ = self.sender.send(CatalogCommand::Shutdown);
314 if let Some(handle) = self.handle.take() {
315 let _ = handle.join();
316 }
317 }
318}
319
320#[expect(
321 clippy::needless_pass_by_value,
322 clippy::too_many_lines,
323 reason = "The worker thread owns the receiver for its whole lifetime"
324)]
325fn run_catalog_worker(catalog: &mut DataCatalog, receiver: Receiver<CatalogCommand>) {
326 let mut async_errors: Vec<anyhow::Error> = Vec::new();
327 let mut sessions: AHashMap<UUID4, DataBatchQueryResult> = AHashMap::new();
328
329 while let Ok(command) = receiver.recv() {
330 match command {
331 CatalogCommand::QueryLastTimestamp {
332 data_type,
333 identifier,
334 reply,
335 } => {
336 let result = catalog.query_last_timestamp(data_type, identifier.as_deref());
337 let _ = reply.send(result);
338 }
339 CatalogCommand::GetMissingIntervals {
340 start,
341 end,
342 data_type,
343 identifier,
344 reply,
345 } => {
346 let result = catalog.get_missing_intervals_for_request(
347 start,
348 end,
349 data_type,
350 identifier.as_deref(),
351 );
352 let _ = reply.send(result);
353 }
354 CatalogCommand::GetMissingIntervalsForIdentifiers {
355 start,
356 end,
357 data_type,
358 identifiers,
359 reply,
360 } => {
361 let result = catalog.get_missing_intervals_for_identifiers(
362 start,
363 end,
364 data_type,
365 &identifiers,
366 );
367 let _ = reply.send(result);
368 }
369 CatalogCommand::GetCoverageIntervalsForIdentifiers {
370 start,
371 end,
372 data_type,
373 identifiers,
374 reply,
375 } => {
376 let result = catalog.get_coverage_intervals_for_identifiers(
377 start,
378 end,
379 data_type,
380 &identifiers,
381 );
382 let _ = reply.send(result);
383 }
384 CatalogCommand::QueryBatch { query, reply } => {
385 let result = catalog.query_batch(&query);
386 let _ = reply.send(result);
387 }
388 CatalogCommand::QueryBatchAsync { query, on_complete } => {
389 let result = catalog.query_batch(&query);
390 on_complete(result);
391 }
392 CatalogCommand::OpenSession {
393 query,
394 chunk_size,
395 reply,
396 } => {
397 let result = if sessions.len() >= MAX_OPEN_SESSIONS {
398 Err(anyhow::anyhow!(
399 "Catalog session limit of {MAX_OPEN_SESSIONS} reached; close finished sessions before opening more",
400 ))
401 } else {
402 open_query_session(catalog, &query, chunk_size).map(|session| {
403 let session_id = UUID4::new();
404 sessions.insert(session_id, session);
405 session_id
406 })
407 };
408 let _ = reply.send(result);
409 }
410 CatalogCommand::PullSession { session_id, reply } => {
411 let result = pull_session(&mut sessions, session_id);
412 let _ = reply.send(result);
413 }
414 CatalogCommand::PullSessionAsync {
415 session_id,
416 on_complete,
417 } => {
418 on_complete(pull_session(&mut sessions, session_id));
419 }
420 CatalogCommand::CloseSession { session_id, reply } => {
421 let _ = reply.send(Ok(sessions.remove(&session_id).is_some()));
422 }
423 CatalogCommand::QueryInstruments { query, reply } => {
424 let result = catalog.instruments(&query);
425 let _ = reply.send(result);
426 }
427 CatalogCommand::Write { job, reply } => {
428 let result = catalog.write_data_batch(&job.data, job.start, job.end, job.params);
429 let _ = reply.send(result);
430 }
431 CatalogCommand::WriteAsync { job } => {
432 let len = job.data.len();
433 match catalog.write_data_batch(&job.data, job.start, job.end, job.params) {
434 Ok(()) => log::info!("Catalog worker wrote {len} data rows"),
435 Err(e) => {
436 log::error!("Catalog worker failed to write {len} data rows: {e}");
437 async_errors.push(e);
438 }
439 }
440 }
441 CatalogCommand::WriteInstruments { instruments, reply } => {
442 let result = catalog.write_instruments(&instruments);
443 let _ = reply.send(result);
444 }
445 CatalogCommand::WriteInstrumentsAsync { instruments } => {
446 let len = instruments.len();
447 match catalog.write_instruments(&instruments) {
448 Ok(()) => log::info!("Catalog worker wrote {len} instruments"),
449 Err(e) => {
450 log::error!("Catalog worker failed to write {len} instruments: {e}");
451 async_errors.push(e);
452 }
453 }
454 }
455 CatalogCommand::Flush { reply } => {
456 let _ = reply.send(drain_async_errors(&mut async_errors));
457 }
458 CatalogCommand::Shutdown => break,
459 }
460 }
461}
462
463fn open_query_session(
464 catalog: &mut DataCatalog,
465 query: &CatalogQuery,
466 chunk_size: Option<usize>,
467) -> anyhow::Result<DataBatchQueryResult> {
468 if let Some(mut query_catalog) = catalog.fork_query_catalog()? {
469 query_catalog.reset_session();
470 return query_catalog.query_batch_session(query, chunk_size);
471 }
472
473 catalog.reset_session();
474 catalog.query_batch_session(query, chunk_size)
475}
476
477fn drain_async_errors(errors: &mut Vec<anyhow::Error>) -> anyhow::Result<()> {
478 let failures = errors.len();
479 let details = errors
480 .iter()
481 .map(|e| format!("{e:#}"))
482 .collect::<Vec<_>>()
483 .join("; ");
484 let Some(first) = errors.drain(..).next() else {
485 return Ok(());
486 };
487
488 if failures == 1 {
489 return Err(first);
490 }
491
492 Err(first.context(format!(
493 "{failures} asynchronous catalog writes failed: {details}"
494 )))
495}
496
497fn pull_session(
498 sessions: &mut AHashMap<UUID4, DataBatchQueryResult>,
499 session_id: UUID4,
500) -> anyhow::Result<Option<DataBatch>> {
501 let result = sessions
502 .get_mut(&session_id)
503 .ok_or_else(|| anyhow::anyhow!("Catalog session {session_id} is not open"))?
504 .next_batch();
505
506 if !matches!(result, Ok(Some(_))) {
507 sessions.remove(&session_id);
508 }
509 result
510}
511
512#[cfg(test)]
517mod tests {
518 use std::{
519 sync::{Arc, Mutex},
520 time::Duration,
521 };
522
523 use nautilus_model::{
524 data::{Data, NautilusRecordType, QuoteTick},
525 identifiers::InstrumentId,
526 types::{Price, Quantity},
527 };
528 use rstest::rstest;
529
530 use super::*;
531 use crate::catalog::{
532 session::{DataBatchQuery, TypedDataBatchSession},
533 traits::{CatalogMetadata, CatalogReader, CatalogRecordQuery, CatalogWriter, RecordBatch},
534 };
535
536 struct FailingSession;
537
538 impl DataBatchQuery for FailingSession {
539 fn next_batch(&mut self) -> anyhow::Result<Option<DataBatch>> {
540 anyhow::bail!("session decode failure")
541 }
542 }
543
544 fn stub_quote() -> QuoteTick {
545 QuoteTick::new(
546 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
547 Price::from("1987.0"),
548 Price::from("1988.0"),
549 Quantity::from("100"),
550 Quantity::from("100"),
551 UnixNanos::from(1),
552 UnixNanos::from(1),
553 )
554 }
555
556 #[derive(Debug)]
557 struct StubCatalog {
558 fail: bool,
559 query_thread: Arc<Mutex<Option<thread::ThreadId>>>,
560 }
561
562 impl CatalogReader for StubCatalog {
563 fn reset_session(&mut self) {}
564
565 fn instruments(
566 &mut self,
567 _query: &CatalogInstrumentQuery,
568 ) -> anyhow::Result<Vec<InstrumentAny>> {
569 Ok(Vec::new())
570 }
571
572 fn query_batch(&mut self, _query: &CatalogQuery) -> anyhow::Result<DataBatch> {
573 *self.query_thread.lock().unwrap() = Some(thread::current().id());
574
575 if self.fail {
576 anyhow::bail!("stub query failure");
577 }
578 Ok(DataBatch::Quote(vec![stub_quote()].into()))
579 }
580
581 fn query_batch_session(
582 &mut self,
583 _query: &CatalogQuery,
584 chunk_size: Option<usize>,
585 ) -> anyhow::Result<DataBatchQueryResult> {
586 *self.query_thread.lock().unwrap() = Some(thread::current().id());
587
588 if self.fail {
589 anyhow::bail!("stub query failure");
590 }
591 Ok(Box::new(TypedDataBatchSession::from_vec(
592 vec![stub_quote()],
593 chunk_size,
594 )))
595 }
596
597 fn query_metadata(
598 &mut self,
599 _query: &CatalogQuery,
600 ) -> anyhow::Result<Vec<CatalogMetadata>> {
601 Ok(Vec::new())
602 }
603
604 fn get_missing_intervals_for_request(
605 &mut self,
606 _start: UnixNanos,
607 _end: UnixNanos,
608 _data_type: NautilusDataType,
609 _identifier: Option<&str>,
610 ) -> anyhow::Result<Vec<(u64, u64)>> {
611 Ok(Vec::new())
612 }
613
614 fn query_last_timestamp(
615 &mut self,
616 _data_type: NautilusDataType,
617 _identifier: Option<&str>,
618 ) -> anyhow::Result<Option<u64>> {
619 Ok(None)
620 }
621
622 fn query_display_record_batches(
623 &mut self,
624 _query: &CatalogQuery,
625 ) -> anyhow::Result<Vec<RecordBatch>> {
626 Ok(Vec::new())
627 }
628
629 fn query_record_batches(
630 &mut self,
631 _query: &CatalogRecordQuery,
632 ) -> anyhow::Result<Vec<RecordBatch>> {
633 Ok(Vec::new())
634 }
635
636 fn query_record_display_batches(
637 &mut self,
638 _query: &CatalogRecordQuery,
639 ) -> anyhow::Result<Vec<RecordBatch>> {
640 Ok(Vec::new())
641 }
642 }
643
644 impl CatalogWriter for StubCatalog {
645 fn write_instruments(&mut self, _instruments: &[InstrumentAny]) -> anyhow::Result<()> {
646 Ok(())
647 }
648
649 fn write_data(
650 &mut self,
651 _data: &[Data],
652 _start: Option<UnixNanos>,
653 _end: Option<UnixNanos>,
654 params: Option<Params>,
655 ) -> anyhow::Result<()> {
656 if self.fail {
657 let message = params
658 .as_ref()
659 .and_then(|params| params.get_str("test_error"))
660 .unwrap_or("stub write failure");
661 anyhow::bail!(message.to_string());
662 }
663 Ok(())
664 }
665
666 fn write_records(
667 &mut self,
668 _record_type: NautilusRecordType,
669 _batches: &[RecordBatch],
670 _params: Option<Params>,
671 ) -> anyhow::Result<()> {
672 Ok(())
673 }
674
675 fn record_empty_coverage(
676 &mut self,
677 _data_type: NautilusDataType,
678 _identifier: Option<&str>,
679 _start: UnixNanos,
680 _end: UnixNanos,
681 ) -> anyhow::Result<()> {
682 Ok(())
683 }
684 }
685
686 fn stub_query() -> CatalogQuery {
687 CatalogQuery::new(NautilusDataType::QuoteTick)
688 }
689
690 #[rstest]
691 fn test_query_batch_async_invokes_callback_on_worker_thread() {
692 let query_thread = Arc::new(Mutex::new(None));
693 let worker = CatalogWorker::start(Box::new(StubCatalog {
694 fail: false,
695 query_thread: query_thread.clone(),
696 }));
697
698 let (tx, rx) = mpsc::channel();
699 worker
700 .query_batch_async(
701 stub_query(),
702 Box::new(move |result| {
703 let _ = tx.send((thread::current().id(), result.map(|batch| batch.len())));
704 }),
705 )
706 .unwrap();
707
708 let (callback_thread, result) = rx
709 .recv_timeout(Duration::from_secs(5))
710 .expect("callback was not invoked");
711 assert_eq!(result.unwrap(), 1);
712 assert_ne!(callback_thread, thread::current().id());
713 assert_eq!(Some(callback_thread), *query_thread.lock().unwrap());
714 }
715
716 #[rstest]
717 fn test_query_batch_async_passes_query_error_to_callback() {
718 let worker = CatalogWorker::start(Box::new(StubCatalog {
719 fail: true,
720 query_thread: Arc::new(Mutex::new(None)),
721 }));
722
723 let (tx, rx) = mpsc::channel();
724 worker
725 .query_batch_async(
726 stub_query(),
727 Box::new(move |result| {
728 let _ = tx.send(result.map(|batch| batch.len()));
729 }),
730 )
731 .unwrap();
732
733 let result = rx
734 .recv_timeout(Duration::from_secs(5))
735 .expect("callback was not invoked");
736 assert_eq!(result.unwrap_err().to_string(), "stub query failure");
737 }
738
739 #[rstest]
740 fn test_worker_owns_and_pulls_catalog_session() {
741 let query_thread = Arc::new(Mutex::new(None));
742 let worker = CatalogWorker::start(Box::new(StubCatalog {
743 fail: false,
744 query_thread: query_thread.clone(),
745 }));
746
747 let session_id = worker.open_session(stub_query(), Some(1)).unwrap();
748 let batch = worker.pull_session(session_id).unwrap().unwrap();
749 let complete = worker.pull_session(session_id).unwrap();
750 let closed = worker.pull_session(session_id).unwrap_err();
751
752 assert_eq!(batch.len(), 1);
753 assert!(complete.is_none());
754 assert_eq!(
755 closed.to_string(),
756 format!("Catalog session {session_id} is not open")
757 );
758 assert_ne!(*query_thread.lock().unwrap(), Some(thread::current().id()));
759 }
760
761 #[rstest]
762 fn test_close_session_drops_open_session() {
763 let worker = CatalogWorker::start(Box::new(StubCatalog {
764 fail: false,
765 query_thread: Arc::new(Mutex::new(None)),
766 }));
767 let session_id = worker.open_session(stub_query(), Some(1)).unwrap();
768
769 let first_close = worker.close_session(session_id).unwrap();
770 let second_close = worker.close_session(session_id).unwrap();
771
772 assert!(first_close);
773 assert!(!second_close);
774 }
775
776 #[rstest]
777 fn errored_session_is_removed_after_pull() {
778 let session_id = UUID4::new();
779 let mut sessions = AHashMap::new();
780 sessions.insert(session_id, Box::new(FailingSession) as DataBatchQueryResult);
781
782 let first = pull_session(&mut sessions, session_id).unwrap_err();
783 let second = pull_session(&mut sessions, session_id).unwrap_err();
784
785 assert_eq!(first.to_string(), "session decode failure");
786 assert_eq!(
787 second.to_string(),
788 format!("Catalog session {session_id} is not open"),
789 );
790 assert!(sessions.is_empty());
791 }
792
793 #[rstest]
794 fn test_flush_reports_every_failed_async_write_then_clears() {
795 let worker = CatalogWorker::start(Box::new(StubCatalog {
796 fail: true,
797 query_thread: Arc::new(Mutex::new(None)),
798 }));
799
800 for message in [
801 "first write failed",
802 "second write failed",
803 "third write failed",
804 ] {
805 let mut params = Params::new();
806 params.insert("test_error".to_string(), message.into());
807 worker
808 .write_async(CatalogWriteJob {
809 data: DataBatch::Quote(vec![stub_quote()].into()),
810 start: None,
811 end: None,
812 params: Some(params),
813 })
814 .unwrap();
815 }
816
817 let error = worker.flush().unwrap_err();
818
819 assert_eq!(
820 error.to_string(),
821 "3 asynchronous catalog writes failed: first write failed; second write failed; \
822 third write failed",
823 );
824 assert_eq!(error.root_cause().to_string(), "first write failed");
825 assert!(worker.flush().is_ok(), "flush must clear reported failures");
826 }
827
828 #[rstest]
829 fn test_open_session_rejects_beyond_the_session_limit() {
830 let worker = CatalogWorker::start(Box::new(StubCatalog {
831 fail: false,
832 query_thread: Arc::new(Mutex::new(None)),
833 }));
834 let session_ids = (0..MAX_OPEN_SESSIONS)
835 .map(|_| worker.open_session(stub_query(), Some(1)).unwrap())
836 .collect::<Vec<_>>();
837
838 let error = worker.open_session(stub_query(), Some(1)).unwrap_err();
839 assert_eq!(
840 error.to_string(),
841 format!(
842 "Catalog session limit of {MAX_OPEN_SESSIONS} reached; close finished sessions before opening more"
843 ),
844 );
845
846 assert!(worker.close_session(session_ids[0]).unwrap());
847 assert!(
848 worker.open_session(stub_query(), Some(1)).is_ok(),
849 "closing a session must free a slot",
850 );
851 }
852
853 #[rstest]
854 fn test_pull_session_async_invokes_callback_on_worker_thread() {
855 let worker = CatalogWorker::start(Box::new(StubCatalog {
856 fail: false,
857 query_thread: Arc::new(Mutex::new(None)),
858 }));
859 let session_id = worker.open_session(stub_query(), Some(1)).unwrap();
860 let (tx, rx) = mpsc::channel();
861
862 worker
863 .pull_session_async(
864 session_id,
865 Box::new(move |result| {
866 let _ = tx.send((
867 thread::current().id(),
868 result.map(|batch| batch.unwrap().len()),
869 ));
870 }),
871 )
872 .unwrap();
873 let (callback_thread, result) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
874
875 assert_eq!(result.unwrap(), 1);
876 assert_ne!(callback_thread, thread::current().id());
877 }
878}