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 an error if any field fails validation (see [`CacheConfig::validate`]).
90    pub fn build(self) -> ConfigResult<CacheConfig> {
91        let config = self.build_inner();
92        config.validate()?;
93        Ok(config)
94    }
95}
96
97impl Default for CacheConfig {
98    fn default() -> Self {
99        Self::builder()
100            .build()
101            .expect("default `CacheConfig` should be valid")
102    }
103}
104
105impl CacheConfig {
106    /// Creates a new [`CacheConfig`] instance.
107    ///
108    /// # Panics
109    ///
110    /// Panics if `tick_capacity` or `bar_capacity` is outside `[1, 1_000_000]`.
111    #[expect(clippy::too_many_arguments)]
112    #[must_use]
113    pub fn new(
114        encoding: SerializationEncoding,
115        timestamps_as_iso8601: bool,
116        buffer_interval_ms: Option<usize>,
117        bulk_read_batch_size: Option<usize>,
118        use_trader_prefix: bool,
119        use_instance_id: bool,
120        flush_on_start: bool,
121        drop_instruments_on_reset: bool,
122        tick_capacity: usize,
123        bar_capacity: usize,
124        persist_account_events: bool,
125        save_market_data: bool,
126    ) -> Self {
127        check_cache_data_capacity(tick_capacity, stringify!(tick_capacity)).expect_display(FAILED);
128        check_cache_data_capacity(bar_capacity, stringify!(bar_capacity)).expect_display(FAILED);
129
130        Self {
131            encoding,
132            timestamps_as_iso8601,
133            buffer_interval_ms,
134            bulk_read_batch_size,
135            use_trader_prefix,
136            use_instance_id,
137            flush_on_start,
138            drop_instruments_on_reset,
139            tick_capacity,
140            bar_capacity,
141            persist_account_events,
142            save_market_data,
143        }
144    }
145
146    /// Checks whether all cache settings are valid.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if a capacity setting is outside `[1, 1_000_000]`.
151    pub fn validate(&self) -> ConfigResult<()> {
152        let mut errors = ConfigErrorCollector::new();
153
154        for (field, value) in [
155            ("tick_capacity", self.tick_capacity),
156            ("bar_capacity", self.bar_capacity),
157        ] {
158            errors.check(
159                check_cache_data_capacity(value, field).is_ok(),
160                ConfigError::range(
161                    field,
162                    format!("must be in range [1, {MAX_CACHE_DATA_CAPACITY}], was {value}"),
163                ),
164            );
165        }
166
167        errors.into_result()
168    }
169}
170
171fn deserialize_cache_data_capacity<'de, D>(deserializer: D) -> Result<usize, D::Error>
172where
173    D: Deserializer<'de>,
174{
175    let value = usize::deserialize(deserializer)?;
176    check_cache_data_capacity(value, "capacity").map_err(D::Error::custom)?;
177    Ok(value)
178}
179
180#[cfg(test)]
181mod tests {
182    use rstest::rstest;
183
184    use super::*;
185
186    #[rstest]
187    fn test_default_uses_json_encoding() {
188        let config = CacheConfig::default();
189
190        assert_eq!(config.encoding, SerializationEncoding::Json);
191    }
192
193    #[rstest]
194    #[case(0, 1)]
195    #[case(1, 0)]
196    #[case(MAX_CACHE_DATA_CAPACITY + 1, 1)]
197    #[case(1, MAX_CACHE_DATA_CAPACITY + 1)]
198    #[case(usize::MAX, 1)]
199    #[case(1, usize::MAX)]
200    #[should_panic]
201    fn test_new_rejects_invalid_capacities(
202        #[case] tick_capacity: usize,
203        #[case] bar_capacity: usize,
204    ) {
205        let _ = CacheConfig::new(
206            SerializationEncoding::MsgPack,
207            false,
208            None,
209            None,
210            true,
211            false,
212            false,
213            true,
214            tick_capacity,
215            bar_capacity,
216            true,
217            false,
218        );
219    }
220
221    #[rstest]
222    fn test_new_accepts_maximum_capacities() {
223        let config = CacheConfig::new(
224            SerializationEncoding::MsgPack,
225            false,
226            None,
227            None,
228            true,
229            false,
230            false,
231            true,
232            MAX_CACHE_DATA_CAPACITY,
233            MAX_CACHE_DATA_CAPACITY,
234            true,
235            false,
236        );
237
238        assert_eq!(config.tick_capacity, MAX_CACHE_DATA_CAPACITY);
239        assert_eq!(config.bar_capacity, MAX_CACHE_DATA_CAPACITY);
240    }
241
242    #[rstest]
243    fn test_builder_rejects_zero_tick_capacity() {
244        let result = CacheConfig::builder().tick_capacity(0).build();
245        assert!(
246            matches!(result, Err(ConfigError::Range { field, .. }) if field == "tick_capacity")
247        );
248    }
249
250    #[rstest]
251    fn test_builder_rejects_zero_bar_capacity() {
252        let result = CacheConfig::builder().bar_capacity(0).build();
253        assert!(matches!(result, Err(ConfigError::Range { field, .. }) if field == "bar_capacity"));
254    }
255
256    #[rstest]
257    fn test_builder_accepts_maximum_capacities() {
258        let config = CacheConfig::builder()
259            .tick_capacity(MAX_CACHE_DATA_CAPACITY)
260            .bar_capacity(MAX_CACHE_DATA_CAPACITY)
261            .build()
262            .unwrap();
263
264        assert_eq!(config.tick_capacity, MAX_CACHE_DATA_CAPACITY);
265        assert_eq!(config.bar_capacity, MAX_CACHE_DATA_CAPACITY);
266    }
267
268    #[rstest]
269    #[case(MAX_CACHE_DATA_CAPACITY + 1, 1, "tick_capacity")]
270    #[case(1, MAX_CACHE_DATA_CAPACITY + 1, "bar_capacity")]
271    #[case(usize::MAX, 1, "tick_capacity")]
272    #[case(1, usize::MAX, "bar_capacity")]
273    fn test_validate_rejects_oversized_capacities(
274        #[case] tick_capacity: usize,
275        #[case] bar_capacity: usize,
276        #[case] expected_field: &str,
277    ) {
278        let config = CacheConfig {
279            tick_capacity,
280            bar_capacity,
281            ..Default::default()
282        };
283
284        let err = config
285            .validate()
286            .expect_err("oversized capacity is invalid");
287
288        assert!(matches!(err, ConfigError::Range { field, .. } if field == expected_field));
289    }
290
291    #[rstest]
292    #[case(0, 1, "tick_capacity")]
293    #[case(1, 0, "bar_capacity")]
294    fn test_validate_rejects_zero_capacities(
295        #[case] tick_capacity: usize,
296        #[case] bar_capacity: usize,
297        #[case] expected_field: &str,
298    ) {
299        let config = CacheConfig {
300            tick_capacity,
301            bar_capacity,
302            ..Default::default()
303        };
304
305        let err = config.validate().expect_err("zero capacity is invalid");
306
307        assert!(matches!(err, ConfigError::Range { field, .. } if field == expected_field));
308    }
309
310    #[rstest]
311    #[case("tick_capacity", 0)]
312    #[case("bar_capacity", 0)]
313    #[case("tick_capacity", MAX_CACHE_DATA_CAPACITY + 1)]
314    #[case("bar_capacity", MAX_CACHE_DATA_CAPACITY + 1)]
315    fn test_deserialize_rejects_invalid_capacities(#[case] field: &str, #[case] capacity: usize) {
316        let raw = format!(r#"{{"{field}":{capacity}}}"#);
317        let err = serde_json::from_str::<CacheConfig>(&raw)
318            .expect_err("invalid capacity should fail deserialization");
319
320        assert!(err.to_string().contains(&format!(
321            "invalid usize for 'capacity' not in range [1, {MAX_CACHE_DATA_CAPACITY}], was {capacity}"
322        )));
323    }
324
325    #[rstest]
326    fn test_deserialize_uses_positive_default_capacities() {
327        let config = serde_json::from_str::<CacheConfig>("{}").unwrap();
328
329        assert_eq!(config.tick_capacity, 10_000);
330        assert_eq!(config.bar_capacity, 10_000);
331    }
332}