nautilus_persistence/catalog/
session.rs1use std::collections::VecDeque;
21
22use nautilus_core::UnixNanos;
23use nautilus_model::data::{DataBatch, HasTsInit, IntoDataBatch};
24
25pub const DEFAULT_DATA_BATCH_CHUNK_SIZE: usize = 10_000;
26
27pub trait DataBatchQuery: Send {
29 fn next_batch(&mut self) -> anyhow::Result<Option<DataBatch>>;
35
36 fn reset(&mut self) -> anyhow::Result<bool> {
42 Ok(false)
43 }
44}
45
46pub type DataBatchQueryResult = Box<dyn DataBatchQuery>;
47
48pub struct TypedDataBatchSession<T> {
50 pages: Box<dyn Iterator<Item = anyhow::Result<Vec<T>>> + Send>,
51 carry: VecDeque<T>,
52 chunk_size: usize,
53 previous_ts_init: Option<UnixNanos>,
54}
55
56impl<T> TypedDataBatchSession<T>
57where
58 T: IntoDataBatch + HasTsInit + Send,
59{
60 #[must_use]
62 pub fn new(
63 pages: Box<dyn Iterator<Item = anyhow::Result<Vec<T>>> + Send>,
64 chunk_size: Option<usize>,
65 ) -> Self {
66 Self {
67 pages,
68 carry: VecDeque::new(),
69 chunk_size: chunk_size.unwrap_or(DEFAULT_DATA_BATCH_CHUNK_SIZE).max(1),
70 previous_ts_init: None,
71 }
72 }
73
74 #[must_use]
76 pub fn from_vec(data: Vec<T>, chunk_size: Option<usize>) -> Self
77 where
78 T: 'static,
79 {
80 Self::new(Box::new(std::iter::once(Ok(data))), chunk_size)
81 }
82
83 fn next_row(&mut self) -> anyhow::Result<Option<T>> {
84 loop {
85 if let Some(row) = self.carry.pop_front() {
86 debug_assert!(
87 self.previous_ts_init
88 .is_none_or(|previous| previous <= row.ts_init()),
89 "TypedDataBatchSession ordering invariant violated: ts_init {} after {:?}",
90 row.ts_init(),
91 self.previous_ts_init,
92 );
93 self.previous_ts_init = Some(row.ts_init());
94 return Ok(Some(row));
95 }
96
97 match self.pages.next() {
98 Some(page) => self.carry.extend(page?),
99 None => return Ok(None),
100 }
101 }
102 }
103}
104
105impl<T> DataBatchQuery for TypedDataBatchSession<T>
106where
107 T: IntoDataBatch + HasTsInit + Send,
108{
109 fn next_batch(&mut self) -> anyhow::Result<Option<DataBatch>> {
110 let Some(first) = self.next_row()? else {
111 return Ok(None);
112 };
113
114 let mut chunk = Vec::with_capacity(self.chunk_size.min(1024));
115 chunk.push(first);
116
117 while chunk.len() < self.chunk_size {
118 let Some(item) = self.next_row()? else {
119 return Ok(Some(T::into_batch(chunk)));
120 };
121
122 chunk.push(item);
123 }
124
125 let boundary_ts = chunk.last().map(HasTsInit::ts_init);
126
127 loop {
128 match self.next_row()? {
129 Some(item) if Some(item.ts_init()) == boundary_ts => chunk.push(item),
130 Some(item) => {
131 self.carry.push_front(item);
132 break;
133 }
134 None => break,
135 }
136 }
137
138 Ok(Some(T::into_batch(chunk)))
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use nautilus_model::{
145 data::QuoteTick,
146 identifiers::InstrumentId,
147 types::{Price, Quantity},
148 };
149 use rstest::rstest;
150
151 use super::*;
152
153 fn typed_quote(ts_init: u64) -> QuoteTick {
154 QuoteTick::new(
155 InstrumentId::from("AUD/USD.SIM"),
156 Price::from("1.0"),
157 Price::from("1.1"),
158 Quantity::from("1000"),
159 Quantity::from("1000"),
160 UnixNanos::from(ts_init),
161 UnixNanos::from(ts_init),
162 )
163 }
164
165 fn batch_ts(batch: &DataBatch) -> Vec<u64> {
166 match batch {
167 DataBatch::Quote(quotes) => quotes
168 .as_ref()
169 .iter()
170 .map(|quote| quote.ts_init.as_u64())
171 .collect(),
172 other => panic!("expected quote batch, found {other:?}"),
173 }
174 }
175
176 #[rstest]
177 fn typed_session_chunks_pages_with_carry_across_pulls() {
178 let pages: Vec<anyhow::Result<Vec<QuoteTick>>> = vec![
179 Ok(vec![typed_quote(1), typed_quote(2), typed_quote(3)]),
180 Ok(Vec::new()),
181 Ok(vec![typed_quote(4), typed_quote(5)]),
182 ];
183 let mut session = TypedDataBatchSession::new(Box::new(pages.into_iter()), Some(2));
184
185 assert_eq!(batch_ts(&session.next_batch().unwrap().unwrap()), [1, 2]);
186 assert_eq!(batch_ts(&session.next_batch().unwrap().unwrap()), [3, 4]);
187 assert_eq!(batch_ts(&session.next_batch().unwrap().unwrap()), [5]);
188 assert!(session.next_batch().unwrap().is_none());
189 }
190
191 #[rstest]
192 fn typed_session_extends_chunk_across_equal_boundary_ts() {
193 let data = vec![
194 typed_quote(1),
195 typed_quote(2),
196 typed_quote(2),
197 typed_quote(2),
198 typed_quote(3),
199 ];
200 let mut session = TypedDataBatchSession::from_vec(data, Some(2));
201
202 assert_eq!(
203 batch_ts(&session.next_batch().unwrap().unwrap()),
204 [1, 2, 2, 2]
205 );
206 assert_eq!(batch_ts(&session.next_batch().unwrap().unwrap()), [3]);
207 assert!(session.next_batch().unwrap().is_none());
208 }
209
210 #[rstest]
211 fn typed_session_empty_source_yields_none() {
212 let mut session = TypedDataBatchSession::<QuoteTick>::from_vec(Vec::new(), None);
213
214 assert!(session.next_batch().unwrap().is_none());
215 }
216
217 #[rstest]
218 fn typed_session_propagates_page_error() {
219 let pages: Vec<anyhow::Result<Vec<QuoteTick>>> = vec![
220 Ok(vec![typed_quote(1)]),
221 Err(anyhow::anyhow!("page failed")),
222 ];
223 let mut session = TypedDataBatchSession::new(Box::new(pages.into_iter()), Some(4));
224
225 assert_eq!(session.next_batch().unwrap_err().to_string(), "page failed");
226 }
227
228 #[rstest]
229 fn typed_session_reports_reset_as_unsupported() {
230 let mut session = TypedDataBatchSession::from_vec(vec![typed_quote(1)], None);
231
232 assert!(!session.reset().unwrap());
233 }
234}