Skip to main content

kvbm_engine/offload/
handle.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Transfer handle and status tracking for offload operations.
5//!
6//! The `TransferHandle` is the user-facing interface for tracking and controlling
7//! an offload transfer. It provides:
8//! - Status tracking (Evaluating, Queued, Transferring, Complete, Cancelled)
9//! - Block visibility (passed, completed, remaining)
10//! - Cancellation with confirmation
11
12use std::collections::HashSet;
13use std::sync::{Arc, RwLock};
14
15use anyhow::Result;
16use tokio::sync::watch;
17use uuid::Uuid;
18
19use crate::BlockId;
20
21use super::cancel::{CancelConfirmation, CancelStateUpdater, CancellationToken};
22
23/// Unique identifier for a transfer operation.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct TransferId(Uuid);
26
27impl TransferId {
28    /// Create a new random transfer ID.
29    pub fn new() -> Self {
30        TransferId(Uuid::new_v4())
31    }
32
33    /// Get the underlying UUID.
34    pub fn as_uuid(&self) -> Uuid {
35        self.0
36    }
37}
38
39impl Default for TransferId {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl std::fmt::Display for TransferId {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}", self.0)
48    }
49}
50
51impl From<Uuid> for TransferId {
52    fn from(uuid: Uuid) -> Self {
53        TransferId(uuid)
54    }
55}
56
57/// Status of a transfer operation.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum TransferStatus {
60    /// Policy/filter evaluation in progress
61    Evaluating,
62    /// Passed filters, waiting in batch queue
63    Queued,
64    /// Transfer operation in progress
65    Transferring,
66    /// Transfer completed successfully
67    Complete,
68    /// Transfer was cancelled
69    Cancelled,
70    /// Transfer failed with error
71    Failed,
72}
73
74impl TransferStatus {
75    /// Check if the transfer is in a terminal state.
76    pub fn is_terminal(&self) -> bool {
77        matches!(
78            self,
79            TransferStatus::Complete | TransferStatus::Cancelled | TransferStatus::Failed
80        )
81    }
82
83    /// Check if the transfer is still in progress.
84    pub fn is_active(&self) -> bool {
85        !self.is_terminal()
86    }
87}
88
89/// Result of a completed transfer.
90#[derive(Debug, Clone)]
91pub struct TransferResult {
92    /// Transfer ID
93    pub id: TransferId,
94    /// Final status
95    pub status: TransferStatus,
96    /// Blocks that passed all filters
97    pub passed_blocks: Vec<BlockId>,
98    /// Blocks successfully transferred
99    pub completed_blocks: Vec<BlockId>,
100    /// Blocks that failed transfer
101    pub failed_blocks: Vec<BlockId>,
102    /// Blocks that were filtered out
103    pub filtered_blocks: Vec<BlockId>,
104    /// Error message if failed
105    pub error: Option<String>,
106}
107
108/// Monotonic block counts for a transfer.
109///
110/// Counts are published through a watch channel, while the corresponding block
111/// IDs remain in shared progress storage. This keeps progress notifications
112/// constant-size regardless of the number of blocks in the transfer.
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
114pub struct TransferProgressCounts {
115    /// Blocks accepted by policy evaluation.
116    pub passed: usize,
117    /// Blocks transferred successfully.
118    pub completed: usize,
119    /// Blocks whose transfer failed.
120    pub failed: usize,
121}
122
123impl TransferProgressCounts {
124    /// Number of blocks that have reached either success or failure.
125    pub fn settled(self) -> usize {
126        self.completed.saturating_add(self.failed)
127    }
128}
129
130/// Per-consumer cursor for incrementally reading transfer progress.
131///
132/// A cursor belongs to one transfer. Create it with
133/// [`TransferHandle::new_progress_cursor`] and retain it at the consumer.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct TransferProgressCursor {
136    transfer_id: TransferId,
137    passed: usize,
138    completed: usize,
139    failed: usize,
140}
141
142/// Blocks appended since a [`TransferProgressCursor`] was last consumed.
143#[derive(Debug, Clone, Default, PartialEq, Eq)]
144pub struct TransferProgressDelta {
145    /// Blocks newly accepted by policy evaluation.
146    pub passed_blocks: Vec<BlockId>,
147    /// Blocks newly transferred successfully.
148    pub completed_blocks: Vec<BlockId>,
149    /// Blocks whose transfer newly failed.
150    pub failed_blocks: Vec<BlockId>,
151}
152
153impl TransferProgressDelta {
154    /// Whether the cursor observed no new progress.
155    pub fn is_empty(&self) -> bool {
156        self.passed_blocks.is_empty()
157            && self.completed_blocks.is_empty()
158            && self.failed_blocks.is_empty()
159    }
160}
161
162#[derive(Debug)]
163struct TransferProgress {
164    input_blocks: Vec<BlockId>,
165    policy_evaluated: bool,
166    passed_blocks: Vec<BlockId>,
167    completed_blocks: Vec<BlockId>,
168    failed_blocks: Vec<BlockId>,
169}
170
171impl TransferProgress {
172    fn new(input_blocks: Vec<BlockId>) -> Self {
173        Self {
174            input_blocks,
175            policy_evaluated: false,
176            passed_blocks: Vec::new(),
177            completed_blocks: Vec::new(),
178            failed_blocks: Vec::new(),
179        }
180    }
181
182    fn counts(&self) -> TransferProgressCounts {
183        TransferProgressCounts {
184            passed: self.passed_blocks.len(),
185            completed: self.completed_blocks.len(),
186            failed: self.failed_blocks.len(),
187        }
188    }
189}
190
191/// Handle for tracking and controlling an offload transfer.
192///
193/// Obtained from `OffloadEngine::enqueue()`. Use this to:
194/// - Monitor transfer progress via `status()`, `passed_blocks()`, etc.
195/// - Cancel the transfer via `cancel()` and await confirmation
196/// - Wait for completion via `wait()`
197#[derive(Clone)]
198pub struct TransferHandle {
199    id: TransferId,
200    status_rx: watch::Receiver<TransferStatus>,
201    progress: Arc<RwLock<TransferProgress>>,
202    progress_rx: watch::Receiver<TransferProgressCounts>,
203    cancel_token: CancellationToken,
204    result_rx: watch::Receiver<Option<TransferResult>>,
205}
206
207impl TransferHandle {
208    /// Get the transfer ID.
209    pub fn id(&self) -> TransferId {
210        self.id
211    }
212
213    /// Get the current transfer status.
214    pub fn status(&self) -> TransferStatus {
215        *self.status_rx.borrow()
216    }
217
218    /// Get blocks that passed all filter policies.
219    pub fn passed_blocks(&self) -> Vec<BlockId> {
220        self.progress
221            .read()
222            .expect("transfer progress lock poisoned")
223            .passed_blocks
224            .clone()
225    }
226
227    /// Get blocks that have been successfully transferred.
228    pub fn completed_blocks(&self) -> Vec<BlockId> {
229        self.progress
230            .read()
231            .expect("transfer progress lock poisoned")
232            .completed_blocks
233            .clone()
234    }
235
236    /// Get blocks that failed transfer.
237    pub fn failed_blocks(&self) -> Vec<BlockId> {
238        self.progress
239            .read()
240            .expect("transfer progress lock poisoned")
241            .failed_blocks
242            .clone()
243    }
244
245    /// Get blocks remaining to be transferred.
246    pub fn remaining_blocks(&self) -> Vec<BlockId> {
247        let progress = self
248            .progress
249            .read()
250            .expect("transfer progress lock poisoned");
251        if !progress.policy_evaluated {
252            return progress.input_blocks.clone();
253        }
254
255        let settled: HashSet<_> = progress
256            .completed_blocks
257            .iter()
258            .chain(&progress.failed_blocks)
259            .copied()
260            .collect();
261        progress
262            .passed_blocks
263            .iter()
264            .filter(|id| !settled.contains(id))
265            .copied()
266            .collect()
267    }
268
269    /// Return constant-size progress counts without cloning block vectors.
270    pub fn progress_counts(&self) -> TransferProgressCounts {
271        *self.progress_rx.borrow()
272    }
273
274    /// Create a cursor that consumes this transfer's progress from the start.
275    pub fn new_progress_cursor(&self) -> TransferProgressCursor {
276        TransferProgressCursor {
277            transfer_id: self.id,
278            passed: 0,
279            completed: 0,
280            failed: 0,
281        }
282    }
283
284    /// Read each newly passed, completed, or failed block exactly once for this
285    /// cursor, then advance the cursor to the current progress boundary.
286    pub fn consume_progress(&self, cursor: &mut TransferProgressCursor) -> TransferProgressDelta {
287        assert_eq!(
288            cursor.transfer_id, self.id,
289            "transfer progress cursor used with a different handle"
290        );
291        let progress = self
292            .progress
293            .read()
294            .expect("transfer progress lock poisoned");
295        assert!(cursor.passed <= progress.passed_blocks.len());
296        assert!(cursor.completed <= progress.completed_blocks.len());
297        assert!(cursor.failed <= progress.failed_blocks.len());
298
299        let delta = TransferProgressDelta {
300            passed_blocks: progress.passed_blocks[cursor.passed..].to_vec(),
301            completed_blocks: progress.completed_blocks[cursor.completed..].to_vec(),
302            failed_blocks: progress.failed_blocks[cursor.failed..].to_vec(),
303        };
304        cursor.passed = progress.passed_blocks.len();
305        cursor.completed = progress.completed_blocks.len();
306        cursor.failed = progress.failed_blocks.len();
307        delta
308    }
309
310    /// Check if the transfer is complete (success, cancelled, or failed).
311    pub fn is_complete(&self) -> bool {
312        self.status().is_terminal()
313    }
314
315    /// Cancel the transfer and await confirmation.
316    ///
317    /// Returns a future that resolves when all blocks are confirmed released
318    /// with no outstanding operations.
319    ///
320    /// # Example
321    /// ```ignore
322    /// // Request cancellation and wait for confirmation
323    /// handle.cancel().wait().await;
324    /// // All blocks are now released
325    /// ```
326    pub fn cancel(&self) -> CancelConfirmation {
327        self.cancel_token.request();
328        self.cancel_token.wait_confirmed()
329    }
330
331    /// Check if cancellation has been requested.
332    pub fn is_cancelled(&self) -> bool {
333        self.cancel_token.is_requested()
334    }
335
336    /// Wait for the transfer to complete.
337    ///
338    /// Returns the final `TransferResult` when the transfer reaches a terminal state.
339    pub async fn wait(&mut self) -> Result<TransferResult> {
340        // Wait until we have a result
341        loop {
342            {
343                let result = self.result_rx.borrow();
344                if let Some(r) = result.as_ref() {
345                    return Ok(r.clone());
346                }
347            }
348
349            if self.result_rx.changed().await.is_err() {
350                // Channel closed without result
351                return Err(anyhow::anyhow!("Transfer channel closed unexpectedly"));
352            }
353        }
354    }
355
356    /// Subscribe to status changes.
357    pub fn subscribe_status(&self) -> watch::Receiver<TransferStatus> {
358        self.status_rx.clone()
359    }
360
361    /// Subscribe to constant-size block progress counts.
362    pub fn subscribe_progress(&self) -> watch::Receiver<TransferProgressCounts> {
363        self.progress_rx.clone()
364    }
365}
366
367impl std::fmt::Debug for TransferHandle {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        let progress = self.progress_counts();
370        f.debug_struct("TransferHandle")
371            .field("id", &self.id)
372            .field("status", &self.status())
373            .field("passed_count", &progress.passed)
374            .field("completed_count", &progress.completed)
375            .field("failed_count", &progress.failed)
376            .field("remaining_count", &self.remaining_blocks().len())
377            .finish()
378    }
379}
380
381/// Internal state for tracking a transfer through the pipeline.
382#[allow(dead_code)]
383pub(crate) struct TransferState {
384    pub(crate) id: TransferId,
385    /// Current phase
386    pub(crate) status: TransferStatus,
387    /// Shared cumulative block progress. Consumers use monotonic cursors so
388    /// scheduler ticks copy only newly settled IDs.
389    progress: Arc<RwLock<TransferProgress>>,
390    /// Blocks currently in-flight (being transferred)
391    pub(crate) in_flight: HashSet<BlockId>,
392    /// Blocks that failed filters
393    pub(crate) filtered_out: Vec<BlockId>,
394    /// Error message if failed
395    pub(crate) error: Option<String>,
396    /// Notifier channels
397    pub(crate) notifiers: TransferNotifiers,
398    /// Cancel state updater
399    pub(crate) cancel_updater: CancelStateUpdater,
400    /// Total blocks expected in this transfer (set by PolicyEvaluator)
401    pub(crate) total_expected_blocks: usize,
402    /// Blocks that have been processed through policy evaluation (for sentinel flush)
403    pub(crate) blocks_processed: usize,
404    /// Precondition event that must be satisfied before processing this transfer.
405    /// Set by the caller when enqueuing offload operations. BatchCollector will
406    /// attach this to the TransferBatch, and PreconditionAwaiter will await it
407    /// before forwarding to TransferExecutor.
408    pub(crate) precondition: Option<velo::EventHandle>,
409}
410
411#[allow(dead_code)]
412impl TransferState {
413    /// Create transfer state and associated handle.
414    pub(crate) fn new(id: TransferId, input_blocks: Vec<BlockId>) -> (Self, TransferHandle) {
415        let (status_tx, status_rx) = watch::channel(TransferStatus::Evaluating);
416        let progress = Arc::new(RwLock::new(TransferProgress::new(input_blocks)));
417        let (progress_tx, progress_rx) = watch::channel(TransferProgressCounts::default());
418        let (result_tx, result_rx) = watch::channel(None);
419        let (cancel_token, cancel_updater) = CancellationToken::new();
420
421        let notifiers = TransferNotifiers {
422            status_tx,
423            progress_tx,
424            result_tx,
425        };
426
427        let state = TransferState {
428            id,
429            status: TransferStatus::Evaluating,
430            progress: progress.clone(),
431            in_flight: HashSet::new(),
432            filtered_out: Vec::new(),
433            error: None,
434            notifiers,
435            cancel_updater,
436            total_expected_blocks: 0, // Set by PolicyEvaluator when transfer starts
437            blocks_processed: 0,
438            precondition: None, // Set by caller via enqueue_with_precondition
439        };
440
441        let handle = TransferHandle {
442            id,
443            status_rx,
444            progress,
445            progress_rx,
446            cancel_token,
447            result_rx,
448        };
449
450        (state, handle)
451    }
452
453    /// Check if cancellation has been requested.
454    pub(crate) fn is_cancel_requested(&self) -> bool {
455        self.cancel_updater.is_requested()
456    }
457
458    /// Update status and notify.
459    pub(crate) fn set_status(&mut self, status: TransferStatus) {
460        self.status = status;
461        let _ = self.notifiers.status_tx.send(status);
462    }
463
464    /// Add blocks that passed filters.
465    pub(crate) fn add_passed(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
466        let counts = {
467            let mut progress = self
468                .progress
469                .write()
470                .expect("transfer progress lock poisoned");
471            progress.policy_evaluated = true;
472            progress.passed_blocks.extend(block_ids);
473            progress.counts()
474        };
475        let _ = self.notifiers.progress_tx.send(counts);
476    }
477
478    /// Add blocks that were filtered out.
479    pub(crate) fn add_filtered(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
480        self.filtered_out.extend(block_ids);
481    }
482
483    /// Mark blocks as in-flight (being transferred).
484    pub(crate) fn mark_in_flight(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
485        self.in_flight.extend(block_ids);
486    }
487
488    /// Mark blocks as completed (transferred successfully).
489    pub(crate) fn mark_completed(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
490        let completed: Vec<_> = block_ids.into_iter().collect();
491        for id in &completed {
492            self.in_flight.remove(id);
493        }
494        let counts = {
495            let mut progress = self
496                .progress
497                .write()
498                .expect("transfer progress lock poisoned");
499            progress.completed_blocks.extend(completed);
500            progress.counts()
501        };
502        let _ = self.notifiers.progress_tx.send(counts);
503    }
504
505    /// Mark blocks as failed (transfer unsuccessful).
506    pub(crate) fn mark_failed(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
507        let failed: Vec<_> = block_ids.into_iter().collect();
508        for id in &failed {
509            self.in_flight.remove(id);
510        }
511        let counts = {
512            let mut progress = self
513                .progress
514                .write()
515                .expect("transfer progress lock poisoned");
516            progress.failed_blocks.extend(failed);
517            progress.counts()
518        };
519        let _ = self.notifiers.progress_tx.send(counts);
520    }
521
522    /// Set error and mark as failed.
523    pub(crate) fn set_error(&mut self, error: String) {
524        self.error = Some(error);
525        self.set_status(TransferStatus::Failed);
526        self.finalize();
527    }
528
529    /// Mark as cancelled.
530    pub(crate) fn set_cancelled(&mut self) {
531        self.set_status(TransferStatus::Cancelled);
532        self.cancel_updater.set_confirmed();
533        self.finalize();
534    }
535
536    /// Mark as complete (all blocks transferred).
537    pub(crate) fn set_complete(&mut self) {
538        self.set_status(TransferStatus::Complete);
539        self.finalize();
540    }
541
542    /// Finalize and send result.
543    fn finalize(&mut self) {
544        let progress = self
545            .progress
546            .read()
547            .expect("transfer progress lock poisoned");
548        let result = TransferResult {
549            id: self.id,
550            status: self.status,
551            passed_blocks: progress.passed_blocks.clone(),
552            completed_blocks: progress.completed_blocks.clone(),
553            failed_blocks: progress.failed_blocks.clone(),
554            filtered_blocks: self.filtered_out.clone(),
555            error: self.error.clone(),
556        };
557        let _ = self.notifiers.result_tx.send(Some(result));
558    }
559
560    pub(crate) fn progress_counts(&self) -> TransferProgressCounts {
561        self.progress
562            .read()
563            .expect("transfer progress lock poisoned")
564            .counts()
565    }
566
567    /// Get current in-flight count (for draining).
568    pub(crate) fn in_flight_count(&self) -> usize {
569        self.in_flight.len()
570    }
571
572    /// Begin draining (cancellation in progress).
573    pub(crate) fn begin_draining(&self) {
574        self.cancel_updater.set_draining(self.in_flight.len());
575    }
576
577    /// Update draining count.
578    pub(crate) fn update_draining(&self) {
579        self.cancel_updater.update_draining(self.in_flight.len());
580    }
581}
582
583/// Internal notification channels for transfer state updates.
584#[allow(dead_code)]
585pub(crate) struct TransferNotifiers {
586    pub(crate) status_tx: watch::Sender<TransferStatus>,
587    pub(crate) progress_tx: watch::Sender<TransferProgressCounts>,
588    pub(crate) result_tx: watch::Sender<Option<TransferResult>>,
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn test_transfer_id() {
597        let id1 = TransferId::new();
598        let id2 = TransferId::new();
599        assert_ne!(id1, id2);
600    }
601
602    #[test]
603    fn test_transfer_status() {
604        assert!(!TransferStatus::Evaluating.is_terminal());
605        assert!(!TransferStatus::Queued.is_terminal());
606        assert!(!TransferStatus::Transferring.is_terminal());
607        assert!(TransferStatus::Complete.is_terminal());
608        assert!(TransferStatus::Cancelled.is_terminal());
609        assert!(TransferStatus::Failed.is_terminal());
610    }
611
612    #[test]
613    fn test_transfer_state_creation() {
614        let id = TransferId::new();
615        let blocks = vec![1, 2, 3];
616        let (state, handle) = TransferState::new(id, blocks.clone());
617
618        assert_eq!(state.id, id);
619        assert_eq!(state.status, TransferStatus::Evaluating);
620        assert_eq!(state.progress_counts(), TransferProgressCounts::default());
621
622        assert_eq!(handle.id(), id);
623        assert_eq!(handle.status(), TransferStatus::Evaluating);
624        assert_eq!(handle.remaining_blocks(), blocks);
625    }
626
627    #[test]
628    fn test_transfer_state_progress() {
629        let id = TransferId::new();
630        let blocks = vec![1, 2, 3, 4, 5];
631        let (mut state, handle) = TransferState::new(id, blocks);
632
633        // Some blocks pass filters
634        state.add_passed(vec![1, 2, 3]);
635        state.add_filtered(vec![4, 5]);
636        assert_eq!(handle.passed_blocks(), vec![1, 2, 3]);
637
638        // Start transferring
639        state.set_status(TransferStatus::Transferring);
640        state.mark_in_flight(vec![1, 2]);
641        assert_eq!(handle.status(), TransferStatus::Transferring);
642
643        // Complete some
644        state.mark_completed(vec![1]);
645        assert_eq!(handle.completed_blocks(), vec![1]);
646        assert_eq!(state.in_flight_count(), 1);
647
648        // Complete rest
649        state.mark_completed(vec![2, 3]);
650        state.set_complete();
651
652        assert_eq!(handle.status(), TransferStatus::Complete);
653        assert_eq!(handle.completed_blocks(), vec![1, 2, 3]);
654    }
655
656    #[tokio::test]
657    async fn test_transfer_handle_wait() {
658        let id = TransferId::new();
659        let blocks = vec![1, 2, 3];
660        let (mut state, mut handle) = TransferState::new(id, blocks);
661
662        // Spawn task to complete the transfer
663        tokio::spawn(async move {
664            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
665            state.add_passed(vec![1, 2, 3]);
666            state.mark_completed(vec![1, 2, 3]);
667            state.set_complete();
668        });
669
670        // Wait for completion
671        let result = tokio::time::timeout(tokio::time::Duration::from_millis(100), handle.wait())
672            .await
673            .expect("Should complete within timeout")
674            .expect("Should succeed");
675
676        assert_eq!(result.status, TransferStatus::Complete);
677        assert_eq!(result.completed_blocks, vec![1, 2, 3]);
678    }
679
680    #[test]
681    fn test_mark_failed_removes_from_in_flight() {
682        let id = TransferId::new();
683        let blocks = vec![1, 2, 3];
684        let (mut state, handle) = TransferState::new(id, blocks);
685
686        state.add_passed(vec![1, 2, 3]);
687        state.mark_in_flight(vec![1, 2, 3]);
688        assert_eq!(state.in_flight_count(), 3);
689
690        state.mark_failed(vec![2]);
691        assert_eq!(state.in_flight_count(), 2);
692        assert_eq!(handle.failed_blocks(), vec![2]);
693        assert!(handle.completed_blocks().is_empty());
694    }
695
696    #[test]
697    fn test_mark_failed_updates_remaining() {
698        let id = TransferId::new();
699        let blocks = vec![1, 2, 3];
700        let (mut state, handle) = TransferState::new(id, blocks);
701
702        state.add_passed(vec![1, 2, 3]);
703        state.mark_in_flight(vec![1, 2, 3]);
704
705        // Fail block 2 — remaining should exclude it
706        state.mark_failed(vec![2]);
707        let remaining = handle.remaining_blocks();
708        assert!(remaining.contains(&1));
709        assert!(!remaining.contains(&2));
710        assert!(remaining.contains(&3));
711    }
712
713    #[test]
714    fn test_partial_failure_result() {
715        let id = TransferId::new();
716        let blocks = vec![1, 2, 3, 4, 5];
717        let (mut state, handle) = TransferState::new(id, blocks);
718
719        state.add_passed(vec![1, 2, 3]);
720        state.add_filtered(vec![4, 5]);
721        state.mark_in_flight(vec![1, 2, 3]);
722
723        // Block 1 succeeds, block 2 fails, block 3 succeeds
724        state.mark_completed(vec![1, 3]);
725        state.mark_failed(vec![2]);
726
727        assert_eq!(handle.completed_blocks(), vec![1, 3]);
728        assert_eq!(handle.failed_blocks(), vec![2]);
729        assert_eq!(state.in_flight_count(), 0);
730
731        // Simulate the pipeline's terminal state logic
732        let progress = state.progress_counts();
733        let total = progress.passed + state.filtered_out.len();
734        let done = progress.settled() + state.filtered_out.len();
735        assert_eq!(done, total);
736
737        // With failures, should set_error not set_complete
738        let failed_count = progress.failed;
739        assert!(failed_count > 0);
740        state.set_error(format!(
741            "{failed_count} blocks failed to transfer to object storage",
742        ));
743        assert_eq!(state.status, TransferStatus::Failed);
744    }
745
746    #[test]
747    fn test_progress_cursor_consumes_each_block_once() {
748        let id = TransferId::new();
749        let (mut state, handle) = TransferState::new(id, vec![1, 2, 3]);
750        let mut cursor = handle.new_progress_cursor();
751
752        state.add_passed([1, 2, 3]);
753        state.mark_completed([1]);
754        let first = handle.consume_progress(&mut cursor);
755        assert_eq!(first.passed_blocks, vec![1, 2, 3]);
756        assert_eq!(first.completed_blocks, vec![1]);
757        assert!(first.failed_blocks.is_empty());
758
759        state.mark_completed([2]);
760        state.mark_failed([3]);
761        let second = handle.consume_progress(&mut cursor);
762        assert!(second.passed_blocks.is_empty());
763        assert_eq!(second.completed_blocks, vec![2]);
764        assert_eq!(second.failed_blocks, vec![3]);
765        assert!(handle.consume_progress(&mut cursor).is_empty());
766        assert_eq!(
767            handle.progress_counts(),
768            TransferProgressCounts {
769                passed: 3,
770                completed: 2,
771                failed: 1,
772            }
773        );
774    }
775
776    #[tokio::test]
777    async fn test_partial_failure_wait_result() {
778        let id = TransferId::new();
779        let blocks = vec![1, 2, 3];
780        let (mut state, mut handle) = TransferState::new(id, blocks);
781
782        tokio::spawn(async move {
783            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
784            state.add_passed(vec![1, 2, 3]);
785            state.mark_in_flight(vec![1, 2, 3]);
786            state.mark_completed(vec![1, 3]);
787            state.mark_failed(vec![2]);
788            state.set_error("1 blocks failed to transfer to object storage".to_string());
789        });
790
791        let result = tokio::time::timeout(tokio::time::Duration::from_millis(100), handle.wait())
792            .await
793            .expect("Should complete within timeout")
794            .expect("Should succeed");
795
796        assert_eq!(result.status, TransferStatus::Failed);
797        assert_eq!(result.completed_blocks, vec![1, 3]);
798        assert_eq!(result.failed_blocks, vec![2]);
799        assert!(result.error.is_some());
800    }
801}