Skip to main content

rc_core/
transfer.rs

1//! Shared planning and execution primitives for bulk transfers.
2//!
3//! Commands expand their own storage-specific inputs into [`TransferCandidate`] values, then use
4//! this module for consistent selection, ordering, retry, rate, concurrency, cancellation, and
5//! aggregate summary behavior.
6
7use 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/// A storage-independent item that can be selected and executed as part of a transfer.
25#[derive(Debug, Clone)]
26pub struct TransferCandidate<T> {
27    /// Command-specific operation data.
28    pub payload: T,
29    /// Display-safe source identity.
30    pub source: String,
31    /// Display-safe destination identity.
32    pub target: String,
33    /// Source-relative path used by include and exclude rules.
34    pub relative_path: String,
35    /// UTC modification timestamp when the source provides one.
36    pub modified: Option<Timestamp>,
37    /// Expected transfer size, used for aggregate rate pacing.
38    pub size_bytes: Option<u64>,
39}
40
41/// Shared path and UTC timestamp selection rules.
42#[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    /// Compile a deterministic selection policy.
53    ///
54    /// Include patterns restrict the candidate set when at least one is present. Exclude patterns
55    /// are applied afterwards and always win, independent of command-line flag order. `newer_than`
56    /// and `older_than` use strict comparisons; `rewind` includes the boundary itself.
57    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    /// Return whether a candidate satisfies every configured rule.
77    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/// Deterministic aggregate counters for a transfer plan and its execution.
116#[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/// Selected candidates in deterministic execution and output order.
127#[derive(Debug)]
128pub struct TransferPlan<T> {
129    pub items: Vec<TransferCandidate<T>>,
130    pub summary: TransferSummary,
131}
132
133impl<T> TransferPlan<T> {
134    /// Filter and sort candidates without performing any transfer.
135    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/// Controls applied across the entire transfer command.
160#[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    /// Validate global limits before any work starts.
170    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/// Cooperative cancellation shared by a transfer command and its executor.
207#[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/// Result state for one candidate.
245#[derive(Debug)]
246pub enum TransferOutcomeState {
247    Success { bytes_transferred: u64 },
248    Failed { error: Error },
249    Cancelled { error: Option<Error> },
250}
251
252/// Deterministically ordered result for one candidate.
253#[derive(Debug)]
254pub struct TransferOutcome<T> {
255    pub item: TransferCandidate<T>,
256    pub state: TransferOutcomeState,
257}
258
259/// Aggregate execution result and per-candidate outcomes.
260#[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    /// Return the first failure in deterministic plan order.
269    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/// Executes a transfer plan with shared controls.
281#[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    /// Execute candidates while bounding all in-flight work across the command.
293    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    /// Execute candidates until completion, failure policy, or cooperative cancellation.
304    ///
305    /// Work already in flight is allowed to settle so callers can await backend cleanup.
306    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                // Work that has already started is allowed to settle so the report never marks a
364                // completed side effect as cancelled. No additional candidates are scheduled.
365                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        // Once an operation starts it is allowed to settle so callers can complete backend
454        // cleanup. Cancellation is checked again before any retry is attempted.
455        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}