1use std::{
19 collections::BTreeSet,
20 fmt::Display,
21 fs, io,
22 path::{Component, PathBuf},
23 sync::Arc,
24};
25
26use ahash::AHashMap;
27use futures::{StreamExt, TryStreamExt, stream::BoxStream};
28use nautilus_core::time::nanos_since_unix_epoch;
29use object_store::{
30 CopyOptions, Error as ObjectStoreError, GetOptions, GetResult, ListResult, MultipartUpload,
31 ObjectMeta, ObjectStore, ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload,
32 PutResult, Result as ObjectStoreResult, path::Path as ObjectPath,
33};
34use serde::{Deserialize, Serialize};
35use url::Url;
36
37pub use crate::common::paths::normalize_path_to_uri;
38use crate::common::paths::{file_uri_to_native_path, make_object_store_path, path_to_file_uri};
39
40pub const RUN_MANIFEST_FILENAME: &str = "_nautilus_run_manifest.json";
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
45pub struct RunManifest {
46 pub schema_version: u32,
47 pub kind: String,
48 pub instance_id: String,
49 pub status: String,
50 pub empty: bool,
51 pub created_ts: u64,
52}
53
54impl RunManifest {
55 #[must_use]
57 pub fn new(kind: &str, instance_id: &str, status: &str, empty: bool) -> Self {
58 Self {
59 schema_version: 1,
60 kind: kind.to_string(),
61 instance_id: instance_id.to_string(),
62 status: status.to_string(),
63 empty,
64 created_ts: nanos_since_unix_epoch(),
65 }
66 }
67}
68
69#[derive(Clone)]
71pub struct StorageBackend {
72 pub object_store: Arc<dyn ObjectStore>,
74 pub base_path: String,
76 pub original_uri: String,
78}
79
80impl StorageBackend {
81 pub fn datafusion_root_url(&self) -> anyhow::Result<Url> {
92 datafusion_root_url(&self.original_uri)
93 }
94
95 pub async fn list_directory_stems(&self, subdirectory: &str) -> anyhow::Result<Vec<String>> {
104 let directory = make_object_store_path(&self.base_path, [subdirectory]);
105 let prefix = ObjectPath::from(format!("{}/", directory.trim_end_matches('/')));
106 let prefix_str = format!("{}/", directory.trim_matches('/'));
107 let mut stream = self.object_store.list(Some(&prefix));
108 let mut stems = BTreeSet::new();
109
110 while let Some(object) = stream.next().await {
111 let object = object?;
112 let path = object.location.to_string();
113
114 if let Some(relative_path) = path.strip_prefix(&prefix_str)
115 && let Some(stem) = relative_path.split('/').find(|segment| !segment.is_empty())
116 {
117 stems.insert(stem.to_string());
118 }
119 }
120
121 Ok(stems.into_iter().collect())
122 }
123
124 pub async fn list_files(
130 &self,
131 subdirectory: &str,
132 suffix: Option<&str>,
133 ) -> anyhow::Result<Vec<String>> {
134 let directory = make_object_store_path(&self.base_path, [subdirectory]);
135 let prefix = ObjectPath::from(format!("{}/", directory.trim_end_matches('/')));
136 let mut stream = self.object_store.list(Some(&prefix));
137 let mut files = Vec::new();
138
139 while let Some(object) = stream.next().await {
140 let object = object?;
141 let path = object.location.to_string();
142 if suffix.is_none_or(|suffix| path.ends_with(suffix)) {
143 files.push(path);
144 }
145 }
146
147 files.sort();
148 Ok(files)
149 }
150
151 pub async fn write_run_manifest(
157 &self,
158 kind: &str,
159 instance_id: &str,
160 status: &str,
161 empty: bool,
162 ) -> anyhow::Result<()> {
163 let manifest = RunManifest::new(kind, instance_id, status, empty);
164 let path = self.run_manifest_path(kind, instance_id);
165 let bytes = serde_json::to_vec(&manifest)?;
166 self.object_store.put(&path, bytes.into()).await?;
167 Ok(())
168 }
169
170 pub async fn write_current_run_manifest(
176 &self,
177 kind: &str,
178 instance_id: &str,
179 status: &str,
180 empty: bool,
181 ) -> anyhow::Result<()> {
182 let manifest = RunManifest::new(kind, instance_id, status, empty);
183 let path = ObjectPath::from(make_object_store_path(
184 &self.base_path,
185 [RUN_MANIFEST_FILENAME],
186 ));
187 let bytes = serde_json::to_vec(&manifest)?;
188 self.object_store.put(&path, bytes.into()).await?;
189 Ok(())
190 }
191
192 pub async fn read_run_manifest(
198 &self,
199 kind: &str,
200 instance_id: &str,
201 ) -> anyhow::Result<Option<RunManifest>> {
202 let path = self.run_manifest_path(kind, instance_id);
203 match self.object_store.get(&path).await {
204 Ok(result) => {
205 let bytes = result.bytes().await?;
206 Ok(Some(serde_json::from_slice(&bytes)?))
207 }
208 Err(ObjectStoreError::NotFound { .. }) => Ok(None),
209 Err(e) => Err(e.into()),
210 }
211 }
212
213 pub async fn list_run_ids(&self, kind: &str) -> anyhow::Result<Vec<String>> {
219 self.list_run_ids_for_kinds(&[kind]).await
220 }
221
222 pub async fn list_run_ids_for_kinds(&self, kinds: &[&str]) -> anyhow::Result<Vec<String>> {
228 let mut run_ids = BTreeSet::new();
229
230 for kind in kinds {
231 run_ids.extend(self.list_directory_stems(kind).await?);
232
233 for manifest in self.list_run_manifests(kind).await? {
234 run_ids.insert(manifest.instance_id);
235 }
236 }
237
238 Ok(run_ids.into_iter().collect())
239 }
240
241 async fn list_run_manifests(&self, kind: &str) -> anyhow::Result<Vec<RunManifest>> {
242 let files = self.list_files(kind, Some(RUN_MANIFEST_FILENAME)).await?;
243 let mut manifests = Vec::new();
244
245 for file in files {
246 match self.object_store.get(&ObjectPath::from(file)).await {
247 Ok(result) => {
248 let bytes = result.bytes().await?;
249 manifests.push(serde_json::from_slice(&bytes)?);
250 }
251 Err(ObjectStoreError::NotFound { .. }) => {}
252 Err(e) => return Err(e.into()),
253 }
254 }
255
256 Ok(manifests)
257 }
258
259 fn run_manifest_path(&self, kind: &str, instance_id: &str) -> ObjectPath {
260 ObjectPath::from(make_object_store_path(
261 &self.base_path,
262 [kind, instance_id, RUN_MANIFEST_FILENAME],
263 ))
264 }
265}
266
267pub fn datafusion_root_url(uri: &str) -> anyhow::Result<Url> {
273 if uri.starts_with("memory://") {
274 return Url::parse("memory:///")
275 .map_err(|e| anyhow::anyhow!("Invalid memory object-store root URL: {e}"));
276 }
277
278 let mut url = Url::parse(uri)?;
279 url.set_path("/");
280 url.set_query(None);
281 url.set_fragment(None);
282 Ok(url)
283}
284
285pub fn normalize_storage_location(path: &str) -> anyhow::Result<String> {
294 let uri = normalize_path_to_uri(path)?;
295 if uri.starts_with("file://") {
296 let path = std::path::absolute(file_uri_to_native_path(&uri))?;
297 let mut resolved = PathBuf::new();
298
299 for component in path.components() {
300 match component {
301 Component::CurDir => {}
302 component @ (Component::Prefix(_) | Component::RootDir) => resolved.push(component),
303 Component::ParentDir => {
304 resolved.pop();
305 }
306 component => {
307 resolved.push(component);
308 match fs::canonicalize(&resolved) {
309 Ok(canonical) => resolved = canonical,
310 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
311 Err(e) => return Err(e.into()),
312 }
313 }
314 }
315 }
316 return Ok(path_to_file_uri(&resolved.to_string_lossy())
317 .trim_end_matches('/')
318 .to_string());
319 }
320 let mut url = Url::parse(&uri)?;
321 if url.scheme() == "gcs" {
322 url.set_scheme("gs")
323 .map_err(|()| anyhow::anyhow!("invalid storage scheme"))?;
324 }
325 url.set_fragment(None);
326 url.set_query(None);
327 Ok(url.as_str().trim_end_matches('/').to_string())
328}
329
330#[cfg_attr(not(feature = "cloud"), allow(clippy::needless_pass_by_value))]
336pub fn create_storage_backend_from_path(
337 path: &str,
338 storage_options: Option<AHashMap<String, String>>,
339) -> anyhow::Result<StorageBackend> {
340 let uri = normalize_path_to_uri(path)?;
341 if uri.starts_with("memory://") {
342 return Ok(storage_backend(
343 Arc::new(object_store::memory::InMemory::new()),
344 String::new(),
345 uri,
346 ));
347 }
348
349 if uri.starts_with("file://") {
350 fs::create_dir_all(file_uri_to_native_path(&uri))?;
351 }
352 let (object_store, base_path, original_uri) =
353 crate::backend::parquet::io::create_object_store_from_path(&uri, storage_options)?;
354 Ok(storage_backend(object_store, base_path, original_uri))
355}
356
357fn storage_backend(
358 inner: Arc<dyn ObjectStore>,
359 base_path: String,
360 original_uri: String,
361) -> StorageBackend {
362 StorageBackend {
363 object_store: Arc::new(SortedListObjectStore { inner }),
364 base_path,
365 original_uri,
366 }
367}
368
369#[derive(Debug)]
370struct SortedListObjectStore {
371 inner: Arc<dyn ObjectStore>,
372}
373
374impl Display for SortedListObjectStore {
375 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376 self.inner.fmt(f)
377 }
378}
379
380#[async_trait::async_trait]
381impl ObjectStore for SortedListObjectStore {
382 async fn put_opts(
383 &self,
384 location: &ObjectPath,
385 payload: PutPayload,
386 opts: PutOptions,
387 ) -> ObjectStoreResult<PutResult> {
388 self.inner.put_opts(location, payload, opts).await
389 }
390
391 async fn put_multipart_opts(
392 &self,
393 location: &ObjectPath,
394 opts: PutMultipartOptions,
395 ) -> ObjectStoreResult<Box<dyn MultipartUpload>> {
396 self.inner.put_multipart_opts(location, opts).await
397 }
398
399 async fn get_opts(
400 &self,
401 location: &ObjectPath,
402 options: GetOptions,
403 ) -> ObjectStoreResult<GetResult> {
404 self.inner.get_opts(location, options).await
405 }
406
407 fn list(
408 &self,
409 prefix: Option<&ObjectPath>,
410 ) -> BoxStream<'static, ObjectStoreResult<ObjectMeta>> {
411 let inner = Arc::clone(&self.inner);
412 let prefix = prefix.cloned();
413 Box::pin(
414 futures::stream::once(async move {
415 let mut entries = inner.list(prefix.as_ref()).try_collect::<Vec<_>>().await?;
416 entries.sort_by(|left, right| left.location.cmp(&right.location));
417 Ok::<_, object_store::Error>(futures::stream::iter(entries.into_iter().map(Ok)))
418 })
419 .try_flatten(),
420 )
421 }
422
423 async fn list_with_delimiter(
424 &self,
425 prefix: Option<&ObjectPath>,
426 ) -> ObjectStoreResult<ListResult> {
427 let mut result = self.inner.list_with_delimiter(prefix).await?;
428 result
429 .common_prefixes
430 .sort_by(|left, right| left.as_ref().cmp(right.as_ref()));
431 result
432 .objects
433 .sort_by(|left, right| left.location.cmp(&right.location));
434 Ok(result)
435 }
436
437 fn delete_stream(
438 &self,
439 locations: BoxStream<'static, ObjectStoreResult<ObjectPath>>,
440 ) -> BoxStream<'static, ObjectStoreResult<ObjectPath>> {
441 self.inner.delete_stream(locations)
442 }
443
444 async fn copy_opts(
445 &self,
446 from: &ObjectPath,
447 to: &ObjectPath,
448 opts: CopyOptions,
449 ) -> ObjectStoreResult<()> {
450 self.inner.copy_opts(from, to, opts).await
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 #[cfg(feature = "cloud")]
457 use object_store::{ObjectStoreExt, path::Path as ObjectPath};
458 use rstest::rstest;
459 use tempfile::TempDir;
460
461 use super::*;
462
463 #[rstest]
464 fn storage_location_resolves_relative_file_uris() {
465 assert_eq!(
466 normalize_storage_location("file://nautilus-stream-location/new-destination").unwrap(),
467 normalize_storage_location("nautilus-stream-location/new-destination").unwrap(),
468 );
469 }
470
471 #[rstest]
472 fn datafusion_root_url_handles_memory_storage() {
473 let storage = create_storage_backend_from_path("memory://", None).unwrap();
474
475 assert_eq!(
476 storage.datafusion_root_url().unwrap().as_str(),
477 "memory:///"
478 );
479 }
480
481 #[rstest]
482 fn datafusion_root_url_handles_local_storage_paths() {
483 let temp_dir = TempDir::new().unwrap();
484 let storage =
485 create_storage_backend_from_path(temp_dir.path().to_str().unwrap(), None).unwrap();
486
487 assert_eq!(storage.datafusion_root_url().unwrap().as_str(), "file:///");
488 }
489
490 #[rstest]
491 fn datafusion_root_url_handles_file_uri_storage() {
492 let storage =
493 create_storage_backend_from_path("file:///tmp/nautilus-catalog", None).unwrap();
494
495 assert_eq!(storage.datafusion_root_url().unwrap().as_str(), "file:///");
496 }
497
498 #[rstest]
499 fn storage_backend_lists_directory_stems() {
500 let storage = create_storage_backend_from_path("memory://", None).unwrap();
501 futures::executor::block_on(async {
502 storage
503 .object_store
504 .put(
505 &ObjectPath::from("backtest/run-001/quotes.feather"),
506 b"quotes".to_vec().into(),
507 )
508 .await
509 .unwrap();
510 storage
511 .object_store
512 .put(
513 &ObjectPath::from("backtest/run-002/trades.feather"),
514 b"trades".to_vec().into(),
515 )
516 .await
517 .unwrap();
518 });
519
520 let runs = futures::executor::block_on(storage.list_directory_stems("backtest")).unwrap();
521
522 assert_eq!(runs, vec!["run-001".to_string(), "run-002".to_string()]);
523 }
524
525 #[rstest]
526 fn storage_backend_lists_files_with_suffix() {
527 let storage = create_storage_backend_from_path("memory://", None).unwrap();
528 futures::executor::block_on(async {
529 storage
530 .object_store
531 .put(
532 &ObjectPath::from("live/run-001/quotes.feather"),
533 b"quotes".to_vec().into(),
534 )
535 .await
536 .unwrap();
537 storage
538 .object_store
539 .put(
540 &ObjectPath::from("live/run-001/manifest.json"),
541 b"manifest".to_vec().into(),
542 )
543 .await
544 .unwrap();
545 });
546
547 let files =
548 futures::executor::block_on(storage.list_files("live/run-001", Some(".feather")))
549 .unwrap();
550
551 assert_eq!(files, vec!["live/run-001/quotes.feather".to_string()]);
552 }
553
554 #[rstest]
555 fn storage_backend_lists_manifest_only_empty_runs() {
556 let storage = create_storage_backend_from_path("memory://", None).unwrap();
557 futures::executor::block_on(async {
558 storage
559 .write_run_manifest("backtest", "empty-run-001", "completed", true)
560 .await
561 .unwrap();
562 });
563
564 let runs = futures::executor::block_on(storage.list_run_ids("backtest")).unwrap();
565 let manifest =
566 futures::executor::block_on(storage.read_run_manifest("backtest", "empty-run-001"))
567 .unwrap()
568 .unwrap();
569
570 assert_eq!(runs, vec!["empty-run-001".to_string()]);
571 assert_eq!(manifest.kind, "backtest");
572 assert_eq!(manifest.instance_id, "empty-run-001");
573 assert_eq!(manifest.status, "completed");
574 assert!(manifest.empty);
575 assert_eq!(manifest.schema_version, 1);
576 }
577
578 #[rstest]
579 fn storage_backend_unions_directory_and_manifest_runs() {
580 let storage = create_storage_backend_from_path("memory://", None).unwrap();
581 futures::executor::block_on(async {
582 storage
583 .object_store
584 .put(
585 &ObjectPath::from("live/run-with-data/quotes.feather"),
586 b"quotes".to_vec().into(),
587 )
588 .await
589 .unwrap();
590 storage
591 .write_run_manifest("live", "empty-run-001", "completed", true)
592 .await
593 .unwrap();
594 });
595
596 let runs = futures::executor::block_on(storage.list_run_ids("live")).unwrap();
597
598 assert_eq!(
599 runs,
600 vec!["empty-run-001".to_string(), "run-with-data".to_string()],
601 );
602 }
603
604 #[cfg(feature = "cloud")]
605 #[rstest]
606 fn datafusion_root_url_handles_cloud_storage_paths() {
607 let storage =
608 create_storage_backend_from_path("s3://nautilus-test/catalog/data", None).unwrap();
609
610 assert_eq!(storage.base_path, "catalog/data");
611 assert_eq!(
612 storage.datafusion_root_url().unwrap().as_str(),
613 "s3://nautilus-test/"
614 );
615 }
616}