1use std::{
19 any::Any,
20 panic::{self, AssertUnwindSafe},
21 path::PathBuf,
22 sync::{
23 Arc, Mutex,
24 atomic::{AtomicUsize, Ordering},
25 mpsc::{self, Receiver, SyncSender, TryRecvError},
26 },
27 thread::JoinHandle,
28 time::Duration,
29};
30
31use ahash::AHashSet;
32use nautilus_common::live::block_on_nautilus_with;
33use nautilus_core::UnixNanos;
34use nautilus_model::data::Data;
35
36use crate::{
37 common::{
38 conversion::FeatherConversionSummary, paths::normalize_path_separators,
39 storage::StorageBackend,
40 },
41 writer::{
42 run::{FeatherSessionSource, RunStatus},
43 traits::StreamingSink,
44 },
45};
46
47pub(crate) fn list_session_feather_files(
48 storage: &StorageBackend,
49 kind: &str,
50 instance_id: &str,
51) -> anyhow::Result<Vec<String>> {
52 let run_directory = format!("{kind}/{instance_id}");
53 let mut files =
54 block_on_nautilus_with(|| storage.list_files(&run_directory, Some(".feather")))?;
55 files.sort();
56 Ok(files)
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub(crate) struct PromotionSession {
61 pub(crate) catalog_uri: String,
62 pub(crate) kind: String,
63 pub(crate) instance_id: String,
64}
65
66impl PromotionSession {
67 pub(crate) fn from_uri(uri: &str) -> Option<Self> {
68 if let Ok(url) = url::Url::parse(uri)
69 && let Some(session) = Self::from_url(url)
70 {
71 return Some(session);
72 }
73 Self::from_local_path(uri)
74 }
75
76 fn from_url(mut url: url::Url) -> Option<Self> {
77 let components = url
78 .path()
79 .trim_matches('/')
80 .split('/')
81 .filter(|component| !component.is_empty())
82 .map(str::to_string)
83 .collect::<Vec<_>>();
84 let instance_id = components.last()?.clone();
85 let kind = components.get(components.len().checked_sub(2)?)?.clone();
86 if !is_run_kind(&kind) {
87 return None;
88 }
89
90 let catalog_segments = &components[..components.len().saturating_sub(2)];
91 let catalog_path = if catalog_segments.is_empty() {
92 "/".to_string()
93 } else {
94 format!("/{}", catalog_segments.join("/"))
95 };
96 url.set_path(&catalog_path);
97 url.set_query(None);
98 url.set_fragment(None);
99
100 Some(Self {
101 catalog_uri: url.to_string(),
102 kind,
103 instance_id,
104 })
105 }
106
107 fn from_local_path(path: &str) -> Option<Self> {
108 let normalized = normalize_path_separators(path);
109 let path = PathBuf::from(&normalized);
110 let instance_id = path.file_name()?.to_string_lossy().to_string();
111 let kind = path.parent()?.file_name()?.to_string_lossy().to_string();
112 if !is_run_kind(&kind) {
113 return None;
114 }
115 let catalog_uri = path.parent()?.parent()?.to_string_lossy().to_string();
116 Some(Self {
117 catalog_uri,
118 kind,
119 instance_id,
120 })
121 }
122}
123
124fn is_run_kind(kind: &str) -> bool {
125 matches!(kind, "backtest" | "live" | "sandbox")
126}
127
128pub(crate) trait PromotionSink: Send {
129 fn stage_data(&mut self, data: Data) -> anyhow::Result<()>;
130
131 fn stage_batch(&mut self, data: Vec<Data>) -> anyhow::Result<()>;
132
133 fn stage_any(&mut self, message: &dyn Any) -> anyhow::Result<bool>;
134
135 fn flush_staging(&mut self) -> anyhow::Result<()>;
136
137 fn close_staging(&mut self) -> anyhow::Result<()>;
138
139 fn mark_run_non_empty(&mut self) -> anyhow::Result<()>;
140
141 fn maybe_promote_by_period(&mut self) -> anyhow::Result<()>;
142
143 fn wait_for_background_promotions(&mut self) -> anyhow::Result<Vec<FeatherConversionSummary>>;
144
145 fn stop_periodic_promotion(&mut self);
146
147 fn take_pending_error(&mut self) -> anyhow::Result<()>;
148
149 fn should_promote_on_flush(&self) -> bool;
150
151 fn should_promote_on_close(&self) -> bool;
152
153 fn promote(&mut self) -> anyhow::Result<Vec<FeatherConversionSummary>>;
154
155 fn record_completed(&mut self);
156}
157
158impl<T> StreamingSink for T
159where
160 T: PromotionSink + std::fmt::Debug,
161{
162 fn write_data(&mut self, data: Data) -> anyhow::Result<()> {
163 self.stage_data(data)?;
164 self.mark_run_non_empty()?;
165 self.maybe_promote_by_period()
166 }
167
168 fn write_batch(&mut self, data: Vec<Data>) -> anyhow::Result<()> {
169 if data.is_empty() {
170 return Ok(());
171 }
172 self.stage_batch(data)?;
173 self.mark_run_non_empty()?;
174 self.maybe_promote_by_period()
175 }
176
177 fn write_any(&mut self, message: &dyn Any) -> anyhow::Result<bool> {
178 let handled = self.stage_any(message)?;
179
180 if handled {
181 self.mark_run_non_empty()?;
182 self.maybe_promote_by_period()?;
183 }
184 Ok(handled)
185 }
186
187 fn flush(&mut self) -> anyhow::Result<()> {
188 self.flush_staging()?;
189 let background_result = self.wait_for_background_promotions();
190
191 let promotion_result = if self.should_promote_on_flush() {
192 self.promote().map(|_| ())
193 } else {
194 Ok(())
195 };
196
197 let pending_result = self.take_pending_error();
198
199 background_result?;
200 promotion_result?;
201 pending_result
202 }
203
204 fn close(&mut self) -> anyhow::Result<()> {
205 self.stop_periodic_promotion();
206 self.close_staging()?;
207 let background_result = self.wait_for_background_promotions();
208 let pending_result = self.take_pending_error();
209
210 let promotion_result = if self.should_promote_on_close() {
211 self.promote().map(|_| ())
212 } else if background_result.is_ok() && pending_result.is_ok() {
213 self.record_completed();
214 Ok(())
215 } else {
216 Ok(())
217 };
218
219 background_result?;
220 promotion_result?;
221 pending_result
222 }
223}
224
225pub(crate) trait PromotionBackend: Send + 'static {
226 type Source: Send + 'static;
227
228 const NAME: &'static str;
229
230 fn convert_file(
231 &mut self,
232 source: &Self::Source,
233 file: &str,
234 use_ts_event_for_ts_init: bool,
235 record_promoted: bool,
236 ) -> anyhow::Result<Option<FeatherConversionSummary>>;
237
238 fn delete_file(&mut self, source: &Self::Source, file: &str) -> anyhow::Result<()>;
239}
240
241pub(crate) struct PromotionScope {
242 pub(crate) source: FeatherSessionSource,
243 pub(crate) staging_uri: String,
244 pub(crate) files: Vec<String>,
245 pub(crate) use_ts_event_for_ts_init: bool,
246 pub(crate) delete_feather_after_commit: bool,
247}
248
249pub(crate) trait StagedPromotionBackend:
250 PromotionBackend<Source = FeatherSessionSource> + Sized
251{
252 const REQUIRES_SESSION: bool = false;
253 const RECORDS_PROMOTED_ATOMICALLY: bool = false;
254
255 fn into_work(self, scope: PromotionScope) -> PromotionWork<Self> {
256 PromotionWork::new(
257 self,
258 scope.source,
259 scope.staging_uri,
260 scope.files,
261 scope.use_ts_event_for_ts_init,
262 scope.delete_feather_after_commit,
263 )
264 }
265
266 fn record_run_state(
267 &mut self,
268 _source: &FeatherSessionSource,
269 _staging_uri: &str,
270 _status: RunStatus,
271 _empty: bool,
272 _error: Option<&str>,
273 ) -> anyhow::Result<()> {
274 Ok(())
275 }
276
277 fn delete_recorded_leftovers(
284 &mut self,
285 _source: &FeatherSessionSource,
286 _files: &[String],
287 ) -> anyhow::Result<Vec<String>> {
288 Ok(Vec::new())
289 }
290}
291
292pub(crate) struct PromotionWork<B>
293where
294 B: StagedPromotionBackend,
295{
296 backend: B,
297 source: B::Source,
298 staging_uri: String,
299 files: Vec<String>,
300 use_ts_event_for_ts_init: bool,
301 delete_feather_after_commit: bool,
302}
303
304impl<B> PromotionWork<B>
305where
306 B: StagedPromotionBackend,
307{
308 pub(crate) fn new(
309 backend: B,
310 source: B::Source,
311 staging_uri: String,
312 files: Vec<String>,
313 use_ts_event_for_ts_init: bool,
314 delete_feather_after_commit: bool,
315 ) -> Self {
316 Self {
317 backend,
318 source,
319 staging_uri,
320 files,
321 use_ts_event_for_ts_init,
322 delete_feather_after_commit,
323 }
324 }
325
326 pub(crate) fn execute(self) -> PromotionResult {
327 let files = self.files.clone();
328
329 match panic::catch_unwind(AssertUnwindSafe(|| self.execute_inner())) {
330 Ok(result) => result,
331 Err(payload) => PromotionResult {
332 files,
333 committed_paths: Vec::new(),
334 deleted_paths: Vec::new(),
335 partial_converted: Vec::new(),
336 run_state_recorded: false,
337 converted: Err(anyhow::anyhow!(
338 "{} promotion worker panicked: {}",
339 B::NAME,
340 panic_payload_message(payload.as_ref()),
341 )),
342 },
343 }
344 }
345
346 pub(crate) fn files(&self) -> &[String] {
347 &self.files
348 }
349
350 #[expect(
351 clippy::too_many_lines,
352 reason = "the promotion loop keeps conversion, promoted-record fallback, and deletion accounting together"
353 )]
354 fn execute_inner(mut self) -> PromotionResult {
355 let mut converted = Vec::new();
356 let mut committed_paths = Vec::new();
357 let mut deleted_paths = Vec::new();
358 let mut errors = Vec::new();
359 let mut promoted_recorded = false;
360 let mut record_error = None;
361
362 let leftovers = match self
365 .backend
366 .delete_recorded_leftovers(&self.source, &self.files)
367 {
368 Ok(leftovers) => leftovers,
369 Err(e) => {
370 log::warn!(
371 "{} recorded-leftover cleanup failed; continuing with promotion: {e:#}",
372 B::NAME,
373 );
374 Vec::new()
375 }
376 };
377 deleted_paths.extend(leftovers.iter().cloned());
378 let convert_files = self
379 .files
380 .iter()
381 .filter(|file| !leftovers.contains(file))
382 .cloned()
383 .collect::<Vec<_>>();
384
385 for (index, file) in convert_files.iter().enumerate() {
386 let record_promoted = index + 1 == convert_files.len() && errors.is_empty();
387
388 match self.backend.convert_file(
389 &self.source,
390 file,
391 self.use_ts_event_for_ts_init,
392 record_promoted,
393 ) {
394 Ok(Some(summary)) => {
395 if B::RECORDS_PROMOTED_ATOMICALLY && record_promoted {
396 if summary.native_version.is_some() {
397 promoted_recorded = true;
398 } else {
399 match self.backend.record_run_state(
405 &self.source,
406 &self.staging_uri,
407 RunStatus::Promoted,
408 false,
409 None,
410 ) {
411 Ok(()) => promoted_recorded = true,
412 Err(e) => record_error = Some(e),
413 }
414 }
415 }
416 converted.push(summary);
417 committed_paths.push(file.clone());
418 if record_error.is_some() {
419 } else if self.delete_feather_after_commit
422 && let Err(e) = self.backend.delete_file(&self.source, file)
423 {
424 errors.push(format!(
425 "{:#}",
426 e.context(format!(
427 "{} source deletion failed for Feather file '{file}'",
428 B::NAME,
429 )),
430 ));
431 } else if self.delete_feather_after_commit {
432 deleted_paths.push(file.clone());
433 }
434 }
435 Ok(None) => {}
436 Err(e) => {
437 let e = e.context(format!(
438 "{} promotion failed for Feather file '{file}'",
439 B::NAME,
440 ));
441 errors.push(format!("{e:#}"));
442 }
443 }
444 }
445
446 if let Some(e) = record_error {
447 return PromotionResult {
448 files: self.files,
449 committed_paths,
450 deleted_paths,
451 partial_converted: converted,
452 run_state_recorded: false,
453 converted: Err(
454 e.context(format!("{} failed to record promoted run state", B::NAME))
455 ),
456 };
457 }
458
459 if !errors.is_empty() {
460 let error = errors.join("; ");
461 let run_state_recorded = match self.backend.record_run_state(
462 &self.source,
463 &self.staging_uri,
464 RunStatus::Failed,
465 false,
466 Some(&error),
467 ) {
468 Ok(()) => true,
469 Err(state_error) => {
470 errors.push(format!(
471 "{} failed to record failed run state: {state_error}",
472 B::NAME,
473 ));
474 false
475 }
476 };
477 return PromotionResult {
478 files: self.files,
479 committed_paths,
480 deleted_paths,
481 partial_converted: converted,
482 run_state_recorded,
483 converted: Err(anyhow::anyhow!(errors.join("; "))),
484 };
485 }
486
487 if !converted.is_empty()
488 && !B::RECORDS_PROMOTED_ATOMICALLY
489 && let Err(e) = self.backend.record_run_state(
490 &self.source,
491 &self.staging_uri,
492 RunStatus::Promoted,
493 false,
494 None,
495 )
496 {
497 return PromotionResult {
498 files: self.files,
499 committed_paths,
500 deleted_paths,
501 partial_converted: converted,
502 run_state_recorded: false,
503 converted: Err(
504 e.context(format!("{} failed to record promoted run state", B::NAME))
505 ),
506 };
507 }
508 let run_state_recorded = if B::RECORDS_PROMOTED_ATOMICALLY {
509 promoted_recorded
510 } else {
511 !converted.is_empty()
512 };
513 PromotionResult {
514 files: self.files,
515 committed_paths,
516 deleted_paths,
517 partial_converted: Vec::new(),
518 run_state_recorded,
519 converted: Ok(converted),
520 }
521 }
522}
523
524pub struct PromotionResult {
529 pub(crate) files: Vec<String>,
530 pub(crate) committed_paths: Vec<String>,
531 pub(crate) deleted_paths: Vec<String>,
532 pub(crate) partial_converted: Vec<FeatherConversionSummary>,
533 pub(crate) run_state_recorded: bool,
534 pub(crate) converted: anyhow::Result<Vec<FeatherConversionSummary>>,
535}
536
537fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
538 if let Some(message) = payload.downcast_ref::<&'static str>() {
539 return (*message).to_string();
540 }
541
542 if let Some(message) = payload.downcast_ref::<String>() {
543 return message.clone();
544 }
545 "unknown panic payload".to_string()
546}
547
548pub(crate) trait PromotionTask: Send + 'static {
549 type Output: Send + 'static;
550
551 fn execute(self) -> Self::Output;
552}
553
554pub(crate) struct PromotionTimer {
555 stop_tx: SyncSender<()>,
556 handle: Option<JoinHandle<()>>,
557}
558
559impl PromotionTimer {
560 pub(crate) fn spawn<F>(
561 thread_name: &str,
562 interval_ms: u64,
563 mut callback: F,
564 ) -> anyhow::Result<Self>
565 where
566 F: FnMut() + Send + 'static,
567 {
568 anyhow::ensure!(interval_ms > 0, "Promotion timer interval must be positive");
569 let (stop_tx, stop_rx) = mpsc::sync_channel(1);
570 let interval = Duration::from_millis(interval_ms);
571 let handle = std::thread::Builder::new() .name(thread_name.to_string())
573 .spawn(move || {
574 loop {
575 match stop_rx.recv_timeout(interval) {
576 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
577 Err(mpsc::RecvTimeoutError::Timeout) => callback(),
578 }
579 }
580 })?;
581 Ok(Self {
582 stop_tx,
583 handle: Some(handle),
584 })
585 }
586
587 pub(crate) fn stop(&mut self) {
588 let _ = self.stop_tx.try_send(());
589
590 if let Some(handle) = self.handle.take()
591 && let Err(e) = handle.join()
592 {
593 log::warn!("Promotion timer panicked: {e:?}");
594 }
595 }
596}
597
598impl Drop for PromotionTimer {
599 fn drop(&mut self) {
600 self.stop();
601 }
602}
603
604enum PromotionMessage<T> {
605 Work(Box<T>),
606 Close,
607}
608
609pub(crate) struct PromotionSubmitter<T>
610where
611 T: PromotionTask,
612{
613 tx: SyncSender<PromotionMessage<T>>,
614 pending: Arc<AtomicUsize>,
615}
616
617impl<T> Clone for PromotionSubmitter<T>
618where
619 T: PromotionTask,
620{
621 fn clone(&self) -> Self {
622 Self {
623 tx: self.tx.clone(),
624 pending: Arc::clone(&self.pending),
625 }
626 }
627}
628
629impl<T> PromotionSubmitter<T>
630where
631 T: PromotionTask,
632{
633 pub(crate) fn submit(&self, work: T) -> anyhow::Result<()> {
634 self.pending.fetch_add(1, Ordering::AcqRel);
635 if let Err(e) = self.tx.send(PromotionMessage::Work(Box::new(work))) {
636 self.pending.fetch_sub(1, Ordering::AcqRel);
637 anyhow::bail!("Promotion worker disconnected: {e}");
638 }
639 Ok(())
640 }
641}
642
643pub(crate) struct PromotionWorker<T>
644where
645 T: PromotionTask,
646{
647 tx: SyncSender<PromotionMessage<T>>,
648 rx: Receiver<T::Output>,
649 handle: Option<JoinHandle<()>>,
650 pending: Arc<AtomicUsize>,
651}
652
653impl<T> PromotionWorker<T>
654where
655 T: PromotionTask,
656{
657 pub(crate) fn spawn(thread_name: &str) -> anyhow::Result<Self> {
658 let (tx, work_rx) = mpsc::sync_channel::<PromotionMessage<T>>(1);
659 let (result_tx, rx) = mpsc::channel::<T::Output>();
660 let handle = std::thread::Builder::new() .name(thread_name.to_string())
662 .spawn(move || {
663 while let Ok(message) = work_rx.recv() {
664 match message {
665 PromotionMessage::Work(work) => {
666 let _ = result_tx.send((*work).execute());
667 }
668 PromotionMessage::Close => break,
669 }
670 }
671 })?;
672
673 Ok(Self {
674 tx,
675 rx,
676 handle: Some(handle),
677 pending: Arc::new(AtomicUsize::new(0)),
678 })
679 }
680
681 pub(crate) fn submit(&self, work: T) -> anyhow::Result<()> {
682 self.submitter().submit(work)
683 }
684
685 pub(crate) fn submitter(&self) -> PromotionSubmitter<T> {
686 PromotionSubmitter {
687 tx: self.tx.clone(),
688 pending: Arc::clone(&self.pending),
689 }
690 }
691
692 pub(crate) fn has_pending_work(&self) -> bool {
693 self.pending.load(Ordering::Acquire) > 0
694 }
695
696 pub(crate) fn try_recv_result(&self) -> anyhow::Result<Option<T::Output>> {
697 match self.rx.try_recv() {
698 Ok(result) => {
699 self.pending.fetch_sub(1, Ordering::AcqRel);
700 Ok(Some(result))
701 }
702 Err(TryRecvError::Empty) => Ok(None),
703 Err(TryRecvError::Disconnected) => anyhow::bail!("Promotion worker disconnected"),
704 }
705 }
706
707 pub(crate) fn recv_result(&self) -> anyhow::Result<T::Output> {
708 let result = self
709 .rx
710 .recv()
711 .map_err(|e| anyhow::anyhow!("Promotion worker disconnected: {e}"))?;
712 self.pending.fetch_sub(1, Ordering::AcqRel);
713 Ok(result)
714 }
715}
716
717impl<T> Drop for PromotionWorker<T>
718where
719 T: PromotionTask,
720{
721 fn drop(&mut self) {
722 while self.pending.load(Ordering::Acquire) > 0 {
723 if self.recv_result().is_err() {
724 break;
725 }
726 }
727
728 let _ = self.tx.send(PromotionMessage::Close);
729
730 if let Some(handle) = self.handle.take()
731 && let Err(e) = handle.join()
732 {
733 log::warn!("Promotion worker panicked: {e:?}");
734 }
735 }
736}
737
738pub(crate) struct PromotionDriver<B>
739where
740 B: StagedPromotionBackend,
741{
742 worker: Option<PromotionWorker<PromotionWork<B>>>,
743 scheduled_paths: Arc<Mutex<AHashSet<String>>>,
744 last_commit_time_ns: UnixNanos,
745}
746
747impl<B> PromotionDriver<B>
748where
749 B: StagedPromotionBackend,
750{
751 pub(crate) fn new(last_commit_time_ns: UnixNanos) -> Self {
752 Self {
753 worker: None,
754 scheduled_paths: Arc::new(Mutex::new(AHashSet::new())),
755 last_commit_time_ns,
756 }
757 }
758
759 pub(crate) fn is_due(&self, now: UnixNanos, interval_ns: u64) -> bool {
760 now.as_u64()
761 .saturating_sub(self.last_commit_time_ns.as_u64())
762 >= interval_ns
763 }
764
765 pub(crate) fn mark_committed_at(&mut self, now: UnixNanos) {
766 self.last_commit_time_ns = now;
767 }
768
769 pub(crate) fn schedule_new(&self, files: Vec<String>) -> anyhow::Result<Vec<String>> {
770 schedule_new_paths(&self.scheduled_paths, files)
771 }
772
773 pub(crate) fn submit(
774 &mut self,
775 work: PromotionWork<B>,
776 worker_name: &str,
777 ) -> anyhow::Result<()> {
778 let files = work.files().to_vec();
779
780 if self.worker.is_none() {
781 self.worker = Some(PromotionWorker::spawn(worker_name)?);
782 }
783
784 if let Err(e) = self.worker.as_mut().unwrap().submit(work) {
785 self.unschedule(&files);
786 return Err(e);
787 }
788 Ok(())
789 }
790
791 pub(crate) fn submitter(
792 &mut self,
793 worker_name: &str,
794 ) -> anyhow::Result<PromotionSubmitter<PromotionWork<B>>> {
795 if self.worker.is_none() {
796 self.worker = Some(PromotionWorker::spawn(worker_name)?);
797 }
798 Ok(self.worker.as_ref().unwrap().submitter())
799 }
800
801 pub(crate) fn scheduled_paths(&self) -> Arc<Mutex<AHashSet<String>>> {
802 Arc::clone(&self.scheduled_paths)
803 }
804
805 pub(crate) fn drain_completed(&mut self) -> anyhow::Result<Vec<PromotionResult>> {
806 let mut results = Vec::new();
807
808 loop {
809 let Some(worker) = self.worker.as_mut() else {
810 return Ok(results);
811 };
812
813 match worker.try_recv_result()? {
814 Some(result) => {
815 results.push(result);
816 }
817 None => return Ok(results),
818 }
819 }
820 }
821
822 pub(crate) fn wait(&mut self) -> anyhow::Result<Vec<PromotionResult>> {
823 let mut results = self.drain_completed()?;
824
825 loop {
826 let Some(worker) = self.worker.as_mut() else {
827 return Ok(results);
828 };
829
830 if !worker.has_pending_work() {
831 return Ok(results);
832 }
833
834 results.push(worker.recv_result()?);
835 }
836 }
837
838 pub(crate) fn unschedule(&self, files: &[String]) {
839 let mut scheduled_paths = self
840 .scheduled_paths
841 .lock()
842 .expect("promotion schedule lock poisoned");
843
844 for file in files {
845 scheduled_paths.remove(file);
846 }
847 }
848
849 pub(crate) fn unschedule_uncommitted(&self, files: &[String], committed_paths: &[String]) {
850 let committed_paths = committed_paths
851 .iter()
852 .map(String::as_str)
853 .collect::<AHashSet<_>>();
854
855 let mut scheduled_paths = self
856 .scheduled_paths
857 .lock()
858 .expect("promotion schedule lock poisoned");
859
860 for file in files {
861 if !committed_paths.contains(file.as_str()) {
862 scheduled_paths.remove(file);
863 }
864 }
865 }
866}
867
868pub(crate) fn schedule_new_paths(
869 scheduled_paths: &Arc<Mutex<AHashSet<String>>>,
870 files: Vec<String>,
871) -> anyhow::Result<Vec<String>> {
872 let mut scheduled = scheduled_paths
873 .lock()
874 .map_err(|e| anyhow::anyhow!("Promotion schedule lock poisoned: {e}"))?;
875 Ok(files
876 .into_iter()
877 .filter(|file| scheduled.insert(file.clone()))
878 .collect())
879}
880
881impl<B> PromotionTask for PromotionWork<B>
882where
883 B: StagedPromotionBackend,
884{
885 type Output = PromotionResult;
886
887 fn execute(self) -> Self::Output {
888 Self::execute(self)
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use std::{
895 any::Any,
896 sync::{Arc, Mutex},
897 };
898
899 use nautilus_model::data::Data;
900 use rstest::rstest;
901
902 use super::{
903 PromotionBackend, PromotionSession, PromotionSink, PromotionWork, StagedPromotionBackend,
904 };
905 use crate::{
906 common::{conversion::FeatherConversionSummary, storage::create_storage_backend_from_path},
907 writer::{
908 run::{FeatherSessionSource, RunStatus},
909 traits::StreamingSink,
910 },
911 };
912
913 #[derive(Debug, Default)]
914 struct RecordingState {
915 converted: Vec<(String, bool)>,
916 deleted: Vec<String>,
917 recorded_states: Vec<RunStatus>,
918 }
919
920 #[derive(Debug)]
921 struct RecordingBackend {
922 leftovers: Vec<String>,
923 state: Arc<Mutex<RecordingState>>,
924 }
925
926 impl PromotionBackend for RecordingBackend {
927 type Source = FeatherSessionSource;
928
929 const NAME: &'static str = "Recording";
930
931 fn convert_file(
932 &mut self,
933 _source: &FeatherSessionSource,
934 file: &str,
935 _use_ts_event_for_ts_init: bool,
936 record_promoted: bool,
937 ) -> anyhow::Result<Option<FeatherConversionSummary>> {
938 self.state
939 .lock()
940 .unwrap()
941 .converted
942 .push((file.to_string(), record_promoted));
943 Ok(Some(FeatherConversionSummary {
944 type_name: "quotes".to_string(),
945 identifier: None,
946 feather_path: file.to_string(),
947 native_version: Some(1),
948 unmatched_identifiers: None,
949 }))
950 }
951
952 fn delete_file(
953 &mut self,
954 _source: &FeatherSessionSource,
955 file: &str,
956 ) -> anyhow::Result<()> {
957 self.state.lock().unwrap().deleted.push(file.to_string());
958 Ok(())
959 }
960 }
961
962 impl StagedPromotionBackend for RecordingBackend {
963 fn record_run_state(
964 &mut self,
965 _source: &FeatherSessionSource,
966 _staging_uri: &str,
967 status: RunStatus,
968 _empty: bool,
969 _error: Option<&str>,
970 ) -> anyhow::Result<()> {
971 self.state.lock().unwrap().recorded_states.push(status);
972 Ok(())
973 }
974
975 fn delete_recorded_leftovers(
976 &mut self,
977 _source: &FeatherSessionSource,
978 files: &[String],
979 ) -> anyhow::Result<Vec<String>> {
980 Ok(files
981 .iter()
982 .filter(|file| self.leftovers.contains(file))
983 .cloned()
984 .collect())
985 }
986 }
987
988 #[rstest]
989 fn recorded_leftovers_are_deleted_and_skipped_before_conversion() {
990 let temp = tempfile::TempDir::new().unwrap();
991 let storage =
992 create_storage_backend_from_path(temp.path().to_str().unwrap(), None).unwrap();
993 let source = FeatherSessionSource::new(storage, "backtest", "run-leftovers");
994 let state = Arc::new(Mutex::new(RecordingState::default()));
995 let backend = RecordingBackend {
996 leftovers: vec!["backtest/run-leftovers/b.feather".to_string()],
997 state: Arc::clone(&state),
998 };
999 let files = vec![
1000 "backtest/run-leftovers/a.feather".to_string(),
1001 "backtest/run-leftovers/b.feather".to_string(),
1002 "backtest/run-leftovers/c.feather".to_string(),
1003 ];
1004
1005 let result = PromotionWork::new(backend, source, "staging".to_string(), files, false, true)
1006 .execute();
1007
1008 let converted = result.converted.unwrap();
1009 let state = state.lock().unwrap();
1010 assert_eq!(
1011 converted
1012 .iter()
1013 .map(|summary| summary.feather_path.as_str())
1014 .collect::<Vec<_>>(),
1015 vec![
1016 "backtest/run-leftovers/a.feather",
1017 "backtest/run-leftovers/c.feather",
1018 ],
1019 );
1020 assert_eq!(
1021 result.committed_paths,
1022 vec![
1023 "backtest/run-leftovers/a.feather",
1024 "backtest/run-leftovers/c.feather",
1025 ],
1026 );
1027 assert_eq!(
1030 result.deleted_paths,
1031 vec![
1032 "backtest/run-leftovers/b.feather",
1033 "backtest/run-leftovers/a.feather",
1034 "backtest/run-leftovers/c.feather",
1035 ],
1036 );
1037 assert_eq!(
1038 state.converted,
1039 vec![
1040 ("backtest/run-leftovers/a.feather".to_string(), false),
1041 ("backtest/run-leftovers/c.feather".to_string(), true),
1042 ],
1043 );
1044 assert_eq!(
1045 state.deleted,
1046 vec![
1047 "backtest/run-leftovers/a.feather",
1048 "backtest/run-leftovers/c.feather",
1049 ],
1050 );
1051 assert_eq!(state.recorded_states, vec![RunStatus::Promoted]);
1052 assert!(result.run_state_recorded);
1053 }
1054
1055 #[derive(Debug)]
1056 struct FailingBackgroundSink {
1057 calls: Vec<&'static str>,
1058 promote_on_flush: bool,
1059 promote_on_close: bool,
1060 }
1061
1062 impl FailingBackgroundSink {
1063 const fn new(promote_on_flush: bool, promote_on_close: bool) -> Self {
1064 Self {
1065 calls: Vec::new(),
1066 promote_on_flush,
1067 promote_on_close,
1068 }
1069 }
1070 }
1071
1072 impl PromotionSink for FailingBackgroundSink {
1073 fn stage_data(&mut self, _data: Data) -> anyhow::Result<()> {
1074 unreachable!()
1075 }
1076
1077 fn stage_batch(&mut self, _data: Vec<Data>) -> anyhow::Result<()> {
1078 unreachable!()
1079 }
1080
1081 fn stage_any(&mut self, _message: &dyn Any) -> anyhow::Result<bool> {
1082 unreachable!()
1083 }
1084
1085 fn flush_staging(&mut self) -> anyhow::Result<()> {
1086 self.calls.push("flush_staging");
1087 Ok(())
1088 }
1089
1090 fn close_staging(&mut self) -> anyhow::Result<()> {
1091 self.calls.push("close_staging");
1092 Ok(())
1093 }
1094
1095 fn mark_run_non_empty(&mut self) -> anyhow::Result<()> {
1096 unreachable!()
1097 }
1098
1099 fn maybe_promote_by_period(&mut self) -> anyhow::Result<()> {
1100 unreachable!()
1101 }
1102
1103 fn wait_for_background_promotions(
1104 &mut self,
1105 ) -> anyhow::Result<Vec<FeatherConversionSummary>> {
1106 self.calls.push("wait_for_background_promotions");
1107 anyhow::bail!("background promotion failed")
1108 }
1109
1110 fn stop_periodic_promotion(&mut self) {
1111 self.calls.push("stop_periodic_promotion");
1112 }
1113
1114 fn take_pending_error(&mut self) -> anyhow::Result<()> {
1115 self.calls.push("take_pending_error");
1116 Ok(())
1117 }
1118
1119 fn should_promote_on_flush(&self) -> bool {
1120 self.promote_on_flush
1121 }
1122
1123 fn should_promote_on_close(&self) -> bool {
1124 self.promote_on_close
1125 }
1126
1127 fn promote(&mut self) -> anyhow::Result<Vec<FeatherConversionSummary>> {
1128 self.calls.push("promote");
1129 Ok(Vec::new())
1130 }
1131
1132 fn record_completed(&mut self) {
1133 self.calls.push("record_completed");
1134 }
1135 }
1136
1137 #[rstest]
1138 fn promotion_session_parses_windows_drive_path() {
1139 let session =
1140 PromotionSession::from_uri(r"C:\catalog\backtest\run-1").expect("valid run path");
1141
1142 assert_eq!(session.catalog_uri, "C:/catalog");
1143 assert_eq!(session.kind, "backtest");
1144 assert_eq!(session.instance_id, "run-1");
1145 }
1146
1147 #[rstest]
1148 fn promotion_session_parses_windows_unc_path() {
1149 let session =
1150 PromotionSession::from_uri(r"\\server\share\live\run-2").expect("valid UNC run path");
1151
1152 assert_eq!(session.kind, "live");
1155 assert_eq!(session.instance_id, "run-2");
1156 }
1157
1158 #[rstest]
1159 fn promotion_session_parses_posix_path() {
1160 let session =
1161 PromotionSession::from_uri("/tmp/catalog/sandbox/run-3").expect("valid run path");
1162
1163 assert_eq!(session.catalog_uri, "/tmp/catalog");
1164 assert_eq!(session.kind, "sandbox");
1165 assert_eq!(session.instance_id, "run-3");
1166 }
1167
1168 #[rstest]
1169 fn flush_finalizes_and_promotes_before_returning_background_error() {
1170 let mut sink = FailingBackgroundSink::new(true, false);
1171
1172 let e = StreamingSink::flush(&mut sink).unwrap_err();
1173
1174 assert_eq!(e.to_string(), "background promotion failed");
1175 assert_eq!(
1176 sink.calls,
1177 [
1178 "flush_staging",
1179 "wait_for_background_promotions",
1180 "promote",
1181 "take_pending_error",
1182 ]
1183 );
1184 }
1185
1186 #[rstest]
1187 fn close_finalizes_and_promotes_before_returning_background_error() {
1188 let mut sink = FailingBackgroundSink::new(false, true);
1189
1190 let e = StreamingSink::close(&mut sink).unwrap_err();
1191
1192 assert_eq!(e.to_string(), "background promotion failed");
1193 assert_eq!(
1194 sink.calls,
1195 [
1196 "stop_periodic_promotion",
1197 "close_staging",
1198 "wait_for_background_promotions",
1199 "take_pending_error",
1200 "promote",
1201 ]
1202 );
1203 }
1204}