nautilus_persistence/backend/parquet/
paths.rs1use std::path::{Path, PathBuf};
23
24use nautilus_core::{
25 UnixNanos,
26 datetime::{iso8601_to_unix_nanos, unix_nanos_to_iso8601},
27};
28
29pub use crate::common::paths::{
30 CatalogPathPrefix, extract_identifier_from_path, extract_path_components,
31 local_to_object_store_path, make_object_store_path, make_sql_safe_identifier,
32 normalize_path_separators, safe_directory_identifier, urisafe_instrument_id,
33};
34
35#[must_use]
39pub fn timestamps_to_filename(timestamp_1: UnixNanos, timestamp_2: UnixNanos) -> String {
40 let datetime_1 = iso_timestamp_to_file_timestamp(&unix_nanos_to_iso8601(timestamp_1));
41 let datetime_2 = iso_timestamp_to_file_timestamp(&unix_nanos_to_iso8601(timestamp_2));
42
43 format!("{datetime_1}_{datetime_2}.parquet")
44}
45
46#[must_use]
48pub(crate) fn catalog_filename(
49 start_ts: UnixNanos,
50 end_ts: UnixNanos,
51 replay_identity: Option<&str>,
52) -> String {
53 let filename = timestamps_to_filename(start_ts, end_ts);
54
55 let Some(identity) = replay_identity else {
56 return filename;
57 };
58
59 let stem = filename.strip_suffix(".parquet").unwrap_or(&filename);
60 let digest = blake3::hash(identity.as_bytes()).to_hex();
61 format!("{stem}_{}.parquet", &digest[..16])
62}
63
64pub(crate) fn iso_timestamp_to_file_timestamp(iso_timestamp: &str) -> String {
66 iso_timestamp.replace([':', '.'], "-")
67}
68
69pub(crate) fn file_timestamp_to_iso_timestamp(file_timestamp: &str) -> String {
71 let (date_part, time_part) = file_timestamp
72 .split_once('T')
73 .unwrap_or((file_timestamp, ""));
74 let time_part = time_part.strip_suffix('Z').unwrap_or(time_part);
75
76 let final_time_part = match time_part.rsplit_once('-') {
78 Some((time, nanos)) => format!("{}.{nanos}", time.replace('-', ":")),
79 None => time_part.replace('-', ":"),
81 };
82
83 format!("{date_part}T{final_time_part}Z")
84}
85
86pub(crate) fn extract_bar_type_instrument_id(bar_type_dir: &str) -> Option<&str> {
92 let standard = bar_type_dir.split('@').next().unwrap_or(bar_type_dir);
93 let pieces: Vec<&str> = standard.rsplitn(5, '-').collect();
94 if pieces.len() == 5 && pieces[3].chars().all(|c| c.is_ascii_digit()) {
96 Some(pieces[4])
97 } else {
98 None
99 }
100}
101
102#[must_use]
104pub fn extract_sql_safe_filename(file_path: &str) -> String {
105 if file_path.is_empty() {
106 return "unknown_file".to_string();
107 }
108
109 let filename = file_path
110 .split(['/', '\\'])
111 .next_back()
112 .unwrap_or("unknown_file");
113
114 let name_without_ext = if let Some(dot_pos) = filename.rfind(".parquet") {
116 &filename[..dot_pos]
117 } else {
118 filename
119 };
120
121 name_without_ext
123 .replace(['-', ':', '.'], "_")
124 .to_lowercase()
125}
126
127pub fn make_local_path<P: AsRef<Path>>(base_path: P, components: &[&str]) -> PathBuf {
129 let mut path = base_path.as_ref().to_path_buf();
130 path.extend(components);
131 path
132}
133
134pub(crate) fn query_intersects_filename(
136 filename: &str,
137 start: Option<u64>,
138 end: Option<u64>,
139) -> bool {
140 let Some((file_start, file_end)) = parse_filename_timestamps(filename) else {
141 return true;
142 };
143
144 start.is_none_or(|start| start <= file_end) && end.is_none_or(|end| file_start <= end)
145}
146
147#[must_use]
152pub fn parse_filename_timestamps(filename: &str) -> Option<(u64, u64)> {
153 let path = Path::new(filename);
154 let base_name = path.file_name()?.to_str()?;
155 let base_filename = base_name.strip_suffix(".parquet")?;
156 let mut parts = base_filename.split('_');
157 let first_part = parts.next()?;
158 let second_part = parts.next()?;
159 if let Some(replay_identity) = parts.next()
160 && (replay_identity.is_empty()
161 || !replay_identity
162 .bytes()
163 .all(|byte| byte.is_ascii_alphanumeric()))
164 {
165 return None;
166 }
167
168 if parts.next().is_some() {
169 return None;
170 }
171
172 let first_iso = file_timestamp_to_iso_timestamp(first_part);
173 let second_iso = file_timestamp_to_iso_timestamp(second_part);
174
175 let first_ts = iso8601_to_unix_nanos(&first_iso).ok()?.as_u64();
176 let second_ts = iso8601_to_unix_nanos(&second_iso).ok()?.as_u64();
177
178 Some((first_ts, second_ts))
179}
180
181#[cfg(test)]
182mod tests {
183 use nautilus_core::UnixNanos;
184 use rstest::rstest;
185
186 use super::{parse_filename_timestamps, query_intersects_filename, timestamps_to_filename};
187
188 #[rstest]
189 fn parse_filename_timestamps_accepts_replay_identity_suffix() {
190 let base = timestamps_to_filename(UnixNanos::from(1), UnixNanos::from(2));
191 let filename = base.replace(".parquet", "_replay.parquet");
192
193 assert_eq!(parse_filename_timestamps(&filename), Some((1, 2)));
194 }
195
196 #[rstest]
197 fn parse_filename_timestamps_rejects_non_timestamp_segments() {
198 let base = timestamps_to_filename(UnixNanos::from(1), UnixNanos::from(2));
199 let filename = base.replace(".parquet", "_bad-suffix.parquet");
200
201 assert_eq!(parse_filename_timestamps(&filename), None);
202 }
203
204 #[rstest]
205 #[case::unparsable_name_is_kept(None, Some(10), Some(20), true)]
206 #[case::start_on_file_end(Some((10, 20)), Some(20), None, true)]
207 #[case::start_after_file_end(Some((10, 20)), Some(21), None, false)]
208 #[case::end_on_file_start(Some((10, 20)), None, Some(10), true)]
209 #[case::end_before_file_start(Some((10, 20)), None, Some(9), false)]
210 #[case::unbounded(Some((10, 20)), None, None, true)]
211 fn query_intersects_filename_cases(
212 #[case] interval: Option<(u64, u64)>,
213 #[case] start: Option<u64>,
214 #[case] end: Option<u64>,
215 #[case] expected: bool,
216 ) {
217 let filename = interval.map_or_else(
218 || "value.parquet".to_string(),
219 |(file_start, file_end)| {
220 timestamps_to_filename(UnixNanos::from(file_start), UnixNanos::from(file_end))
221 },
222 );
223
224 assert_eq!(query_intersects_filename(&filename, start, end), expected);
225 }
226}