nautilus_persistence/python/backend/session.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
16use std::collections::HashMap;
17
18use nautilus_core::python::{to_pyruntime_err, to_pyvalue_err};
19use nautilus_model::{
20 data::{
21 Bar, InstrumentStatus, MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDepth10,
22 QuoteTick, TradeTick,
23 },
24 python::data::data_to_pyobject,
25};
26use nautilus_serialization::arrow::{ArrowSchemaProvider, custom::CustomDataDecoder};
27use pyo3::{IntoPyObjectExt, prelude::*};
28
29use crate::backend::session::{DataBackendSession, DataQueryResult, QueryError};
30
31/// Wrapper to pass a raw pointer across the GIL release boundary.
32struct SendPtr<T>(*mut T);
33
34// SAFETY: Access is serialized by the calling `PyRefMut`
35unsafe impl<T> Send for SendPtr<T> {}
36
37#[repr(C)]
38#[pyclass(frozen, eq, eq_int, from_py_object)]
39#[pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.persistence")]
40#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
41pub enum NautilusDataType {
42 // Custom = 0, # First slot reserved for custom data
43 OrderBookDelta = 1,
44 OrderBookDepth10 = 2,
45 QuoteTick = 3,
46 TradeTick = 4,
47 Bar = 5,
48 MarkPriceUpdate = 6,
49 OptionGreeks = 7,
50 InstrumentStatus = 8,
51}
52
53#[pymethods]
54#[pyo3_stub_gen::derive::gen_stub_pymethods]
55impl NautilusDataType {
56 #[expect(
57 clippy::trivially_copy_pass_by_ref,
58 reason = "PyO3 special methods use a borrowed receiver"
59 )]
60 const fn __hash__(&self) -> isize {
61 *self as isize
62 }
63}
64
65#[pymethods]
66#[pyo3_stub_gen::derive::gen_stub_pymethods]
67impl DataBackendSession {
68 /// Provides a DataFusion session and registers DataFusion queries.
69 ///
70 /// The session is used to register data sources and make queries on them. A
71 /// query returns a Chunk of Arrow records. It is decoded and converted into
72 /// a Vec of data by types that implement `DecodeDataFromRecordBatch`.
73 #[new]
74 #[pyo3(signature=(chunk_size=10_000))]
75 fn py_new(chunk_size: usize) -> PyResult<Self> {
76 if chunk_size == 0 {
77 return Err(to_pyvalue_err("chunk_size must be positive"));
78 }
79
80 Ok(Self::new(chunk_size))
81 }
82
83 /// Registers a Parquet file and adds a batch stream for decoding.
84 ///
85 /// The caller must specify `T` to indicate the kind of data expected. `table_name` is
86 /// the logical name for queries; `file_path` is the Parquet path; `sql_query` defaults
87 /// to `SELECT * FROM {table_name} ORDER BY ts_init` if `None`.
88 ///
89 /// When `custom_type_name` is `Some`, it is merged into each batch's schema metadata
90 /// before decoding (as `type_name`). Use this for custom data when Parquet/DataFusion
91 /// does not preserve schema metadata so the decoder can look up the type in the registry.
92 ///
93 /// The file data must be ordered by the `ts_init` in ascending order for this
94 /// to work correctly.
95 ///
96 /// # Errors
97 ///
98 /// Returns an error if parquet registration, SQL planning, stream execution, or
99 /// data decoding setup fails.
100 #[pyo3(name = "add_file")]
101 #[pyo3(signature = (data_type, table_name, file_path, sql_query=None))]
102 fn py_add_file(
103 mut slf: PyRefMut<'_, Self>,
104 data_type: NautilusDataType,
105 table_name: &str,
106 file_path: &str,
107 sql_query: Option<&str>,
108 ) -> PyResult<()> {
109 let _guard = slf.runtime.enter();
110
111 match data_type {
112 NautilusDataType::OrderBookDelta => slf
113 .add_file::<OrderBookDelta>(table_name, file_path, sql_query, None)
114 .map_err(to_pyruntime_err),
115 NautilusDataType::OrderBookDepth10 => slf
116 .add_file::<OrderBookDepth10>(table_name, file_path, sql_query, None)
117 .map_err(to_pyruntime_err),
118 NautilusDataType::QuoteTick => slf
119 .add_file::<QuoteTick>(table_name, file_path, sql_query, None)
120 .map_err(to_pyruntime_err),
121 NautilusDataType::TradeTick => slf
122 .add_file::<TradeTick>(table_name, file_path, sql_query, None)
123 .map_err(to_pyruntime_err),
124 NautilusDataType::Bar => slf
125 .add_file::<Bar>(table_name, file_path, sql_query, None)
126 .map_err(to_pyruntime_err),
127 NautilusDataType::MarkPriceUpdate => slf
128 .add_file::<MarkPriceUpdate>(table_name, file_path, sql_query, None)
129 .map_err(to_pyruntime_err),
130 NautilusDataType::OptionGreeks => slf
131 .add_file::<OptionGreeks>(table_name, file_path, sql_query, None)
132 .map_err(to_pyruntime_err),
133 NautilusDataType::InstrumentStatus => slf
134 .add_file::<InstrumentStatus>(table_name, file_path, sql_query, None)
135 .map_err(to_pyruntime_err),
136 }
137 }
138
139 /// Registers a Parquet file for a custom data type identified by `type_name`.
140 ///
141 /// The custom data type must have been registered via
142 /// `ensure_custom_data_registered::<T>()` before calling this method.
143 #[pyo3(name = "add_custom_file")]
144 #[pyo3(signature = (type_name, table_name, file_path, sql_query=None))]
145 fn py_add_custom_file(
146 mut slf: PyRefMut<'_, Self>,
147 type_name: &str,
148 table_name: &str,
149 file_path: &str,
150 sql_query: Option<&str>,
151 ) -> PyResult<()> {
152 let _guard = slf.runtime.enter();
153 let mut metadata = HashMap::new();
154 metadata.insert("type_name".to_string(), type_name.to_string());
155 let base_schema = CustomDataDecoder::get_schema(Some(metadata));
156 base_schema.field_with_name("ts_init").map_err(|_| {
157 to_pyruntime_err(format!(
158 "custom data type '{type_name}' is not registered with an Arrow schema containing ts_init"
159 ))
160 })?;
161 // Use schemaless registration so DataFusion preserves the parquet file's
162 // schema metadata (e.g. `bar_type`) on output batches, since the
163 // explicit-schema variant strips per-batch metadata that decoders rely on.
164 slf.add_file::<CustomDataDecoder>(table_name, file_path, sql_query, Some(type_name))
165 .map_err(to_pyruntime_err)
166 }
167
168 fn to_query_result(mut slf: PyRefMut<'_, Self>) -> DataQueryResult {
169 let py = slf.py();
170 let chunk_size = slf.chunk_size;
171 let ptr = SendPtr(&raw mut *slf);
172
173 // SAFETY: see comment on `__next__` for the safety argument.
174 // The GIL release is needed here because `get_query_result` eagerly
175 // pulls the first element from each stream (via `KMerge::push_iter`),
176 // which blocks on the tokio channel while workers may need the GIL.
177 let query_result = unsafe {
178 py.detach(move || {
179 let p = ptr;
180 (*p.0).get_query_result()
181 })
182 };
183
184 DataQueryResult::new(query_result, chunk_size)
185 }
186
187 /// Register an object store with the session context from a URI with optional storage options.
188 ///
189 /// # Errors
190 ///
191 /// Returns an error if the object store URI cannot be normalized or the backend
192 /// cannot be created.
193 #[pyo3(name = "register_object_store_from_uri")]
194 #[pyo3(signature = (uri, storage_options=None))]
195 fn py_register_object_store_from_uri(
196 mut slf: PyRefMut<'_, Self>,
197 uri: &str,
198 storage_options: Option<HashMap<String, String>>,
199 ) -> PyResult<()> {
200 // Convert HashMap to AHashMap for internal use
201 let storage_options = storage_options.map(|m| m.into_iter().collect());
202 slf.register_object_store_from_uri(uri, storage_options)
203 .map_err(to_pyruntime_err)
204 }
205}
206
207#[pymethods]
208#[pyo3_stub_gen::derive::gen_stub_pymethods]
209impl DataQueryResult {
210 /// Collects the remaining query records as native Python objects.
211 ///
212 /// # Errors
213 ///
214 /// Returns an error if a query stream fails or a record batch cannot be decoded.
215 #[pyo3(name = "to_list")]
216 fn py_to_list(mut slf: PyRefMut<'_, Self>) -> PyResult<Vec<Py<PyAny>>> {
217 let py = slf.py();
218 let ptr = SendPtr(&raw mut *slf);
219
220 // SAFETY: `PyRefMut` guarantees exclusive access to the underlying query result for the
221 // duration of this method call. As with `__next__`, release the GIL while waiting for
222 // decoder workers that may need to acquire it for custom data.
223 let data = unsafe {
224 py.detach(move || -> Result<Vec<_>, QueryError> {
225 let p = ptr;
226 let result = &mut *p.0;
227 let mut data = Vec::new();
228
229 for chunk in result.by_ref() {
230 let chunk = chunk?;
231
232 if chunk.is_empty() {
233 break;
234 }
235 data.extend(chunk);
236 }
237
238 Ok(data)
239 })
240 }
241 .map_err(to_pyruntime_err)?;
242
243 data.into_iter()
244 .map(|item| data_to_pyobject(py, item))
245 .collect()
246 }
247
248 /// The reader implements an iterator.
249 const fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
250 slf
251 }
252
253 /// Each iteration returns a chunk of values read from the parquet file.
254 ///
255 /// # Errors
256 ///
257 /// Returns an error if a query stream fails or a record batch cannot be decoded.
258 fn __next__(mut slf: PyRefMut<'_, Self>) -> PyResult<Option<Py<PyAny>>> {
259 let py = slf.py();
260 let ptr = SendPtr(&raw mut *slf);
261
262 // SAFETY: `PyRefMut` guarantees exclusive access to the underlying
263 // object for the duration of this method call. The runtime borrow
264 // flag prevents any other Python thread from accessing it.
265 //
266 // The GIL must be released here so that tokio worker threads can
267 // acquire it when decoding custom data types via `Python::attach`.
268 // Without this, custom-type streaming deadlocks: the main thread
269 // holds the GIL while blocking on `recv`, and workers block on
270 // `Python::attach` waiting for the GIL.
271 let acc = unsafe {
272 py.detach(move || {
273 let p = ptr;
274 (*p.0).next()
275 })
276 };
277
278 match acc {
279 Some(Ok(acc)) if !acc.is_empty() => {
280 let objects: Vec<Py<PyAny>> = acc
281 .into_iter()
282 .map(|item| data_to_pyobject(py, item))
283 .collect::<PyResult<_>>()?;
284 Ok(Some(objects.into_py_any(py)?))
285 }
286 Some(Err(e)) => Err(to_pyruntime_err(e)),
287 _ => Ok(None),
288 }
289 }
290}