Skip to main content

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}