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_common::enums::Environment;
29use nautilus_core::time::nanos_since_unix_epoch;
30use object_store::{
31 CopyOptions, Error as ObjectStoreError, GetOptions, GetResult, ListResult, MultipartUpload,
32 ObjectMeta, ObjectStore, ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload,
33 PutResult, RenameOptions, Result as ObjectStoreResult, path::Path as ObjectPath,
34};
35use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
36use url::Url;
37
38pub use crate::common::paths::normalize_path_to_uri;
39use crate::{
40 backend::parquet::io::{create_object_store_from_path, decode_object_store_segment},
41 common::paths::{
42 create_local_directory, environment_directory, environment_from_directory,
43 file_uri_to_native_path, make_object_store_path, path_to_file_uri,
44 },
45 writer::run::RunStatus,
46};
47
48pub const RUN_MANIFEST_FILENAME: &str = "_nautilus_run_manifest.json";
50
51#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
55pub struct RunManifest {
56 pub schema_version: u32,
57 #[serde(
58 rename = "kind",
59 serialize_with = "serialize_environment",
60 deserialize_with = "deserialize_environment"
61 )]
62 pub environment: Environment,
63 pub instance_id: String,
64 #[serde(
65 serialize_with = "serialize_run_status",
66 deserialize_with = "deserialize_run_status"
67 )]
68 pub status: RunStatus,
69 pub empty: bool,
70 pub created_ts: u64,
71}
72
73impl RunManifest {
74 #[must_use]
76 pub fn new(
77 environment: Environment,
78 instance_id: &str,
79 status: RunStatus,
80 empty: bool,
81 ) -> Self {
82 Self {
83 schema_version: 1,
84 environment,
85 instance_id: instance_id.to_string(),
86 status,
87 empty,
88 created_ts: nanos_since_unix_epoch(),
89 }
90 }
91}
92
93#[expect(
94 clippy::trivially_copy_pass_by_ref,
95 reason = "serde passes the field by reference"
96)]
97fn serialize_environment<S: Serializer>(
98 environment: &Environment,
99 serializer: S,
100) -> Result<S::Ok, S::Error> {
101 serializer.serialize_str(environment_directory(*environment))
102}
103
104fn deserialize_environment<'de, D: Deserializer<'de>>(
105 deserializer: D,
106) -> Result<Environment, D::Error> {
107 let directory = String::deserialize(deserializer)?;
108 environment_from_directory(&directory)
109 .ok_or_else(|| D::Error::custom(format!("unknown run environment '{directory}'")))
110}
111
112#[expect(
113 clippy::trivially_copy_pass_by_ref,
114 reason = "serde passes the field by reference"
115)]
116fn serialize_run_status<S: Serializer>(
117 status: &RunStatus,
118 serializer: S,
119) -> Result<S::Ok, S::Error> {
120 serializer.serialize_str(status.as_str())
121}
122
123fn deserialize_run_status<'de, D: Deserializer<'de>>(
124 deserializer: D,
125) -> Result<RunStatus, D::Error> {
126 Ok(RunStatus::from_storage_str(&String::deserialize(
127 deserializer,
128 )?))
129}
130
131#[derive(Clone)]
133pub struct StorageBackend {
134 pub object_store: Arc<dyn ObjectStore>,
136 pub base_path: String,
138 pub original_uri: String,
140}
141
142impl StorageBackend {
143 pub fn datafusion_root_url(&self) -> anyhow::Result<Url> {
154 datafusion_root_url(&self.original_uri)
155 }
156
157 pub async fn list_directory_stems(&self, subdirectory: &str) -> anyhow::Result<Vec<String>> {
167 let directory = make_object_store_path(&self.base_path, [subdirectory]);
168 let prefix = ObjectPath::from(format!("{}/", directory.trim_end_matches('/')));
169 let prefix_str = format!("{}/", directory.trim_matches('/'));
170 let mut stream = self.object_store.list(Some(&prefix));
171 let mut stems = BTreeSet::new();
172
173 while let Some(object) = stream.next().await {
174 let object = object?;
175 let path = object.location.to_string();
176
177 if let Some(relative_path) = path.strip_prefix(&prefix_str)
178 && let Some(stem) = relative_path.split('/').find(|segment| !segment.is_empty())
179 {
180 stems.insert(decode_object_store_segment(stem));
181 }
182 }
183
184 Ok(stems.into_iter().collect())
185 }
186
187 pub async fn list_files(
193 &self,
194 subdirectory: &str,
195 suffix: Option<&str>,
196 ) -> anyhow::Result<Vec<String>> {
197 let directory = make_object_store_path(&self.base_path, [subdirectory]);
198 let prefix = ObjectPath::from(format!("{}/", directory.trim_end_matches('/')));
199 let mut stream = self.object_store.list(Some(&prefix));
200 let mut files = Vec::new();
201
202 while let Some(object) = stream.next().await {
203 let object = object?;
204 let path = object.location.to_string();
205 if suffix.is_none_or(|suffix| path.ends_with(suffix)) {
206 files.push(path);
207 }
208 }
209
210 files.sort();
211 Ok(files)
212 }
213
214 pub async fn write_run_manifest(
220 &self,
221 environment: Environment,
222 instance_id: &str,
223 status: RunStatus,
224 empty: bool,
225 ) -> anyhow::Result<()> {
226 let manifest = RunManifest::new(environment, instance_id, status, empty);
227 let path = self.run_manifest_path(environment, instance_id);
228 let bytes = serde_json::to_vec(&manifest)?;
229 self.object_store.put(&path, bytes.into()).await?;
230 Ok(())
231 }
232
233 pub async fn write_current_run_manifest(
239 &self,
240 environment: Environment,
241 instance_id: &str,
242 status: RunStatus,
243 empty: bool,
244 ) -> anyhow::Result<()> {
245 let manifest = RunManifest::new(environment, instance_id, status, empty);
246 let path = ObjectPath::from(make_object_store_path(
247 &self.base_path,
248 [RUN_MANIFEST_FILENAME],
249 ));
250 let bytes = serde_json::to_vec(&manifest)?;
251 self.object_store.put(&path, bytes.into()).await?;
252 Ok(())
253 }
254
255 pub async fn read_run_manifest(
261 &self,
262 environment: Environment,
263 instance_id: &str,
264 ) -> anyhow::Result<Option<RunManifest>> {
265 self.read_run_manifest_at(&self.run_manifest_path(environment, instance_id))
266 .await
267 }
268
269 pub async fn list_run_ids(&self, environment: Environment) -> anyhow::Result<Vec<String>> {
275 let directory = environment_directory(environment);
276 let mut run_ids = self
277 .list_directory_stems(directory)
278 .await?
279 .into_iter()
280 .collect::<BTreeSet<_>>();
281
282 for manifest in self.list_run_manifests(directory).await? {
283 run_ids.insert(manifest.instance_id);
284 }
285
286 Ok(run_ids.into_iter().collect())
287 }
288
289 async fn list_run_manifests(&self, directory: &str) -> anyhow::Result<Vec<RunManifest>> {
290 let files = self
291 .list_files(directory, Some(RUN_MANIFEST_FILENAME))
292 .await?;
293 let mut manifests = Vec::new();
294
295 for file in files {
296 if let Some(manifest) = self.read_run_manifest_at(&ObjectPath::parse(file)?).await? {
297 manifests.push(manifest);
298 }
299 }
300
301 Ok(manifests)
302 }
303
304 async fn read_run_manifest_at(&self, path: &ObjectPath) -> anyhow::Result<Option<RunManifest>> {
305 match self.object_store.get(path).await {
306 Ok(result) => {
307 let bytes = result.bytes().await?;
308 Ok(Some(serde_json::from_slice(&bytes)?))
309 }
310 Err(ObjectStoreError::NotFound { .. }) => Ok(None),
311 Err(e) => Err(e.into()),
312 }
313 }
314
315 fn run_manifest_path(&self, environment: Environment, instance_id: &str) -> ObjectPath {
316 ObjectPath::from(make_object_store_path(
317 &self.base_path,
318 [
319 environment_directory(environment),
320 instance_id,
321 RUN_MANIFEST_FILENAME,
322 ],
323 ))
324 }
325}
326
327pub fn datafusion_root_url(uri: &str) -> anyhow::Result<Url> {
333 if uri.starts_with("memory://") {
334 return Url::parse("memory:///")
335 .map_err(|e| anyhow::anyhow!("Invalid memory object-store root URL: {e}"));
336 }
337
338 let mut url = Url::parse(uri)?;
339 url.set_path("/");
340 url.set_query(None);
341 url.set_fragment(None);
342 Ok(url)
343}
344
345pub fn normalize_storage_location(path: &str) -> anyhow::Result<String> {
354 let uri = normalize_path_to_uri(path)?;
355 if uri.starts_with("file://") {
356 let path = std::path::absolute(file_uri_to_native_path(&uri))?;
357 let mut resolved = PathBuf::new();
358
359 for component in path.components() {
360 match component {
361 Component::CurDir => {}
362 component @ (Component::Prefix(_) | Component::RootDir) => resolved.push(component),
363 Component::ParentDir => {
364 resolved.pop();
365 }
366 component => {
367 resolved.push(component);
368 match fs::canonicalize(&resolved) {
369 Ok(canonical) => resolved = canonical,
370 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
371 Err(e) => return Err(e.into()),
372 }
373 }
374 }
375 }
376
377 return Ok(path_to_file_uri(&resolved.to_string_lossy())
378 .trim_end_matches('/')
379 .to_string());
380 }
381
382 let mut url = Url::parse(&uri)?;
383 if url.scheme() == "gcs" {
384 url.set_scheme("gs")
385 .map_err(|()| anyhow::anyhow!("invalid storage scheme"))?;
386 }
387
388 url.set_fragment(None);
389 url.set_query(None);
390 Ok(url.as_str().trim_end_matches('/').to_string())
391}
392
393#[cfg_attr(not(feature = "cloud"), allow(clippy::needless_pass_by_value))]
399pub fn create_storage_backend_from_path(
400 path: &str,
401 storage_options: Option<AHashMap<String, String>>,
402) -> anyhow::Result<StorageBackend> {
403 let uri = normalize_path_to_uri(path)?;
404 if uri.starts_with("memory://") {
405 return Ok(storage_backend(
406 Arc::new(object_store::memory::InMemory::new()),
407 String::new(),
408 uri,
409 ));
410 }
411
412 create_local_directory(&uri)?;
413
414 let (object_store, base_path, original_uri) =
415 create_object_store_from_path(&uri, storage_options)?;
416 Ok(storage_backend(object_store, base_path, original_uri))
417}
418
419fn storage_backend(
420 inner: Arc<dyn ObjectStore>,
421 base_path: String,
422 original_uri: String,
423) -> StorageBackend {
424 StorageBackend {
425 object_store: Arc::new(SortedListObjectStore { inner }),
426 base_path,
427 original_uri,
428 }
429}
430
431#[derive(Debug)]
432struct SortedListObjectStore {
433 inner: Arc<dyn ObjectStore>,
434}
435
436impl Display for SortedListObjectStore {
437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438 self.inner.fmt(f)
439 }
440}
441
442#[async_trait::async_trait]
443impl ObjectStore for SortedListObjectStore {
444 async fn put_opts(
445 &self,
446 location: &ObjectPath,
447 payload: PutPayload,
448 opts: PutOptions,
449 ) -> ObjectStoreResult<PutResult> {
450 self.inner.put_opts(location, payload, opts).await
451 }
452
453 async fn put_multipart_opts(
454 &self,
455 location: &ObjectPath,
456 opts: PutMultipartOptions,
457 ) -> ObjectStoreResult<Box<dyn MultipartUpload>> {
458 self.inner.put_multipart_opts(location, opts).await
459 }
460
461 async fn get_opts(
462 &self,
463 location: &ObjectPath,
464 options: GetOptions,
465 ) -> ObjectStoreResult<GetResult> {
466 self.inner.get_opts(location, options).await
467 }
468
469 fn list(
470 &self,
471 prefix: Option<&ObjectPath>,
472 ) -> BoxStream<'static, ObjectStoreResult<ObjectMeta>> {
473 let inner = Arc::clone(&self.inner);
474 let prefix = prefix.cloned();
475 Box::pin(
476 futures::stream::once(async move {
477 let mut entries = inner.list(prefix.as_ref()).try_collect::<Vec<_>>().await?;
478 entries.sort_by(|left, right| left.location.cmp(&right.location));
479 Ok::<_, object_store::Error>(futures::stream::iter(entries.into_iter().map(Ok)))
480 })
481 .try_flatten(),
482 )
483 }
484
485 async fn list_with_delimiter(
486 &self,
487 prefix: Option<&ObjectPath>,
488 ) -> ObjectStoreResult<ListResult> {
489 let mut result = self.inner.list_with_delimiter(prefix).await?;
490 result
491 .common_prefixes
492 .sort_by(|left, right| left.as_ref().cmp(right.as_ref()));
493 result
494 .objects
495 .sort_by(|left, right| left.location.cmp(&right.location));
496 Ok(result)
497 }
498
499 fn delete_stream(
500 &self,
501 locations: BoxStream<'static, ObjectStoreResult<ObjectPath>>,
502 ) -> BoxStream<'static, ObjectStoreResult<ObjectPath>> {
503 self.inner.delete_stream(locations)
504 }
505
506 async fn copy_opts(
507 &self,
508 from: &ObjectPath,
509 to: &ObjectPath,
510 opts: CopyOptions,
511 ) -> ObjectStoreResult<()> {
512 self.inner.copy_opts(from, to, opts).await
513 }
514
515 async fn rename_opts(
516 &self,
517 from: &ObjectPath,
518 to: &ObjectPath,
519 opts: RenameOptions,
520 ) -> ObjectStoreResult<()> {
521 self.inner.rename_opts(from, to, opts).await
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use std::sync::atomic::{AtomicUsize, Ordering};
528
529 use object_store::memory::InMemory;
530 use rstest::rstest;
531 use tempfile::TempDir;
532
533 use super::*;
534
535 #[derive(Debug)]
537 struct RenameCountingStore {
538 inner: InMemory,
539 renames: AtomicUsize,
540 }
541
542 impl Display for RenameCountingStore {
543 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544 f.write_str("rename-counting")
545 }
546 }
547
548 #[async_trait::async_trait]
549 impl ObjectStore for RenameCountingStore {
550 async fn put_opts(
551 &self,
552 location: &ObjectPath,
553 payload: PutPayload,
554 opts: PutOptions,
555 ) -> ObjectStoreResult<PutResult> {
556 self.inner.put_opts(location, payload, opts).await
557 }
558
559 async fn put_multipart_opts(
560 &self,
561 location: &ObjectPath,
562 opts: PutMultipartOptions,
563 ) -> ObjectStoreResult<Box<dyn MultipartUpload>> {
564 self.inner.put_multipart_opts(location, opts).await
565 }
566
567 async fn get_opts(
568 &self,
569 location: &ObjectPath,
570 options: GetOptions,
571 ) -> ObjectStoreResult<GetResult> {
572 self.inner.get_opts(location, options).await
573 }
574
575 fn list(
576 &self,
577 prefix: Option<&ObjectPath>,
578 ) -> BoxStream<'static, ObjectStoreResult<ObjectMeta>> {
579 self.inner.list(prefix)
580 }
581
582 async fn list_with_delimiter(
583 &self,
584 prefix: Option<&ObjectPath>,
585 ) -> ObjectStoreResult<ListResult> {
586 self.inner.list_with_delimiter(prefix).await
587 }
588
589 fn delete_stream(
590 &self,
591 locations: BoxStream<'static, ObjectStoreResult<ObjectPath>>,
592 ) -> BoxStream<'static, ObjectStoreResult<ObjectPath>> {
593 self.inner.delete_stream(locations)
594 }
595
596 async fn copy_opts(
597 &self,
598 from: &ObjectPath,
599 to: &ObjectPath,
600 opts: CopyOptions,
601 ) -> ObjectStoreResult<()> {
602 self.inner.copy_opts(from, to, opts).await
603 }
604
605 async fn rename_opts(
606 &self,
607 from: &ObjectPath,
608 to: &ObjectPath,
609 opts: RenameOptions,
610 ) -> ObjectStoreResult<()> {
611 self.renames.fetch_add(1, Ordering::Relaxed);
612 self.inner.rename_opts(from, to, opts).await
613 }
614 }
615
616 #[rstest]
617 fn storage_backend_forwards_rename_to_inner_store() {
618 let inner = Arc::new(RenameCountingStore {
619 inner: InMemory::new(),
620 renames: AtomicUsize::new(0),
621 });
622
623 let storage = storage_backend(inner.clone(), String::new(), "memory://".to_string());
624 let from = ObjectPath::from("backtest/run-001/quotes.feather");
625 let to = ObjectPath::from("backtest/run-001/quotes-renamed.feather");
626
627 let (moved, source) = futures::executor::block_on(async {
628 storage
629 .object_store
630 .put(&from, b"quotes".to_vec().into())
631 .await
632 .unwrap();
633 storage.object_store.rename(&from, &to).await.unwrap();
634 let moved = storage
635 .object_store
636 .get(&to)
637 .await
638 .unwrap()
639 .bytes()
640 .await
641 .unwrap();
642 (moved, storage.object_store.head(&from).await)
643 });
644
645 assert_eq!(inner.renames.load(Ordering::Relaxed), 1);
646 assert_eq!(moved.as_ref(), b"quotes");
647 assert!(matches!(source, Err(ObjectStoreError::NotFound { .. })));
648 }
649
650 #[rstest]
651 fn storage_location_resolves_relative_file_uris() {
652 assert_eq!(
653 normalize_storage_location("file://nautilus-stream-location/new-destination").unwrap(),
654 normalize_storage_location("nautilus-stream-location/new-destination").unwrap(),
655 );
656 }
657
658 #[rstest]
659 fn datafusion_root_url_handles_memory_storage() {
660 let storage = create_storage_backend_from_path("memory://", None).unwrap();
661
662 assert_eq!(
663 storage.datafusion_root_url().unwrap().as_str(),
664 "memory:///"
665 );
666 }
667
668 #[rstest]
669 fn datafusion_root_url_handles_local_storage_paths() {
670 let temp_dir = TempDir::new().unwrap();
671 let storage =
672 create_storage_backend_from_path(temp_dir.path().to_str().unwrap(), None).unwrap();
673
674 assert_eq!(storage.datafusion_root_url().unwrap().as_str(), "file:///");
675 }
676
677 #[rstest]
678 fn datafusion_root_url_handles_file_uri_storage() {
679 let storage =
680 create_storage_backend_from_path("file:///tmp/nautilus-catalog", None).unwrap();
681
682 assert_eq!(storage.datafusion_root_url().unwrap().as_str(), "file:///");
683 }
684
685 #[rstest]
686 fn storage_backend_lists_directory_stems() {
687 let storage = create_storage_backend_from_path("memory://", None).unwrap();
688 futures::executor::block_on(async {
689 storage
690 .object_store
691 .put(
692 &ObjectPath::from("backtest/run-001/quotes.feather"),
693 b"quotes".to_vec().into(),
694 )
695 .await
696 .unwrap();
697 storage
698 .object_store
699 .put(
700 &ObjectPath::from("backtest/run-002/trades.feather"),
701 b"trades".to_vec().into(),
702 )
703 .await
704 .unwrap();
705 });
706
707 let runs = futures::executor::block_on(storage.list_directory_stems("backtest")).unwrap();
708
709 assert_eq!(runs, vec!["run-001".to_string(), "run-002".to_string()]);
710 }
711
712 #[rstest]
713 fn storage_backend_lists_files_with_suffix() {
714 let storage = create_storage_backend_from_path("memory://", None).unwrap();
715 futures::executor::block_on(async {
716 storage
717 .object_store
718 .put(
719 &ObjectPath::from("live/run-001/quotes.feather"),
720 b"quotes".to_vec().into(),
721 )
722 .await
723 .unwrap();
724 storage
725 .object_store
726 .put(
727 &ObjectPath::from("live/run-001/manifest.json"),
728 b"manifest".to_vec().into(),
729 )
730 .await
731 .unwrap();
732 });
733
734 let files =
735 futures::executor::block_on(storage.list_files("live/run-001", Some(".feather")))
736 .unwrap();
737
738 assert_eq!(files, vec!["live/run-001/quotes.feather".to_string()]);
739 }
740
741 #[rstest]
742 fn storage_backend_lists_manifest_only_empty_runs() {
743 let storage = create_storage_backend_from_path("memory://", None).unwrap();
744 futures::executor::block_on(async {
745 storage
746 .write_run_manifest(
747 Environment::Backtest,
748 "empty-run-001",
749 RunStatus::Completed,
750 true,
751 )
752 .await
753 .unwrap();
754 });
755
756 let runs =
757 futures::executor::block_on(storage.list_run_ids(Environment::Backtest)).unwrap();
758 let manifest = futures::executor::block_on(
759 storage.read_run_manifest(Environment::Backtest, "empty-run-001"),
760 )
761 .unwrap()
762 .unwrap();
763
764 assert_eq!(runs, vec!["empty-run-001".to_string()]);
765 assert_eq!(manifest.environment, Environment::Backtest);
766 assert_eq!(manifest.instance_id, "empty-run-001");
767 assert_eq!(manifest.status, RunStatus::Completed);
768 assert!(manifest.empty);
769 assert_eq!(manifest.schema_version, 1);
770 }
771
772 #[rstest]
773 fn storage_backend_lists_manifest_of_run_with_encoded_name() {
774 let storage = create_storage_backend_from_path("memory://", None).unwrap();
775 futures::executor::block_on(storage.write_run_manifest(
776 Environment::Backtest,
777 "run-é",
778 RunStatus::Completed,
779 true,
780 ))
781 .unwrap();
782
783 let manifests = futures::executor::block_on(
784 storage.list_run_manifests(environment_directory(Environment::Backtest)),
785 )
786 .unwrap();
787 let manifest =
788 futures::executor::block_on(storage.read_run_manifest(Environment::Backtest, "run-é"))
789 .unwrap()
790 .unwrap();
791
792 assert_eq!(manifests, vec![manifest]);
793 }
794
795 #[rstest]
796 fn storage_backend_lists_run_ids_with_encoded_name() {
797 let storage = create_storage_backend_from_path("memory://", None).unwrap();
798 futures::executor::block_on(storage.write_run_manifest(
799 Environment::Backtest,
800 "run-é",
801 RunStatus::Completed,
802 true,
803 ))
804 .unwrap();
805
806 let runs =
807 futures::executor::block_on(storage.list_run_ids(Environment::Backtest)).unwrap();
808
809 assert_eq!(runs, vec!["run-é".to_string()]);
810 }
811
812 #[rstest]
813 fn read_run_manifest_returns_none_for_missing_run() {
814 let storage = create_storage_backend_from_path("memory://", None).unwrap();
815
816 let manifest = futures::executor::block_on(
817 storage.read_run_manifest(Environment::Backtest, "missing-run"),
818 )
819 .unwrap();
820
821 assert_eq!(manifest, None);
822 }
823
824 #[rstest]
825 fn storage_backend_unions_directory_and_manifest_runs() {
826 let storage = create_storage_backend_from_path("memory://", None).unwrap();
827 futures::executor::block_on(async {
828 storage
829 .object_store
830 .put(
831 &ObjectPath::from("live/run-with-data/quotes.feather"),
832 b"quotes".to_vec().into(),
833 )
834 .await
835 .unwrap();
836 storage
837 .write_run_manifest(
838 Environment::Live,
839 "empty-run-001",
840 RunStatus::Completed,
841 true,
842 )
843 .await
844 .unwrap();
845 });
846
847 let runs = futures::executor::block_on(storage.list_run_ids(Environment::Live)).unwrap();
848
849 assert_eq!(
850 runs,
851 vec!["empty-run-001".to_string(), "run-with-data".to_string()],
852 );
853 }
854
855 #[rstest]
856 #[case::gcs_alias("gcs://bucket/catalog/", "gs://bucket/catalog")]
857 #[case::query_and_fragment("s3://bucket/catalog?versionId=1#part", "s3://bucket/catalog")]
858 fn storage_location_normalizes_remote_uris(#[case] path: &str, #[case] expected: &str) {
859 assert_eq!(normalize_storage_location(path).unwrap(), expected);
860 }
861
862 #[cfg(unix)]
863 #[rstest]
864 fn storage_location_resolves_symlinks_and_parent_segments() {
865 let temp_dir = TempDir::new().unwrap();
866 let nested = temp_dir.path().join("source").join("nested");
867 fs::create_dir_all(&nested).unwrap();
868 let link = temp_dir.path().join("link");
869 std::os::unix::fs::symlink(&nested, &link).unwrap();
870 let expected = format!(
871 "file://{}/new-destination",
872 fs::canonicalize(&nested).unwrap().display()
873 );
874
875 let through_link =
876 normalize_storage_location(link.join("new-destination").to_str().unwrap()).unwrap();
877 let through_parent = normalize_storage_location(
878 temp_dir
879 .path()
880 .join("source/missing/../nested/new-destination")
881 .to_str()
882 .unwrap(),
883 )
884 .unwrap();
885
886 assert_eq!(through_link, expected);
887 assert_eq!(through_parent, expected);
888 }
889
890 #[cfg(unix)]
891 #[rstest]
892 fn storage_location_propagates_errors_other_than_missing_paths() {
893 let temp_dir = TempDir::new().unwrap();
894 let file = temp_dir.path().join("file");
895 fs::write(&file, b"data").unwrap();
896
897 let error = normalize_storage_location(file.join("child").to_str().unwrap()).unwrap_err();
898
899 assert_eq!(
900 error.downcast_ref::<io::Error>().unwrap().kind(),
901 io::ErrorKind::NotADirectory,
902 );
903 }
904
905 #[rstest]
906 fn current_run_manifest_is_readable_from_catalog_root() {
907 let inner: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
908 let run = storage_backend(
909 inner.clone(),
910 "backtest/run-001".to_string(),
911 "memory://".to_string(),
912 );
913 let root = storage_backend(inner, String::new(), "memory://".to_string());
914
915 futures::executor::block_on(run.write_current_run_manifest(
916 Environment::Backtest,
917 "run-001",
918 RunStatus::Completed,
919 false,
920 ))
921 .unwrap();
922 let manifest =
923 futures::executor::block_on(root.read_run_manifest(Environment::Backtest, "run-001"))
924 .unwrap()
925 .unwrap();
926
927 assert_eq!(
928 manifest,
929 RunManifest {
930 schema_version: 1,
931 environment: Environment::Backtest,
932 instance_id: "run-001".to_string(),
933 status: RunStatus::Completed,
934 empty: false,
935 created_ts: manifest.created_ts,
936 },
937 );
938 }
939
940 #[rstest]
941 fn run_manifest_stores_environment_folder_and_status_names() {
942 let manifest = RunManifest {
943 schema_version: 1,
944 environment: Environment::Sandbox,
945 instance_id: "run-001".to_string(),
946 status: RunStatus::InProgress,
947 empty: true,
948 created_ts: 7,
949 };
950
951 let json = serde_json::to_value(&manifest).unwrap();
952 let decoded: RunManifest = serde_json::from_value(json.clone()).unwrap();
953
954 assert_eq!(
955 json,
956 serde_json::json!({
957 "schema_version": 1,
958 "kind": "sandbox",
959 "instance_id": "run-001",
960 "status": "in_progress",
961 "empty": true,
962 "created_ts": 7,
963 }),
964 );
965 assert_eq!(decoded, manifest);
966 }
967
968 #[rstest]
969 fn run_manifest_rejects_unknown_environment_folder() {
970 let error = serde_json::from_value::<RunManifest>(serde_json::json!({
971 "schema_version": 1,
972 "kind": "paper",
973 "instance_id": "run-001",
974 "status": "completed",
975 "empty": false,
976 "created_ts": 7,
977 }))
978 .unwrap_err();
979
980 assert_eq!(error.to_string(), "unknown run environment 'paper'");
981 }
982
983 #[rstest]
984 fn run_ids_reject_unreadable_manifest() {
985 let storage = create_storage_backend_from_path("memory://", None).unwrap();
986 futures::executor::block_on(storage.object_store.put(
987 &ObjectPath::from(format!("backtest/run-001/{RUN_MANIFEST_FILENAME}")),
988 b"{".to_vec().into(),
989 ))
990 .unwrap();
991
992 let error =
993 futures::executor::block_on(storage.list_run_ids(Environment::Backtest)).unwrap_err();
994
995 assert_eq!(
996 error.to_string(),
997 "EOF while parsing an object at line 1 column 1"
998 );
999 }
1000
1001 #[cfg(feature = "cloud")]
1002 #[rstest]
1003 fn datafusion_root_url_handles_cloud_storage_paths() {
1004 let storage =
1005 create_storage_backend_from_path("s3://nautilus-test/catalog/data", None).unwrap();
1006
1007 assert_eq!(storage.base_path, "catalog/data");
1008 assert_eq!(
1009 storage.datafusion_root_url().unwrap().as_str(),
1010 "s3://nautilus-test/"
1011 );
1012 }
1013
1014 #[cfg(feature = "cloud")]
1015 #[rstest]
1016 fn create_storage_backend_rejects_base_path_that_paths_change() {
1017 let uri = "s3://nautilus-test/préfix";
1018
1019 let error = create_storage_backend_from_path(uri, None)
1020 .err()
1021 .unwrap()
1022 .to_string();
1023
1024 assert_eq!(
1025 error,
1026 format!(
1027 "Storage URI '{uri}' has a base path that URL parsing or object-store path \
1028 encoding changes; use a base path without spaces, non-ASCII, or reserved \
1029 characters"
1030 )
1031 );
1032 }
1033}