1use std::collections::BTreeMap;
8use std::future::Future;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::time::Duration;
12
13use futures::stream::{FuturesUnordered, StreamExt as _};
14use glob::Pattern;
15use jiff::Timestamp;
16use serde::Serialize;
17use tokio::sync::{Mutex, Notify};
18use tokio::time::{Instant, sleep, sleep_until};
19
20use crate::alias::RetryConfig;
21use crate::error::{Error, Result};
22use crate::retry::{calculate_backoff, is_retryable_error};
23
24#[derive(Debug, Clone)]
26pub struct TransferCandidate<T> {
27 pub payload: T,
29 pub source: String,
31 pub target: String,
33 pub relative_path: String,
35 pub modified: Option<Timestamp>,
37 pub size_bytes: Option<u64>,
39}
40
41#[derive(Debug, Clone, Default)]
43pub struct TransferSelection {
44 include: Vec<Pattern>,
45 exclude: Vec<Pattern>,
46 newer_than: Option<Timestamp>,
47 older_than: Option<Timestamp>,
48 rewind: Option<Timestamp>,
49}
50
51impl TransferSelection {
52 pub fn new(
58 include: &[String],
59 exclude: &[String],
60 newer_than: Option<Timestamp>,
61 older_than: Option<Timestamp>,
62 rewind: Option<Timestamp>,
63 ) -> Result<Self> {
64 let include = compile_patterns("include", include)?;
65 let exclude = compile_patterns("exclude", exclude)?;
66
67 Ok(Self {
68 include,
69 exclude,
70 newer_than,
71 older_than,
72 rewind,
73 })
74 }
75
76 pub fn matches<T>(&self, candidate: &TransferCandidate<T>) -> bool {
78 let path_matches = (self.include.is_empty()
79 || self
80 .include
81 .iter()
82 .any(|pattern| pattern.matches(&candidate.relative_path)))
83 && !self
84 .exclude
85 .iter()
86 .any(|pattern| pattern.matches(&candidate.relative_path));
87
88 if !path_matches {
89 return false;
90 }
91
92 let has_time_filter =
93 self.newer_than.is_some() || self.older_than.is_some() || self.rewind.is_some();
94 let Some(modified) = candidate.modified else {
95 return !has_time_filter;
96 };
97
98 self.newer_than.is_none_or(|cutoff| modified > cutoff)
99 && self.older_than.is_none_or(|cutoff| modified < cutoff)
100 && self.rewind.is_none_or(|cutoff| modified <= cutoff)
101 }
102}
103
104fn compile_patterns(kind: &str, values: &[String]) -> Result<Vec<Pattern>> {
105 values
106 .iter()
107 .map(|value| {
108 Pattern::new(value).map_err(|error| {
109 Error::InvalidPath(format!("Invalid {kind} pattern '{value}': {error}"))
110 })
111 })
112 .collect()
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
117pub struct TransferSummary {
118 pub planned: usize,
119 pub skipped: usize,
120 pub successful: usize,
121 pub failed: usize,
122 pub cancelled: usize,
123 pub transferred_bytes: u64,
124}
125
126#[derive(Debug)]
128pub struct TransferPlan<T> {
129 pub items: Vec<TransferCandidate<T>>,
130 pub summary: TransferSummary,
131}
132
133impl<T> TransferPlan<T> {
134 pub fn build(candidates: Vec<TransferCandidate<T>>, selection: &TransferSelection) -> Self {
136 let candidate_count = candidates.len();
137 let mut items: Vec<_> = candidates
138 .into_iter()
139 .filter(|candidate| selection.matches(candidate))
140 .collect();
141 items.sort_by(|left, right| {
142 left.relative_path
143 .cmp(&right.relative_path)
144 .then_with(|| left.source.cmp(&right.source))
145 .then_with(|| left.target.cmp(&right.target))
146 });
147
148 Self {
149 summary: TransferSummary {
150 planned: items.len(),
151 skipped: candidate_count.saturating_sub(items.len()),
152 ..TransferSummary::default()
153 },
154 items,
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct TransferControls {
162 pub concurrency: usize,
163 pub bytes_per_second: Option<u64>,
164 pub retry: RetryConfig,
165 pub continue_on_error: bool,
166}
167
168impl TransferControls {
169 pub fn validate(&self) -> Result<()> {
171 if !(1..=256).contains(&self.concurrency) {
172 return Err(Error::Config(
173 "Transfer concurrency must be between 1 and 256".to_string(),
174 ));
175 }
176 if self.bytes_per_second == Some(0) {
177 return Err(Error::Config(
178 "Transfer rate limit must be greater than zero".to_string(),
179 ));
180 }
181 if self.retry.max_attempts == 0 {
182 return Err(Error::Config(
183 "Transfer retry attempts must be greater than zero".to_string(),
184 ));
185 }
186 if self.retry.initial_backoff_ms > self.retry.max_backoff_ms {
187 return Err(Error::Config(
188 "Transfer retry initial backoff must not exceed its maximum backoff".to_string(),
189 ));
190 }
191 Ok(())
192 }
193}
194
195impl Default for TransferControls {
196 fn default() -> Self {
197 Self {
198 concurrency: 4,
199 bytes_per_second: None,
200 retry: RetryConfig::default(),
201 continue_on_error: false,
202 }
203 }
204}
205
206#[derive(Debug, Clone, Default)]
208pub struct TransferCancellation {
209 inner: Arc<TransferCancellationInner>,
210}
211
212#[derive(Debug, Default)]
213struct TransferCancellationInner {
214 cancelled: AtomicBool,
215 notify: Notify,
216}
217
218impl TransferCancellation {
219 pub fn new() -> Self {
220 Self::default()
221 }
222
223 pub fn cancel(&self) {
224 if !self.inner.cancelled.swap(true, Ordering::AcqRel) {
225 self.inner.notify.notify_waiters();
226 }
227 }
228
229 pub fn is_cancelled(&self) -> bool {
230 self.inner.cancelled.load(Ordering::Acquire)
231 }
232
233 pub async fn cancelled(&self) {
234 let notified = self.inner.notify.notified();
235 tokio::pin!(notified);
236 notified.as_mut().enable();
237 if self.is_cancelled() {
238 return;
239 }
240 notified.await;
241 }
242}
243
244#[derive(Debug)]
246pub enum TransferOutcomeState {
247 Success { bytes_transferred: u64 },
248 Failed { error: Error },
249 Cancelled { error: Option<Error> },
250}
251
252#[derive(Debug)]
254pub struct TransferOutcome<T> {
255 pub item: TransferCandidate<T>,
256 pub state: TransferOutcomeState,
257}
258
259#[derive(Debug)]
261pub struct TransferReport<T> {
262 pub summary: TransferSummary,
263 pub outcomes: Vec<TransferOutcome<T>>,
264 pub was_cancelled: bool,
265}
266
267impl<T> TransferReport<T> {
268 pub fn first_failure(&self) -> Option<&Error> {
270 self.outcomes
271 .iter()
272 .find_map(|outcome| match &outcome.state {
273 TransferOutcomeState::Failed { error } => Some(error),
274 TransferOutcomeState::Success { .. }
275 | TransferOutcomeState::Cancelled { error: _ } => None,
276 })
277 }
278}
279
280#[derive(Debug, Clone)]
282pub struct TransferExecutor {
283 controls: TransferControls,
284}
285
286impl TransferExecutor {
287 pub fn new(controls: TransferControls) -> Result<Self> {
288 controls.validate()?;
289 Ok(Self { controls })
290 }
291
292 pub async fn execute<T, F, Fut>(&self, plan: TransferPlan<T>, operation: F) -> TransferReport<T>
294 where
295 T: Clone + Send + Sync + 'static,
296 F: Fn(TransferCandidate<T>) -> Fut + Send + Sync + 'static,
297 Fut: Future<Output = Result<u64>> + Send + 'static,
298 {
299 self.execute_with_cancellation(plan, TransferCancellation::new(), operation)
300 .await
301 }
302
303 pub async fn execute_with_cancellation<T, F, Fut>(
307 &self,
308 plan: TransferPlan<T>,
309 cancellation: TransferCancellation,
310 operation: F,
311 ) -> TransferReport<T>
312 where
313 T: Clone + Send + Sync + 'static,
314 F: Fn(TransferCandidate<T>) -> Fut + Send + Sync + 'static,
315 Fut: Future<Output = Result<u64>> + Send + 'static,
316 {
317 let mut summary = plan.summary;
318 let items = plan.items;
319 let operation = Arc::new(operation);
320 let rate_gate = Arc::new(RateGate::new(self.controls.bytes_per_second));
321 let mut in_flight = FuturesUnordered::new();
322 let mut completed = BTreeMap::new();
323 let mut next_index = 0usize;
324 let mut was_cancelled = cancellation.is_cancelled();
325 let mut stop_scheduling = was_cancelled;
326
327 while !stop_scheduling
328 && next_index < items.len()
329 && in_flight.len() < self.controls.concurrency
330 {
331 in_flight.push(execute_candidate(
332 next_index,
333 items[next_index].clone(),
334 Arc::clone(&operation),
335 Arc::clone(&rate_gate),
336 self.controls.retry.clone(),
337 cancellation.clone(),
338 ));
339 next_index += 1;
340 }
341
342 while !in_flight.is_empty() {
343 let next = if stop_scheduling {
344 in_flight.next().await
345 } else {
346 tokio::select! {
347 biased;
348 _ = cancellation.cancelled() => {
349 was_cancelled = true;
350 stop_scheduling = true;
351 continue;
352 }
353 next = in_flight.next() => next,
354 }
355 };
356 let Some((index, result)) = next else {
357 break;
358 };
359 let failed = result.is_err();
360 completed.insert(index, result);
361
362 if failed && !self.controls.continue_on_error {
363 stop_scheduling = true;
366 }
367
368 if cancellation.is_cancelled() {
369 was_cancelled = true;
370 stop_scheduling = true;
371 }
372
373 if !stop_scheduling && next_index < items.len() {
374 in_flight.push(execute_candidate(
375 next_index,
376 items[next_index].clone(),
377 Arc::clone(&operation),
378 Arc::clone(&rate_gate),
379 self.controls.retry.clone(),
380 cancellation.clone(),
381 ));
382 next_index += 1;
383 }
384 }
385 drop(in_flight);
386 if cancellation.is_cancelled() {
387 was_cancelled = true;
388 }
389
390 let mut outcomes = Vec::with_capacity(items.len());
391 for (index, item) in items.into_iter().enumerate() {
392 let state = match completed.remove(&index) {
393 Some(Ok(bytes_transferred)) => {
394 summary.successful += 1;
395 summary.transferred_bytes =
396 summary.transferred_bytes.saturating_add(bytes_transferred);
397 TransferOutcomeState::Success { bytes_transferred }
398 }
399 Some(Err(error @ Error::Interrupted(_))) => {
400 summary.cancelled += 1;
401 TransferOutcomeState::Cancelled { error: Some(error) }
402 }
403 Some(Err(error)) => {
404 summary.failed += 1;
405 TransferOutcomeState::Failed { error }
406 }
407 None => {
408 summary.cancelled += 1;
409 TransferOutcomeState::Cancelled { error: None }
410 }
411 };
412 outcomes.push(TransferOutcome { item, state });
413 }
414
415 TransferReport {
416 summary,
417 outcomes,
418 was_cancelled,
419 }
420 }
421}
422
423async fn execute_candidate<T, F, Fut>(
424 index: usize,
425 item: TransferCandidate<T>,
426 operation: Arc<F>,
427 rate_gate: Arc<RateGate>,
428 retry: RetryConfig,
429 cancellation: TransferCancellation,
430) -> (usize, Result<u64>)
431where
432 T: Clone + Send + Sync + 'static,
433 F: Fn(TransferCandidate<T>) -> Fut + Send + Sync + 'static,
434 Fut: Future<Output = Result<u64>> + Send + 'static,
435{
436 let mut attempt = 0;
437 let result = loop {
438 if cancellation.is_cancelled() {
439 break Err(Error::Interrupted("Transfer cancelled".to_string()));
440 }
441
442 attempt += 1;
443 let rate_wait = rate_gate.wait(item.size_bytes.unwrap_or_default());
444 tokio::pin!(rate_wait);
445 tokio::select! {
446 biased;
447 _ = cancellation.cancelled() => {
448 break Err(Error::Interrupted("Transfer cancelled".to_string()));
449 }
450 _ = &mut rate_wait => {}
451 }
452
453 match operation(item.clone()).await {
456 Ok(bytes) => break Ok(bytes),
457 Err(error) => {
458 if attempt >= retry.max_attempts || !is_retryable_error(&error) {
459 break Err(error);
460 }
461
462 let backoff = calculate_backoff(&retry, attempt);
463 tracing::debug!(
464 attempt,
465 backoff_ms = backoff.as_millis(),
466 error = %error,
467 "Retrying transfer after transient error"
468 );
469 tokio::select! {
470 biased;
471 _ = cancellation.cancelled() => {
472 break Err(Error::Interrupted("Transfer cancelled".to_string()));
473 }
474 _ = sleep(backoff) => {}
475 }
476 }
477 }
478 };
479 (index, result)
480}
481
482#[derive(Debug)]
483struct RateGate {
484 bytes_per_second: Option<u64>,
485 next_slot: Mutex<Instant>,
486}
487
488impl RateGate {
489 fn new(bytes_per_second: Option<u64>) -> Self {
490 Self {
491 bytes_per_second,
492 next_slot: Mutex::new(Instant::now()),
493 }
494 }
495
496 async fn wait(&self, bytes: u64) {
497 let Some(bytes_per_second) = self.bytes_per_second else {
498 return;
499 };
500 if bytes == 0 || bytes_per_second == 0 {
501 return;
502 }
503
504 let wait_until = {
505 let mut next_slot = self.next_slot.lock().await;
506 let now = Instant::now();
507 let wait_until = (*next_slot).max(now);
508 let delay = rate_duration(bytes, bytes_per_second);
509 *next_slot = wait_until.checked_add(delay).unwrap_or(wait_until);
510 wait_until
511 };
512
513 if wait_until > Instant::now() {
514 sleep_until(wait_until).await;
515 }
516 }
517}
518
519fn rate_duration(bytes: u64, bytes_per_second: u64) -> Duration {
520 let seconds = bytes / bytes_per_second;
521 let remaining_bytes = bytes % bytes_per_second;
522 let nanos =
523 (u128::from(remaining_bytes) * 1_000_000_000u128).div_ceil(u128::from(bytes_per_second));
524 Duration::from_secs(seconds).saturating_add(Duration::from_nanos(nanos as u64))
525}
526
527#[cfg(test)]
528mod tests {
529 use std::sync::atomic::{AtomicUsize, Ordering};
530
531 use super::*;
532
533 fn candidate(
534 name: &str,
535 modified: Option<Timestamp>,
536 size_bytes: u64,
537 ) -> TransferCandidate<()> {
538 TransferCandidate {
539 payload: (),
540 source: format!("source/{name}"),
541 target: format!("target/{name}"),
542 relative_path: name.to_string(),
543 modified,
544 size_bytes: Some(size_bytes),
545 }
546 }
547
548 #[test]
549 fn selection_applies_includes_before_excludes_and_sorts_deterministically() {
550 let selection = TransferSelection::new(
551 &["reports/*.csv".to_string()],
552 &["**/private-*".to_string()],
553 None,
554 None,
555 None,
556 )
557 .expect("valid selection");
558 let plan = TransferPlan::build(
559 vec![
560 candidate("reports/z.csv", None, 1),
561 candidate("reports/private-a.csv", None, 1),
562 candidate("notes.txt", None, 1),
563 candidate("reports/a.csv", None, 1),
564 ],
565 &selection,
566 );
567
568 assert_eq!(
569 plan.items
570 .iter()
571 .map(|item| item.relative_path.as_str())
572 .collect::<Vec<_>>(),
573 ["reports/a.csv", "reports/z.csv"]
574 );
575 assert_eq!(plan.summary.planned, 2);
576 assert_eq!(plan.summary.skipped, 2);
577 }
578
579 #[test]
580 fn time_selection_has_explicit_utc_boundary_semantics() {
581 let cutoff: Timestamp = "2026-07-21T12:00:00Z".parse().expect("valid UTC cutoff");
582 let at_boundary = candidate("boundary", Some(cutoff), 1);
583
584 let newer = TransferSelection::new(&[], &[], Some(cutoff), None, None)
585 .expect("valid newer selection");
586 let older = TransferSelection::new(&[], &[], None, Some(cutoff), None)
587 .expect("valid older selection");
588 let rewind = TransferSelection::new(&[], &[], None, None, Some(cutoff))
589 .expect("valid rewind selection");
590
591 assert!(!newer.matches(&at_boundary));
592 assert!(!older.matches(&at_boundary));
593 assert!(rewind.matches(&at_boundary));
594 assert!(!rewind.matches(&candidate("missing", None, 1)));
595 }
596
597 #[tokio::test]
598 async fn executor_enforces_one_global_concurrency_limit() {
599 let controls = TransferControls {
600 concurrency: 2,
601 ..TransferControls::default()
602 };
603 let executor = TransferExecutor::new(controls).expect("valid controls");
604 let plan = TransferPlan::build(
605 (0..6)
606 .map(|index| candidate(&format!("{index}"), None, 1))
607 .collect(),
608 &TransferSelection::default(),
609 );
610 let active = Arc::new(AtomicUsize::new(0));
611 let maximum = Arc::new(AtomicUsize::new(0));
612
613 let report = executor
614 .execute(plan, {
615 let active = Arc::clone(&active);
616 let maximum = Arc::clone(&maximum);
617 move |_| {
618 let active = Arc::clone(&active);
619 let maximum = Arc::clone(&maximum);
620 async move {
621 let current = active.fetch_add(1, Ordering::SeqCst) + 1;
622 maximum.fetch_max(current, Ordering::SeqCst);
623 tokio::time::sleep(Duration::from_millis(10)).await;
624 active.fetch_sub(1, Ordering::SeqCst);
625 Ok(1)
626 }
627 }
628 })
629 .await;
630
631 assert_eq!(maximum.load(Ordering::SeqCst), 2);
632 assert_eq!(report.summary.successful, 6);
633 }
634
635 #[tokio::test]
636 async fn executor_applies_rate_limit_across_all_workers() {
637 let controls = TransferControls {
638 concurrency: 3,
639 bytes_per_second: Some(1_000),
640 ..TransferControls::default()
641 };
642 let executor = TransferExecutor::new(controls).expect("valid controls");
643 let plan = TransferPlan::build(
644 vec![
645 candidate("a", None, 10),
646 candidate("b", None, 10),
647 candidate("c", None, 10),
648 ],
649 &TransferSelection::default(),
650 );
651 let starts = Arc::new(Mutex::new(Vec::new()));
652
653 let report = executor
654 .execute(plan, {
655 let starts = Arc::clone(&starts);
656 move |_| {
657 let starts = Arc::clone(&starts);
658 async move {
659 starts.lock().await.push(Instant::now());
660 Ok(10)
661 }
662 }
663 })
664 .await;
665
666 let starts = starts.lock().await;
667 let first = starts.iter().min().copied().expect("first start");
668 let last = starts.iter().max().copied().expect("last start");
669 assert!(last.duration_since(first) >= Duration::from_millis(15));
670 assert_eq!(report.summary.transferred_bytes, 30);
671 }
672
673 #[tokio::test]
674 async fn executor_retries_only_classified_transient_failures() {
675 let controls = TransferControls {
676 concurrency: 1,
677 retry: RetryConfig {
678 max_attempts: 3,
679 initial_backoff_ms: 1,
680 max_backoff_ms: 1,
681 },
682 continue_on_error: true,
683 ..TransferControls::default()
684 };
685 let executor = TransferExecutor::new(controls).expect("valid controls");
686 let transient_calls = Arc::new(AtomicUsize::new(0));
687 let permanent_calls = Arc::new(AtomicUsize::new(0));
688 let plan = TransferPlan::build(
689 vec![
690 candidate("transient", None, 1),
691 candidate("permanent", None, 1),
692 ],
693 &TransferSelection::default(),
694 );
695
696 let report = executor
697 .execute(plan, {
698 let transient_calls = Arc::clone(&transient_calls);
699 let permanent_calls = Arc::clone(&permanent_calls);
700 move |item| {
701 let transient_calls = Arc::clone(&transient_calls);
702 let permanent_calls = Arc::clone(&permanent_calls);
703 async move {
704 if item.relative_path == "transient" {
705 let attempt = transient_calls.fetch_add(1, Ordering::SeqCst) + 1;
706 if attempt < 3 {
707 return Err(Error::Network("503 Service Unavailable".to_string()));
708 }
709 return Ok(1);
710 }
711
712 permanent_calls.fetch_add(1, Ordering::SeqCst);
713 Err(Error::Auth("denied".to_string()))
714 }
715 }
716 })
717 .await;
718
719 assert_eq!(transient_calls.load(Ordering::SeqCst), 3);
720 assert_eq!(permanent_calls.load(Ordering::SeqCst), 1);
721 assert_eq!(report.summary.successful, 1);
722 assert_eq!(report.summary.failed, 1);
723 }
724
725 #[tokio::test]
726 async fn executor_cancels_remaining_work_after_first_failure() {
727 let controls = TransferControls {
728 concurrency: 1,
729 retry: RetryConfig {
730 max_attempts: 1,
731 ..RetryConfig::default()
732 },
733 continue_on_error: false,
734 ..TransferControls::default()
735 };
736 let executor = TransferExecutor::new(controls).expect("valid controls");
737 let calls = Arc::new(AtomicUsize::new(0));
738 let plan = TransferPlan::build(
739 vec![
740 candidate("a", None, 1),
741 candidate("b", None, 1),
742 candidate("c", None, 1),
743 ],
744 &TransferSelection::default(),
745 );
746
747 let report = executor
748 .execute(plan, {
749 let calls = Arc::clone(&calls);
750 move |_| {
751 let calls = Arc::clone(&calls);
752 async move {
753 calls.fetch_add(1, Ordering::SeqCst);
754 Err(Error::Auth("stop".to_string()))
755 }
756 }
757 })
758 .await;
759
760 assert_eq!(calls.load(Ordering::SeqCst), 1);
761 assert_eq!(report.summary.failed, 1);
762 assert_eq!(report.summary.cancelled, 2);
763 }
764
765 #[tokio::test]
766 async fn executor_drains_started_work_but_does_not_schedule_more_after_failure() {
767 let controls = TransferControls {
768 concurrency: 2,
769 retry: RetryConfig {
770 max_attempts: 1,
771 ..RetryConfig::default()
772 },
773 continue_on_error: false,
774 ..TransferControls::default()
775 };
776 let executor = TransferExecutor::new(controls).expect("valid controls");
777 let calls = Arc::new(AtomicUsize::new(0));
778 let plan = TransferPlan::build(
779 vec![
780 candidate("a", None, 1),
781 candidate("b", None, 1),
782 candidate("c", None, 1),
783 ],
784 &TransferSelection::default(),
785 );
786
787 let report = executor
788 .execute(plan, {
789 let calls = Arc::clone(&calls);
790 move |item| {
791 let calls = Arc::clone(&calls);
792 async move {
793 calls.fetch_add(1, Ordering::SeqCst);
794 if item.relative_path == "a" {
795 tokio::time::sleep(Duration::from_millis(5)).await;
796 Err(Error::Auth("stop".to_string()))
797 } else {
798 tokio::time::sleep(Duration::from_millis(10)).await;
799 Ok(1)
800 }
801 }
802 }
803 })
804 .await;
805
806 assert_eq!(calls.load(Ordering::SeqCst), 2);
807 assert_eq!(report.summary.successful, 1);
808 assert_eq!(report.summary.failed, 1);
809 assert_eq!(report.summary.cancelled, 1);
810 }
811
812 #[tokio::test]
813 async fn cooperative_cancellation_stops_scheduling_and_drains_started_work() {
814 let controls = TransferControls {
815 concurrency: 2,
816 retry: RetryConfig {
817 max_attempts: 1,
818 ..RetryConfig::default()
819 },
820 continue_on_error: true,
821 ..TransferControls::default()
822 };
823 let executor = TransferExecutor::new(controls).expect("valid controls");
824 let cancellation = TransferCancellation::new();
825 let plan = TransferPlan::build(
826 vec![
827 candidate("a", None, 1),
828 candidate("b", None, 1),
829 candidate("c", None, 1),
830 ],
831 &TransferSelection::default(),
832 );
833 let started = Arc::new(AtomicUsize::new(0));
834 let release = Arc::new(tokio::sync::Semaphore::new(0));
835
836 let task = tokio::spawn({
837 let cancellation = cancellation.clone();
838 let started = Arc::clone(&started);
839 let release = Arc::clone(&release);
840 async move {
841 executor
842 .execute_with_cancellation(plan, cancellation, move |_| {
843 let started = Arc::clone(&started);
844 let release = Arc::clone(&release);
845 async move {
846 started.fetch_add(1, Ordering::SeqCst);
847 release.acquire().await.expect("release semaphore").forget();
848 Ok(1)
849 }
850 })
851 .await
852 }
853 });
854
855 while started.load(Ordering::SeqCst) < 2 {
856 tokio::task::yield_now().await;
857 }
858 cancellation.cancel();
859 release.add_permits(2);
860 let report = task.await.expect("executor task");
861
862 assert!(report.was_cancelled);
863 assert_eq!(started.load(Ordering::SeqCst), 2);
864 assert_eq!(report.summary.successful, 2);
865 assert_eq!(report.summary.cancelled, 1);
866 }
867
868 #[tokio::test]
869 async fn cancellation_during_retry_backoff_does_not_start_another_attempt() {
870 let controls = TransferControls {
871 concurrency: 1,
872 retry: RetryConfig {
873 max_attempts: 3,
874 initial_backoff_ms: 10_000,
875 max_backoff_ms: 10_000,
876 },
877 continue_on_error: true,
878 ..TransferControls::default()
879 };
880 let executor = TransferExecutor::new(controls).expect("valid controls");
881 let cancellation = TransferCancellation::new();
882 let calls = Arc::new(AtomicUsize::new(0));
883 let first_attempt = Arc::new(tokio::sync::Semaphore::new(0));
884 let plan = TransferPlan::build(
885 vec![candidate("retry", None, 1)],
886 &TransferSelection::default(),
887 );
888
889 let task = tokio::spawn({
890 let cancellation = cancellation.clone();
891 let calls = Arc::clone(&calls);
892 let first_attempt = Arc::clone(&first_attempt);
893 async move {
894 executor
895 .execute_with_cancellation(plan, cancellation, move |_| {
896 let calls = Arc::clone(&calls);
897 let first_attempt = Arc::clone(&first_attempt);
898 async move {
899 calls.fetch_add(1, Ordering::SeqCst);
900 first_attempt.add_permits(1);
901 Err(Error::Network("503 Service Unavailable".to_string()))
902 }
903 })
904 .await
905 }
906 });
907
908 first_attempt
909 .acquire()
910 .await
911 .expect("first attempt semaphore")
912 .forget();
913 cancellation.cancel();
914 let report = tokio::time::timeout(Duration::from_secs(1), task)
915 .await
916 .expect("cancellation should interrupt retry backoff")
917 .expect("executor task");
918
919 assert!(report.was_cancelled);
920 assert_eq!(calls.load(Ordering::SeqCst), 1);
921 assert_eq!(report.summary.failed, 0);
922 assert_eq!(report.summary.cancelled, 1);
923 }
924
925 #[tokio::test]
926 async fn simultaneous_completion_and_cancellation_never_schedules_more_work() {
927 for _ in 0..100 {
928 let controls = TransferControls {
929 concurrency: 1,
930 retry: RetryConfig {
931 max_attempts: 1,
932 ..RetryConfig::default()
933 },
934 continue_on_error: true,
935 ..TransferControls::default()
936 };
937 let executor = TransferExecutor::new(controls).expect("valid controls");
938 let cancellation = TransferCancellation::new();
939 let started = Arc::new(AtomicUsize::new(0));
940 let release = Arc::new(tokio::sync::Semaphore::new(0));
941 let plan = TransferPlan::build(
942 vec![candidate("a", None, 1), candidate("b", None, 1)],
943 &TransferSelection::default(),
944 );
945
946 let task = tokio::spawn({
947 let cancellation = cancellation.clone();
948 let started = Arc::clone(&started);
949 let release = Arc::clone(&release);
950 async move {
951 executor
952 .execute_with_cancellation(plan, cancellation, move |_| {
953 let started = Arc::clone(&started);
954 let release = Arc::clone(&release);
955 async move {
956 started.fetch_add(1, Ordering::SeqCst);
957 release.acquire().await.expect("release semaphore").forget();
958 Ok(1)
959 }
960 })
961 .await
962 }
963 });
964
965 while started.load(Ordering::SeqCst) == 0 {
966 tokio::task::yield_now().await;
967 }
968 cancellation.cancel();
969 release.add_permits(1);
970 let report = task.await.expect("executor task");
971
972 assert!(report.was_cancelled);
973 assert_eq!(started.load(Ordering::SeqCst), 1);
974 }
975 }
976}