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