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;
13
14use anyhow::Result;
15use tokio::sync::watch;
16use uuid::Uuid;
17
18use crate::BlockId;
19
20use super::cancel::{CancelConfirmation, CancelStateUpdater, CancellationToken};
21
22/// Unique identifier for a transfer operation.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct TransferId(Uuid);
25
26impl TransferId {
27    /// Create a new random transfer ID.
28    pub fn new() -> Self {
29        TransferId(Uuid::new_v4())
30    }
31
32    /// Get the underlying UUID.
33    pub fn as_uuid(&self) -> Uuid {
34        self.0
35    }
36}
37
38impl Default for TransferId {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl std::fmt::Display for TransferId {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        write!(f, "{}", self.0)
47    }
48}
49
50impl From<Uuid> for TransferId {
51    fn from(uuid: Uuid) -> Self {
52        TransferId(uuid)
53    }
54}
55
56/// Status of a transfer operation.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum TransferStatus {
59    /// Policy/filter evaluation in progress
60    Evaluating,
61    /// Passed filters, waiting in batch queue
62    Queued,
63    /// Transfer operation in progress
64    Transferring,
65    /// Transfer completed successfully
66    Complete,
67    /// Transfer was cancelled
68    Cancelled,
69    /// Transfer failed with error
70    Failed,
71}
72
73impl TransferStatus {
74    /// Check if the transfer is in a terminal state.
75    pub fn is_terminal(&self) -> bool {
76        matches!(
77            self,
78            TransferStatus::Complete | TransferStatus::Cancelled | TransferStatus::Failed
79        )
80    }
81
82    /// Check if the transfer is still in progress.
83    pub fn is_active(&self) -> bool {
84        !self.is_terminal()
85    }
86}
87
88/// Result of a completed transfer.
89#[derive(Debug, Clone)]
90pub struct TransferResult {
91    /// Transfer ID
92    pub id: TransferId,
93    /// Final status
94    pub status: TransferStatus,
95    /// Blocks that passed all filters
96    pub passed_blocks: Vec<BlockId>,
97    /// Blocks successfully transferred
98    pub completed_blocks: Vec<BlockId>,
99    /// Blocks that failed transfer
100    pub failed_blocks: Vec<BlockId>,
101    /// Blocks that were filtered out
102    pub filtered_blocks: Vec<BlockId>,
103    /// Error message if failed
104    pub error: Option<String>,
105}
106
107/// Handle for tracking and controlling an offload transfer.
108///
109/// Obtained from `OffloadEngine::enqueue()`. Use this to:
110/// - Monitor transfer progress via `status()`, `passed_blocks()`, etc.
111/// - Cancel the transfer via `cancel()` and await confirmation
112/// - Wait for completion via `wait()`
113#[derive(Clone)]
114pub struct TransferHandle {
115    id: TransferId,
116    status_rx: watch::Receiver<TransferStatus>,
117    passed_blocks_rx: watch::Receiver<Vec<BlockId>>,
118    completed_rx: watch::Receiver<Vec<BlockId>>,
119    failed_rx: watch::Receiver<Vec<BlockId>>,
120    remaining_rx: watch::Receiver<Vec<BlockId>>,
121    cancel_token: CancellationToken,
122    result_rx: watch::Receiver<Option<TransferResult>>,
123}
124
125impl TransferHandle {
126    /// Get the transfer ID.
127    pub fn id(&self) -> TransferId {
128        self.id
129    }
130
131    /// Get the current transfer status.
132    pub fn status(&self) -> TransferStatus {
133        *self.status_rx.borrow()
134    }
135
136    /// Get blocks that passed all filter policies.
137    pub fn passed_blocks(&self) -> Vec<BlockId> {
138        self.passed_blocks_rx.borrow().clone()
139    }
140
141    /// Get blocks that have been successfully transferred.
142    pub fn completed_blocks(&self) -> Vec<BlockId> {
143        self.completed_rx.borrow().clone()
144    }
145
146    /// Get blocks that failed transfer.
147    pub fn failed_blocks(&self) -> Vec<BlockId> {
148        self.failed_rx.borrow().clone()
149    }
150
151    /// Get blocks remaining to be transferred.
152    pub fn remaining_blocks(&self) -> Vec<BlockId> {
153        self.remaining_rx.borrow().clone()
154    }
155
156    /// Check if the transfer is complete (success, cancelled, or failed).
157    pub fn is_complete(&self) -> bool {
158        self.status().is_terminal()
159    }
160
161    /// Cancel the transfer and await confirmation.
162    ///
163    /// Returns a future that resolves when all blocks are confirmed released
164    /// with no outstanding operations.
165    ///
166    /// # Example
167    /// ```ignore
168    /// // Request cancellation and wait for confirmation
169    /// handle.cancel().wait().await;
170    /// // All blocks are now released
171    /// ```
172    pub fn cancel(&self) -> CancelConfirmation {
173        self.cancel_token.request();
174        self.cancel_token.wait_confirmed()
175    }
176
177    /// Check if cancellation has been requested.
178    pub fn is_cancelled(&self) -> bool {
179        self.cancel_token.is_requested()
180    }
181
182    /// Wait for the transfer to complete.
183    ///
184    /// Returns the final `TransferResult` when the transfer reaches a terminal state.
185    pub async fn wait(&mut self) -> Result<TransferResult> {
186        // Wait until we have a result
187        loop {
188            {
189                let result = self.result_rx.borrow();
190                if let Some(r) = result.as_ref() {
191                    return Ok(r.clone());
192                }
193            }
194
195            if self.result_rx.changed().await.is_err() {
196                // Channel closed without result
197                return Err(anyhow::anyhow!("Transfer channel closed unexpectedly"));
198            }
199        }
200    }
201
202    /// Subscribe to status changes.
203    pub fn subscribe_status(&self) -> watch::Receiver<TransferStatus> {
204        self.status_rx.clone()
205    }
206
207    /// Subscribe to completed-block progress.
208    pub fn subscribe_completed(&self) -> watch::Receiver<Vec<BlockId>> {
209        self.completed_rx.clone()
210    }
211
212    /// Subscribe to failed-block progress.
213    pub fn subscribe_failed(&self) -> watch::Receiver<Vec<BlockId>> {
214        self.failed_rx.clone()
215    }
216}
217
218impl std::fmt::Debug for TransferHandle {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.debug_struct("TransferHandle")
221            .field("id", &self.id)
222            .field("status", &self.status())
223            .field("passed_count", &self.passed_blocks().len())
224            .field("completed_count", &self.completed_blocks().len())
225            .field("failed_count", &self.failed_blocks().len())
226            .field("remaining_count", &self.remaining_blocks().len())
227            .finish()
228    }
229}
230
231/// Internal state for tracking a transfer through the pipeline.
232#[allow(dead_code)]
233pub(crate) struct TransferState {
234    pub(crate) id: TransferId,
235    /// Current phase
236    pub(crate) status: TransferStatus,
237    /// Original input block IDs
238    pub(crate) input_blocks: Vec<BlockId>,
239    /// Blocks that passed policy filters
240    pub(crate) passed_blocks: Vec<BlockId>,
241    /// Blocks currently in-flight (being transferred)
242    pub(crate) in_flight: HashSet<BlockId>,
243    /// Successfully transferred blocks
244    pub(crate) completed: Vec<BlockId>,
245    /// Blocks that failed transfer
246    pub(crate) failed: Vec<BlockId>,
247    /// Blocks that failed filters
248    pub(crate) filtered_out: Vec<BlockId>,
249    /// Error message if failed
250    pub(crate) error: Option<String>,
251    /// Notifier channels
252    pub(crate) notifiers: TransferNotifiers,
253    /// Cancel state updater
254    pub(crate) cancel_updater: CancelStateUpdater,
255    /// Total blocks expected in this transfer (set by PolicyEvaluator)
256    pub(crate) total_expected_blocks: usize,
257    /// Blocks that have been processed through policy evaluation (for sentinel flush)
258    pub(crate) blocks_processed: usize,
259    /// Precondition event that must be satisfied before processing this transfer.
260    /// Set by the caller when enqueuing offload operations. BatchCollector will
261    /// attach this to the TransferBatch, and PreconditionAwaiter will await it
262    /// before forwarding to TransferExecutor.
263    pub(crate) precondition: Option<velo::EventHandle>,
264}
265
266#[allow(dead_code)]
267impl TransferState {
268    /// Create transfer state and associated handle.
269    pub(crate) fn new(id: TransferId, input_blocks: Vec<BlockId>) -> (Self, TransferHandle) {
270        let (status_tx, status_rx) = watch::channel(TransferStatus::Evaluating);
271        let (passed_tx, passed_rx) = watch::channel(Vec::new());
272        let (completed_tx, completed_rx) = watch::channel(Vec::new());
273        let (failed_tx, failed_rx) = watch::channel(Vec::new());
274        let (remaining_tx, remaining_rx) = watch::channel(input_blocks.clone());
275        let (result_tx, result_rx) = watch::channel(None);
276        let (cancel_token, cancel_updater) = CancellationToken::new();
277
278        let notifiers = TransferNotifiers {
279            status_tx,
280            passed_tx,
281            completed_tx,
282            failed_tx,
283            remaining_tx,
284            result_tx,
285        };
286
287        let state = TransferState {
288            id,
289            status: TransferStatus::Evaluating,
290            input_blocks: input_blocks.clone(),
291            passed_blocks: Vec::new(),
292            in_flight: HashSet::new(),
293            completed: Vec::new(),
294            failed: Vec::new(),
295            filtered_out: Vec::new(),
296            error: None,
297            notifiers,
298            cancel_updater,
299            total_expected_blocks: 0, // Set by PolicyEvaluator when transfer starts
300            blocks_processed: 0,
301            precondition: None, // Set by caller via enqueue_with_precondition
302        };
303
304        let handle = TransferHandle {
305            id,
306            status_rx,
307            passed_blocks_rx: passed_rx,
308            completed_rx,
309            failed_rx,
310            remaining_rx,
311            cancel_token,
312            result_rx,
313        };
314
315        (state, handle)
316    }
317
318    /// Check if cancellation has been requested.
319    pub(crate) fn is_cancel_requested(&self) -> bool {
320        self.cancel_updater.is_requested()
321    }
322
323    /// Update status and notify.
324    pub(crate) fn set_status(&mut self, status: TransferStatus) {
325        self.status = status;
326        let _ = self.notifiers.status_tx.send(status);
327    }
328
329    /// Add blocks that passed filters.
330    pub(crate) fn add_passed(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
331        self.passed_blocks.extend(block_ids);
332        let _ = self.notifiers.passed_tx.send(self.passed_blocks.clone());
333        self.update_remaining();
334    }
335
336    /// Add blocks that were filtered out.
337    pub(crate) fn add_filtered(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
338        self.filtered_out.extend(block_ids);
339        self.update_remaining();
340    }
341
342    /// Mark blocks as in-flight (being transferred).
343    pub(crate) fn mark_in_flight(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
344        self.in_flight.extend(block_ids);
345    }
346
347    /// Mark blocks as completed (transferred successfully).
348    pub(crate) fn mark_completed(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
349        for id in block_ids {
350            self.in_flight.remove(&id);
351            self.completed.push(id);
352        }
353        let _ = self.notifiers.completed_tx.send(self.completed.clone());
354        self.update_remaining();
355    }
356
357    /// Mark blocks as failed (transfer unsuccessful).
358    pub(crate) fn mark_failed(&mut self, block_ids: impl IntoIterator<Item = BlockId>) {
359        for id in block_ids {
360            self.in_flight.remove(&id);
361            self.failed.push(id);
362        }
363        let _ = self.notifiers.failed_tx.send(self.failed.clone());
364        self.update_remaining();
365    }
366
367    /// Update remaining blocks notification.
368    fn update_remaining(&self) {
369        let remaining: Vec<BlockId> = self
370            .passed_blocks
371            .iter()
372            .filter(|id| !self.completed.contains(id) && !self.failed.contains(id))
373            .copied()
374            .collect();
375        let _ = self.notifiers.remaining_tx.send(remaining);
376    }
377
378    /// Set error and mark as failed.
379    pub(crate) fn set_error(&mut self, error: String) {
380        self.error = Some(error);
381        self.set_status(TransferStatus::Failed);
382        self.finalize();
383    }
384
385    /// Mark as cancelled.
386    pub(crate) fn set_cancelled(&mut self) {
387        self.set_status(TransferStatus::Cancelled);
388        self.cancel_updater.set_confirmed();
389        self.finalize();
390    }
391
392    /// Mark as complete (all blocks transferred).
393    pub(crate) fn set_complete(&mut self) {
394        self.set_status(TransferStatus::Complete);
395        self.finalize();
396    }
397
398    /// Finalize and send result.
399    fn finalize(&mut self) {
400        let result = TransferResult {
401            id: self.id,
402            status: self.status,
403            passed_blocks: self.passed_blocks.clone(),
404            completed_blocks: self.completed.clone(),
405            failed_blocks: self.failed.clone(),
406            filtered_blocks: self.filtered_out.clone(),
407            error: self.error.clone(),
408        };
409        let _ = self.notifiers.result_tx.send(Some(result));
410    }
411
412    /// Get current in-flight count (for draining).
413    pub(crate) fn in_flight_count(&self) -> usize {
414        self.in_flight.len()
415    }
416
417    /// Begin draining (cancellation in progress).
418    pub(crate) fn begin_draining(&self) {
419        self.cancel_updater.set_draining(self.in_flight.len());
420    }
421
422    /// Update draining count.
423    pub(crate) fn update_draining(&self) {
424        self.cancel_updater.update_draining(self.in_flight.len());
425    }
426}
427
428/// Internal notification channels for transfer state updates.
429#[allow(dead_code)]
430pub(crate) struct TransferNotifiers {
431    pub(crate) status_tx: watch::Sender<TransferStatus>,
432    pub(crate) passed_tx: watch::Sender<Vec<BlockId>>,
433    pub(crate) completed_tx: watch::Sender<Vec<BlockId>>,
434    pub(crate) failed_tx: watch::Sender<Vec<BlockId>>,
435    pub(crate) remaining_tx: watch::Sender<Vec<BlockId>>,
436    pub(crate) result_tx: watch::Sender<Option<TransferResult>>,
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_transfer_id() {
445        let id1 = TransferId::new();
446        let id2 = TransferId::new();
447        assert_ne!(id1, id2);
448    }
449
450    #[test]
451    fn test_transfer_status() {
452        assert!(!TransferStatus::Evaluating.is_terminal());
453        assert!(!TransferStatus::Queued.is_terminal());
454        assert!(!TransferStatus::Transferring.is_terminal());
455        assert!(TransferStatus::Complete.is_terminal());
456        assert!(TransferStatus::Cancelled.is_terminal());
457        assert!(TransferStatus::Failed.is_terminal());
458    }
459
460    #[test]
461    fn test_transfer_state_creation() {
462        let id = TransferId::new();
463        let blocks = vec![1, 2, 3];
464        let (state, handle) = TransferState::new(id, blocks.clone());
465
466        assert_eq!(state.id, id);
467        assert_eq!(state.status, TransferStatus::Evaluating);
468        assert_eq!(state.input_blocks, blocks);
469        assert!(state.passed_blocks.is_empty());
470        assert!(state.completed.is_empty());
471
472        assert_eq!(handle.id(), id);
473        assert_eq!(handle.status(), TransferStatus::Evaluating);
474        assert_eq!(handle.remaining_blocks(), blocks);
475    }
476
477    #[test]
478    fn test_transfer_state_progress() {
479        let id = TransferId::new();
480        let blocks = vec![1, 2, 3, 4, 5];
481        let (mut state, handle) = TransferState::new(id, blocks);
482
483        // Some blocks pass filters
484        state.add_passed(vec![1, 2, 3]);
485        state.add_filtered(vec![4, 5]);
486        assert_eq!(handle.passed_blocks(), vec![1, 2, 3]);
487
488        // Start transferring
489        state.set_status(TransferStatus::Transferring);
490        state.mark_in_flight(vec![1, 2]);
491        assert_eq!(handle.status(), TransferStatus::Transferring);
492
493        // Complete some
494        state.mark_completed(vec![1]);
495        assert_eq!(handle.completed_blocks(), vec![1]);
496        assert_eq!(state.in_flight_count(), 1);
497
498        // Complete rest
499        state.mark_completed(vec![2, 3]);
500        state.set_complete();
501
502        assert_eq!(handle.status(), TransferStatus::Complete);
503        assert_eq!(handle.completed_blocks(), vec![1, 2, 3]);
504    }
505
506    #[tokio::test]
507    async fn test_transfer_handle_wait() {
508        let id = TransferId::new();
509        let blocks = vec![1, 2, 3];
510        let (mut state, mut handle) = TransferState::new(id, blocks);
511
512        // Spawn task to complete the transfer
513        tokio::spawn(async move {
514            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
515            state.add_passed(vec![1, 2, 3]);
516            state.mark_completed(vec![1, 2, 3]);
517            state.set_complete();
518        });
519
520        // Wait for completion
521        let result = tokio::time::timeout(tokio::time::Duration::from_millis(100), handle.wait())
522            .await
523            .expect("Should complete within timeout")
524            .expect("Should succeed");
525
526        assert_eq!(result.status, TransferStatus::Complete);
527        assert_eq!(result.completed_blocks, vec![1, 2, 3]);
528    }
529
530    #[test]
531    fn test_mark_failed_removes_from_in_flight() {
532        let id = TransferId::new();
533        let blocks = vec![1, 2, 3];
534        let (mut state, handle) = TransferState::new(id, blocks);
535
536        state.add_passed(vec![1, 2, 3]);
537        state.mark_in_flight(vec![1, 2, 3]);
538        assert_eq!(state.in_flight_count(), 3);
539
540        state.mark_failed(vec![2]);
541        assert_eq!(state.in_flight_count(), 2);
542        assert_eq!(handle.failed_blocks(), vec![2]);
543        assert!(handle.completed_blocks().is_empty());
544    }
545
546    #[test]
547    fn test_mark_failed_updates_remaining() {
548        let id = TransferId::new();
549        let blocks = vec![1, 2, 3];
550        let (mut state, handle) = TransferState::new(id, blocks);
551
552        state.add_passed(vec![1, 2, 3]);
553        state.mark_in_flight(vec![1, 2, 3]);
554
555        // Fail block 2 — remaining should exclude it
556        state.mark_failed(vec![2]);
557        let remaining = handle.remaining_blocks();
558        assert!(remaining.contains(&1));
559        assert!(!remaining.contains(&2));
560        assert!(remaining.contains(&3));
561    }
562
563    #[test]
564    fn test_partial_failure_result() {
565        let id = TransferId::new();
566        let blocks = vec![1, 2, 3, 4, 5];
567        let (mut state, _handle) = TransferState::new(id, blocks);
568
569        state.add_passed(vec![1, 2, 3]);
570        state.add_filtered(vec![4, 5]);
571        state.mark_in_flight(vec![1, 2, 3]);
572
573        // Block 1 succeeds, block 2 fails, block 3 succeeds
574        state.mark_completed(vec![1, 3]);
575        state.mark_failed(vec![2]);
576
577        assert_eq!(state.completed, vec![1, 3]);
578        assert_eq!(state.failed, vec![2]);
579        assert_eq!(state.in_flight_count(), 0);
580
581        // Simulate the pipeline's terminal state logic
582        let total = state.passed_blocks.len() + state.filtered_out.len();
583        let done = state.completed.len() + state.failed.len() + state.filtered_out.len();
584        assert_eq!(done, total);
585
586        // With failures, should set_error not set_complete
587        let failed_count = state.failed.len();
588        assert!(failed_count > 0);
589        state.set_error(format!(
590            "{failed_count} blocks failed to transfer to object storage",
591        ));
592        assert_eq!(state.status, TransferStatus::Failed);
593    }
594
595    #[tokio::test]
596    async fn test_partial_failure_wait_result() {
597        let id = TransferId::new();
598        let blocks = vec![1, 2, 3];
599        let (mut state, mut handle) = TransferState::new(id, blocks);
600
601        tokio::spawn(async move {
602            tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
603            state.add_passed(vec![1, 2, 3]);
604            state.mark_in_flight(vec![1, 2, 3]);
605            state.mark_completed(vec![1, 3]);
606            state.mark_failed(vec![2]);
607            state.set_error("1 blocks failed to transfer to object storage".to_string());
608        });
609
610        let result = tokio::time::timeout(tokio::time::Duration::from_millis(100), handle.wait())
611            .await
612            .expect("Should complete within timeout")
613            .expect("Should succeed");
614
615        assert_eq!(result.status, TransferStatus::Failed);
616        assert_eq!(result.completed_blocks, vec![1, 3]);
617        assert_eq!(result.failed_blocks, vec![2]);
618        assert!(result.error.is_some());
619    }
620}