Skip to main content

nautilus_common/cache/
config.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 nautilus_core::correctness::{
17    CorrectnessResult, CorrectnessResultExt, FAILED, check_in_range_inclusive_usize,
18};
19use serde::{Deserialize, Deserializer, Serialize, de::Error};
20
21use crate::{
22    config::{ConfigError, ConfigErrorCollector, ConfigResult},
23    enums::SerializationEncoding,
24};
25
26pub(super) const MAX_CACHE_DATA_CAPACITY: usize = 1_000_000;
27
28pub(super) fn check_cache_data_capacity(capacity: usize, parameter: &str) -> CorrectnessResult<()> {
29    check_in_range_inclusive_usize(capacity, 1, MAX_CACHE_DATA_CAPACITY, parameter)
30}
31
32/// Configuration for `Cache` instances.
33#[cfg_attr(
34    feature = "python",
35    pyo3::pyclass(module = "nautilus_trader.common", from_py_object)
36)]
37#[cfg_attr(
38    feature = "python",
39    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")
40)]
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
42#[builder(finish_fn(name = build_inner, vis = ""))]
43#[serde(default, deny_unknown_fields)]
44pub struct CacheConfig {
45    /// The encoding for database operations, controls the type of serializer used.
46    #[builder(default = SerializationEncoding::Json)]
47    pub encoding: SerializationEncoding,
48    /// If timestamps should be persisted as ISO 8601 strings.
49    #[builder(default)]
50    pub timestamps_as_iso8601: bool,
51    /// The buffer interval (milliseconds) between pipelined/batched transactions.
52    pub buffer_interval_ms: Option<usize>,
53    /// The batch size for bulk read operations (e.g., MGET).
54    /// If set, bulk reads will be batched into chunks of this size.
55    pub bulk_read_batch_size: Option<usize>,
56    /// If a 'trader-' prefix is used for keys.
57    #[builder(default = true)]
58    pub use_trader_prefix: bool,
59    /// If the trader's instance ID is used for keys.
60    #[builder(default)]
61    pub use_instance_id: bool,
62    /// If the database should be flushed on start.
63    #[builder(default)]
64    pub flush_on_start: bool,
65    /// If instrument data should be dropped from the cache's memory on reset.
66    #[builder(default = true)]
67    pub drop_instruments_on_reset: bool,
68    /// The maximum length for internal tick deques (range `[1, 1_000_000]`).
69    #[builder(default = 10_000)]
70    #[serde(deserialize_with = "deserialize_cache_data_capacity")]
71    pub tick_capacity: usize,
72    /// The maximum length for internal bar deques (range `[1, 1_000_000]`).
73    #[builder(default = 10_000)]
74    #[serde(deserialize_with = "deserialize_cache_data_capacity")]
75    pub bar_capacity: usize,
76    /// If account events should be persisted to a backing database.
77    #[builder(default = true)]
78    pub persist_account_events: bool,
79    /// If market data should be persisted to disk.
80    #[builder(default)]
81    pub save_market_data: bool,
82}
83
84impl<S: cache_config_builder::IsComplete> CacheConfigBuilder<S> {
85    /// Validates and builds the [`CacheConfig`].
86    ///
87    /// # Errors
88    ///
89    /// Returns a [`ConfigError`] if any field fails validation
90    /// (see [`CacheConfig::validate`]).
91    pub fn build(self) -> ConfigResult<CacheConfig> {
92        let config = self.build_inner();
93        config.validate()?;
94        Ok(config)
95    }
96}
97
98impl Default for CacheConfig {
99    fn default() -> Self {
100        Self::builder()
101            .build()
102            .expect("default `CacheConfig` should be valid")
103    }
104}
105
106impl CacheConfig {
107    /// Creates a new [`CacheConfig`] instance.
108    ///
109    /// # Panics
110    ///
111    /// Panics if `tick_capacity` or `bar_capacity` is outside `[1, 1_000_000]`.
112    #[expect(clippy::too_many_arguments)]
113    #[must_use]
114    pub fn new(
115        encoding: SerializationEncoding,
116        timestamps_as_iso8601: bool,
117        buffer_interval_ms: Option<usize>,
118        bulk_read_batch_size: Option<usize>,
119        use_trader_prefix: bool,
120        use_instance_id: bool,
121        flush_on_start: bool,
122        drop_instruments_on_reset: bool,
123        tick_capacity: usize,
124        bar_capacity: usize,
125        persist_account_events: bool,
126        save_market_data: bool,
127    ) -> Self {
128        check_cache_data_capacity(tick_capacity, stringify!(tick_capacity)).expect_display(FAILED);
129        check_cache_data_capacity(bar_capacity, stringify!(bar_capacity)).expect_display(FAILED);
130
131        Self {
132            encoding,
133            timestamps_as_iso8601,
134            buffer_interval_ms,
135            bulk_read_batch_size,
136            use_trader_prefix,
137            use_instance_id,
138            flush_on_start,
139            drop_instruments_on_reset,
140            tick_capacity,
141            bar_capacity,
142            persist_account_events,
143            save_market_data,
144        }
145    }
146
147    /// Checks whether all cache settings are valid.
148    ///
149    /// # Errors
150    ///
151    /// Returns a [`ConfigError`] if a capacity setting is outside `[1, 1_000_000]`.
152    pub fn validate(&self) -> ConfigResult<()> {
153        let mut errors = ConfigErrorCollector::new();
154
155        for (field, value) in [
156            ("tick_capacity", self.tick_capacity),
157            ("bar_capacity", self.bar_capacity),
158        ] {
159            errors.check(
160                check_cache_data_capacity(value, field).is_ok(),
161                ConfigError::range(
162                    field,
163                    format!("must be in range [1, {MAX_CACHE_DATA_CAPACITY}], was {value}"),
164                ),
165            );
166        }
167
168        errors.into_result()
169    }
170}
171
172fn deserialize_cache_data_capacity<'de, D>(deserializer: D) -> Result<usize, D::Error>
173where
174    D: Deserializer<'de>,
175{
176    let value = usize::deserialize(deserializer)?;
177    check_cache_data_capacity(value, "capacity").map_err(D::Error::custom)?;
178    Ok(value)
179}
180
181#[cfg(test)]
182mod tests {
183    use rstest::rstest;
184
185    use super::*;
186
187    #[rstest]
188    fn test_default_uses_json_encoding() {
189        let config = CacheConfig::default();
190
191        assert_eq!(config.encoding, SerializationEncoding::Json);
192    }
193
194    #[rstest]
195    #[case(0, 1)]
196    #[case(1, 0)]
197    #[case(MAX_CACHE_DATA_CAPACITY + 1, 1)]
198    #[case(1, MAX_CACHE_DATA_CAPACITY + 1)]
199    #[case(usize::MAX, 1)]
200    #[case(1, usize::MAX)]
201    #[should_panic]
202    fn test_new_rejects_invalid_capacities(
203        #[case] tick_capacity: usize,
204        #[case] bar_capacity: usize,
205    ) {
206        let _ = CacheConfig::new(
207            SerializationEncoding::MsgPack,
208            false,
209            None,
210            None,
211            true,
212            false,
213            false,
214            true,
215            tick_capacity,
216            bar_capacity,
217            true,
218            false,
219        );
220    }
221
222    #[rstest]
223    fn test_new_accepts_maximum_capacities() {
224        let config = CacheConfig::new(
225            SerializationEncoding::MsgPack,
226            false,
227            None,
228            None,
229            true,
230            false,
231            false,
232            true,
233            MAX_CACHE_DATA_CAPACITY,
234            MAX_CACHE_DATA_CAPACITY,
235            true,
236            false,
237        );
238
239        assert_eq!(config.tick_capacity, MAX_CACHE_DATA_CAPACITY);
240        assert_eq!(config.bar_capacity, MAX_CACHE_DATA_CAPACITY);
241    }
242
243    #[rstest]
244    fn test_builder_rejects_zero_tick_capacity() {
245        let result = CacheConfig::builder().tick_capacity(0).build();
246        assert!(
247            matches!(result, Err(ConfigError::Range { field, .. }) if field == "tick_capacity")
248        );
249    }
250
251    #[rstest]
252    fn test_builder_rejects_zero_bar_capacity() {
253        let result = CacheConfig::builder().bar_capacity(0).build();
254        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "bar_capacity"));
255    }
256
257    #[rstest]
258    fn test_builder_accepts_maximum_capacities() {
259        let config = CacheConfig::builder()
260            .tick_capacity(MAX_CACHE_DATA_CAPACITY)
261            .bar_capacity(MAX_CACHE_DATA_CAPACITY)
262            .build()
263            .unwrap();
264
265        assert_eq!(config.tick_capacity, MAX_CACHE_DATA_CAPACITY);
266        assert_eq!(config.bar_capacity, MAX_CACHE_DATA_CAPACITY);
267    }
268
269    #[rstest]
270    #[case(MAX_CACHE_DATA_CAPACITY + 1, 1, "tick_capacity")]
271    #[case(1, MAX_CACHE_DATA_CAPACITY + 1, "bar_capacity")]
272    #[case(usize::MAX, 1, "tick_capacity")]
273    #[case(1, usize::MAX, "bar_capacity")]
274    fn test_validate_rejects_oversized_capacities(
275        #[case] tick_capacity: usize,
276        #[case] bar_capacity: usize,
277        #[case] expected_field: &str,
278    ) {
279        let config = CacheConfig {
280            tick_capacity,
281            bar_capacity,
282            ..Default::default()
283        };
284
285        let err = config
286            .validate()
287            .expect_err("oversized capacity is invalid");
288
289        assert!(matches!(err, ConfigError::Range { field, .. } if field == expected_field));
290    }
291
292    #[rstest]
293    #[case(0, 1, "tick_capacity")]
294    #[case(1, 0, "bar_capacity")]
295    fn test_validate_rejects_zero_capacities(
296        #[case] tick_capacity: usize,
297        #[case] bar_capacity: usize,
298        #[case] expected_field: &str,
299    ) {
300        let config = CacheConfig {
301            tick_capacity,
302            bar_capacity,
303            ..Default::default()
304        };
305
306        let err = config.validate().expect_err("zero capacity is invalid");
307
308        assert!(matches!(err, ConfigError::Range { field, .. } if field == expected_field));
309    }
310
311    #[rstest]
312    #[case("tick_capacity", 0)]
313    #[case("bar_capacity", 0)]
314    #[case("tick_capacity", MAX_CACHE_DATA_CAPACITY + 1)]
315    #[case("bar_capacity", MAX_CACHE_DATA_CAPACITY + 1)]
316    fn test_deserialize_rejects_invalid_capacities(#[case] field: &str, #[case] capacity: usize) {
317        let raw = format!(r#"{{"{field}":{capacity}}}"#);
318        let err = serde_json::from_str::<CacheConfig>(&raw)
319            .expect_err("invalid capacity should fail deserialization");
320
321        assert!(err.to_string().contains(&format!(
322            "invalid usize for 'capacity' not in range [1, {MAX_CACHE_DATA_CAPACITY}], was {capacity}"
323        )));
324    }
325
326    #[rstest]
327    fn test_deserialize_uses_positive_default_capacities() {
328        let config = serde_json::from_str::<CacheConfig>("{}").unwrap();
329
330        assert_eq!(config.tick_capacity, 10_000);
331        assert_eq!(config.bar_capacity, 10_000);
332    }
333}