Skip to main content

kvbm_engine/offload/
pipeline.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pipeline coordination for offload transfers.
5//!
6//! A pipeline connects these stages:
7//! 1. **PolicyEvaluator**: Evaluates blocks against policies, filters out non-passing blocks
8//! 2. **BatchCollector**: Accumulates passing blocks into batches
9//! 3. **PreconditionAwaiter**: Awaits precondition events before processing
10//! 4. **BlockUpgrader**: Upgrades `WeakBlock` → `ImmutableBlock` (via `upgrade_batch`)
11//! 5. **Transfer Executor**: Executes the actual data transfer
12//!    - `BlockTransferExecutor`: For BlockManager destinations (G2, G3)
13//!    - `ObjectTransferExecutor`: For object storage destinations (G4)
14//!
15//! # Cancellation Architecture
16//!
17//! Unlike mpsc-based pipelines where cancellation only happens at dequeue boundaries,
18//! this implementation uses [`CancellableQueue`] which enables a dedicated sweeper task
19//! to actively remove items from cancelled transfers. This ensures that `ImmutableBlock`
20//! guards are dropped promptly when a transfer is cancelled.
21//!
22//! ```text
23//! enqueue() ─┬─► [CancellableQueue A] ──► PolicyEvaluator ──┬─► [CancellableQueue B] ──► ...
24//!            │                                              │
25//!            └──────────────► [CancelSweeper] ◄─────────────┘
26//!                                    │
27//!                              (iterates queues,
28//!                               removes by TransferId,
29//!                               drops ImmutableBlock guards)
30//! ```
31
32use std::collections::HashSet;
33use std::marker::PhantomData;
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37use futures::future::Either;
38use tokio::sync::{Semaphore, mpsc, watch};
39use tokio::task::JoinHandle;
40
41use crate::leader::InstanceLeader;
42use crate::object::ObjectBlockOps;
43use crate::{BlockId, SequenceHash};
44use kvbm_common::LogicalLayoutHandle;
45use kvbm_logical::blocks::{BlockMetadata, BlockRegistry, ImmutableBlock};
46use kvbm_logical::manager::BlockManager;
47use kvbm_physical::transfer::TransferOptions;
48
49use super::batch::{
50    BatchCollector, BatchConfig, BatchOutputRx, EvalResult, QueuedBlock, TimingTrace, TransferBatch,
51};
52use super::handle::{TransferId, TransferState, TransferStatus};
53use super::pending::PendingTracker;
54use super::policy::{EvalContext, OffloadPolicy};
55use super::queue::CancellableQueue;
56use super::source::{SourceBlock, SourceBlocks};
57use crate::object::ObjectLockManager;
58
59/// Configuration for a pipeline.
60#[derive(Clone)]
61pub struct PipelineConfig<Src: BlockMetadata, Dst: BlockMetadata> {
62    /// Policies to evaluate (all must pass)
63    pub policies: Vec<Arc<dyn OffloadPolicy<Src>>>,
64    /// Batch configuration
65    pub batch_config: BatchConfig,
66    /// Timeout for policy evaluation (fail-fast)
67    pub policy_timeout: Duration,
68    /// Whether arrivals from this pipeline auto-feed downstream
69    pub auto_chain: bool,
70    /// Channel capacity for evaluation input
71    pub eval_input_capacity: usize,
72    /// Channel capacity for batch input
73    pub batch_input_capacity: usize,
74    /// Channel capacity for transfer input
75    pub transfer_input_capacity: usize,
76    /// Sweep interval for cancellation task
77    pub sweep_interval: Duration,
78    /// Skip actual transfers (for testing)
79    pub skip_transfers: bool,
80    /// Maximum number of concurrent transfer batches.
81    ///
82    /// This controls how many batches can be transferred simultaneously.
83    /// Setting this higher can improve throughput at the cost of memory.
84    /// Default: 1 (sequential execution)
85    pub max_concurrent_transfers: usize,
86    /// Pending tracker for duplicate prevention.
87    ///
88    /// If provided, this tracker is used. If None, the pipeline creates its own.
89    /// Share this tracker with presence-based policies to prevent duplicate transfers.
90    pub pending_tracker: Option<Arc<PendingTracker>>,
91    /// Maximum number of concurrent precondition awaits.
92    ///
93    /// This controls how many batches can be awaiting their preconditions simultaneously.
94    /// Allows multiple iterations to be in-flight without blocking the pipeline.
95    /// Default: 8 (allows ~8 iterations in-flight concurrently)
96    pub max_concurrent_precondition_awaits: usize,
97    /// Marker
98    _marker: PhantomData<(Src, Dst)>,
99}
100
101impl<Src: BlockMetadata, Dst: BlockMetadata> Default for PipelineConfig<Src, Dst> {
102    fn default() -> Self {
103        Self {
104            policies: Vec::new(),
105            batch_config: BatchConfig::default(),
106            policy_timeout: Duration::from_millis(100),
107            auto_chain: false,
108            eval_input_capacity: 128,
109            batch_input_capacity: 256,
110            transfer_input_capacity: 8,
111            sweep_interval: Duration::from_millis(10),
112            skip_transfers: false,
113            max_concurrent_transfers: 1,
114            pending_tracker: None,
115            max_concurrent_precondition_awaits: 8,
116            _marker: PhantomData,
117        }
118    }
119}
120
121/// Builder for pipeline configuration.
122pub struct PipelineBuilder<Src: BlockMetadata, Dst: BlockMetadata> {
123    config: PipelineConfig<Src, Dst>,
124}
125
126impl<Src: BlockMetadata, Dst: BlockMetadata> PipelineBuilder<Src, Dst> {
127    /// Create a new pipeline builder with defaults.
128    pub fn new() -> Self {
129        Self {
130            config: PipelineConfig::default(),
131        }
132    }
133
134    /// Add a policy to the pipeline.
135    pub fn policy(mut self, policy: Arc<dyn OffloadPolicy<Src>>) -> Self {
136        self.config.policies.push(policy);
137        self
138    }
139
140    /// Set batch size.
141    pub fn batch_size(mut self, size: usize) -> Self {
142        self.config.batch_config.max_batch_size = size;
143        self
144    }
145
146    /// Set minimum batch size for flush.
147    pub fn min_batch_size(mut self, size: usize) -> Self {
148        self.config.batch_config.min_batch_size = size;
149        self
150    }
151
152    /// Set batch flush interval.
153    pub fn flush_interval(mut self, interval: Duration) -> Self {
154        self.config.batch_config.flush_interval = interval;
155        self
156    }
157
158    /// Set policy timeout.
159    pub fn policy_timeout(mut self, timeout: Duration) -> Self {
160        self.config.policy_timeout = timeout;
161        self
162    }
163
164    /// Enable auto-chaining to downstream pipelines.
165    pub fn auto_chain(mut self, enabled: bool) -> Self {
166        self.config.auto_chain = enabled;
167        self
168    }
169
170    /// Set the sweep interval for cancellation.
171    pub fn sweep_interval(mut self, interval: Duration) -> Self {
172        self.config.sweep_interval = interval;
173        self
174    }
175
176    /// Skip actual transfers (for testing).
177    ///
178    /// When enabled, the transfer executor will mark blocks as completed
179    /// without executing actual data transfers.
180    pub fn skip_transfers(mut self, skip: bool) -> Self {
181        self.config.skip_transfers = skip;
182        self
183    }
184
185    /// Set maximum concurrent transfers.
186    ///
187    /// This controls how many batches can be transferred simultaneously.
188    /// Must be at least 1.
189    ///
190    /// # Default
191    /// 1 (sequential execution)
192    pub fn max_concurrent_transfers(mut self, n: usize) -> Self {
193        self.config.max_concurrent_transfers = n.max(1);
194        self
195    }
196
197    /// Set the pending tracker for duplicate prevention.
198    ///
199    /// Share this tracker with presence-based policies (via `create_policy_from_config`)
200    /// to prevent duplicate transfers when overlapping sequences are enqueued.
201    pub fn pending_tracker(mut self, tracker: Arc<PendingTracker>) -> Self {
202        self.config.pending_tracker = Some(tracker);
203        self
204    }
205
206    /// Build the configuration.
207    pub fn build(self) -> PipelineConfig<Src, Dst> {
208        self.config
209    }
210}
211
212impl<Src: BlockMetadata, Dst: BlockMetadata> Default for PipelineBuilder<Src, Dst> {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218/// Input to the pipeline (from enqueue).
219pub(crate) struct PipelineInput<T: BlockMetadata> {
220    pub(crate) transfer_id: TransferId,
221    /// Source blocks - can be External, Strong, or Weak
222    pub(crate) source: SourceBlocks<T>,
223    pub(crate) state: Arc<std::sync::Mutex<TransferState>>,
224}
225
226/// Output from the pipeline (completed transfer).
227pub struct PipelineOutput {
228    pub transfer_id: TransferId,
229    pub completed_hashes: Vec<SequenceHash>,
230}
231
232/// Chain output - carries registered blocks for downstream pipelines.
233///
234/// When `auto_chain` is enabled, the pipeline sends registered blocks
235/// through this channel instead of dropping them. The receiving pipeline
236/// can then process them through its own policy evaluation and transfer.
237pub struct ChainOutput<T: BlockMetadata> {
238    pub transfer_id: TransferId,
239    pub blocks: Vec<ImmutableBlock<T>>,
240    /// State for transfer tracking (used when feeding downstream pipelines)
241    #[allow(dead_code)]
242    pub(crate) state: Arc<std::sync::Mutex<TransferState>>,
243}
244
245/// Receiver for chain output from a pipeline.
246pub type ChainOutputRx<T> = mpsc::Receiver<ChainOutput<T>>;
247
248/// A running pipeline instance.
249pub struct Pipeline<Src: BlockMetadata, Dst: BlockMetadata> {
250    config: PipelineConfig<Src, Dst>,
251    /// Input queue for new blocks (CancellableQueue for sweep support)
252    pub(crate) eval_queue: Arc<CancellableQueue<PipelineInput<Src>>>,
253    /// Output channel for completed blocks (may feed downstream)
254    output_tx: Option<mpsc::Sender<PipelineOutput>>,
255    /// Chain output receiver - provides registered blocks for downstream pipelines
256    chain_rx: Option<ChainOutputRx<Dst>>,
257    /// Watch channel for cancelled transfer IDs (triggers sweep)
258    cancel_tx: watch::Sender<HashSet<TransferId>>,
259    /// Tracker for pending (in-flight) transfers to prevent duplicates
260    pending_tracker: Arc<PendingTracker>,
261    /// Task handles for pipeline stages
262    _task_handles: Vec<JoinHandle<()>>,
263    /// Marker
264    _marker: PhantomData<Dst>,
265}
266
267impl<Src: BlockMetadata, Dst: BlockMetadata> Pipeline<Src, Dst> {
268    /// Create a new pipeline with the given configuration.
269    ///
270    /// # Arguments
271    /// * `config` - Pipeline configuration
272    /// * `registry` - Block registry for policy evaluation
273    /// * `dst_manager` - Destination tier block manager
274    /// * `leader` - Instance leader for transfer execution
275    /// * `src_layout` - Source logical layout handle
276    /// * `dst_layout` - Destination logical layout handle
277    /// * `runtime` - Tokio runtime handle for spawning background tasks
278    #[allow(clippy::too_many_arguments)]
279    pub fn new(
280        config: PipelineConfig<Src, Dst>,
281        _registry: Arc<BlockRegistry>,
282        dst_manager: Arc<BlockManager<Dst>>,
283        leader: Arc<InstanceLeader>,
284        src_layout: LogicalLayoutHandle,
285        dst_layout: LogicalLayoutHandle,
286        runtime: tokio::runtime::Handle,
287    ) -> Self {
288        // Create cancellable queues
289        let eval_queue: Arc<CancellableQueue<PipelineInput<Src>>> =
290            Arc::new(CancellableQueue::new());
291        let batch_queue: Arc<CancellableQueue<EvalResult<Src>>> = Arc::new(CancellableQueue::new());
292
293        // Create output channel (still mpsc for downstream chaining)
294        let (output_tx, _output_rx) = mpsc::channel(64);
295
296        // Create watch channel for cancelled transfer IDs
297        let (cancel_tx, cancel_rx) = watch::channel(HashSet::new());
298
299        // Create batch output channel (BatchCollector → PreconditionAwaiter)
300        let (batch_tx, batch_rx) = mpsc::channel(config.transfer_input_capacity);
301
302        // Create precondition output channel (PreconditionAwaiter → TransferExecutor)
303        let (precond_tx, precond_rx) = mpsc::channel(config.transfer_input_capacity);
304
305        // Create chain output channel if auto_chain is enabled
306        let (chain_tx, chain_rx) = if config.auto_chain {
307            let (tx, rx) = mpsc::channel(64);
308            (Some(tx), Some(rx))
309        } else {
310            (None, None)
311        };
312
313        // Use provided pending tracker or create a new one
314        let pending_tracker = config
315            .pending_tracker
316            .clone()
317            .unwrap_or_else(|| Arc::new(PendingTracker::new()));
318
319        // Spawn policy evaluator
320        let evaluator = PolicyEvaluator {
321            policies: config.policies.clone(),
322            timeout: config.policy_timeout,
323            input_queue: eval_queue.clone(),
324            output_queue: batch_queue.clone(),
325            cancel_rx: cancel_rx.clone(),
326            pending_tracker: pending_tracker.clone(),
327        };
328        let eval_handle = runtime.spawn(async move {
329            evaluator.run().await;
330        });
331
332        // Spawn batch collector (reads from CancellableQueue, outputs to mpsc)
333        let collector_input_queue = batch_queue.clone();
334        let batch_config = config.batch_config.clone();
335        let collector_cancel_rx = cancel_rx.clone();
336        let batch_handle = runtime.spawn(async move {
337            let collector = BatchCollector::new(
338                batch_config,
339                collector_input_queue,
340                batch_tx,
341                collector_cancel_rx,
342            );
343            collector.run().await;
344        });
345
346        // Spawn precondition awaiter (reads from batch_rx, outputs to precond_tx)
347        let awaiter_leader = leader.clone();
348        let precond_handle = runtime.spawn(async move {
349            let awaiter = PreconditionAwaiter {
350                input_rx: batch_rx,
351                output_tx: precond_tx,
352                leader: awaiter_leader,
353            };
354            awaiter.run().await;
355        });
356
357        // Spawn block transfer executor (reads from precond_rx)
358        let executor = BlockTransferExecutor {
359            input_rx: precond_rx,
360            leader,
361            dst_manager,
362            src_layout,
363            dst_layout,
364            skip_transfers: config.skip_transfers,
365            max_concurrent_transfers: config.max_concurrent_transfers,
366            chain_tx,
367            _src_marker: PhantomData::<Src>,
368        };
369        let transfer_handle = runtime.spawn(async move {
370            executor.run().await;
371        });
372
373        // Spawn cancel sweeper
374        let sweeper_queues = vec![eval_queue.clone()];
375        let sweeper_batch_queue = batch_queue;
376        let sweeper_interval = config.sweep_interval;
377        let sweeper_cancel_rx = cancel_rx;
378        let sweeper_handle = runtime.spawn(async move {
379            cancel_sweeper(
380                sweeper_queues,
381                sweeper_batch_queue,
382                sweeper_cancel_rx,
383                sweeper_interval,
384            )
385            .await;
386        });
387
388        Self {
389            config,
390            eval_queue,
391            output_tx: Some(output_tx),
392            chain_rx,
393            cancel_tx,
394            pending_tracker,
395            _task_handles: vec![
396                eval_handle,
397                batch_handle,
398                precond_handle,
399                transfer_handle,
400                sweeper_handle,
401            ],
402            _marker: PhantomData,
403        }
404    }
405
406    /// Enqueue blocks for offloading through this pipeline.
407    pub(crate) fn enqueue(
408        &self,
409        transfer_id: TransferId,
410        source: SourceBlocks<Src>,
411        state: Arc<std::sync::Mutex<TransferState>>,
412    ) -> bool {
413        tracing::debug!(%transfer_id, num_blocks = source.len(), "Pipeline: enqueueing blocks");
414        let input = PipelineInput {
415            transfer_id,
416            source,
417            state,
418        };
419        self.eval_queue.push(transfer_id, input)
420    }
421
422    /// Request cancellation for a transfer.
423    ///
424    /// This marks the transfer as cancelled in all queues, triggering the sweeper
425    /// to remove queued items and the evaluator/collector to skip them.
426    pub fn request_cancel(&self, transfer_id: TransferId) {
427        // Mark cancelled in queues
428        self.eval_queue.mark_cancelled(transfer_id);
429
430        // Notify sweeper via watch channel
431        self.cancel_tx.send_modify(|set| {
432            set.insert(transfer_id);
433        });
434    }
435
436    /// Check if this pipeline auto-chains to downstream.
437    pub fn auto_chain(&self) -> bool {
438        self.config.auto_chain
439    }
440
441    /// Get a clone of the output channel sender.
442    pub fn output_tx(&self) -> Option<mpsc::Sender<PipelineOutput>> {
443        self.output_tx.clone()
444    }
445
446    /// Take the chain output receiver for downstream pipeline feeding.
447    ///
448    /// This transfers ownership of the receiver - can only be called once.
449    /// When `auto_chain` is enabled, this receiver will yield `ChainOutput<Dst>`
450    /// containing registered blocks that can be fed to a downstream pipeline.
451    ///
452    /// # Returns
453    /// - `Some(rx)` if `auto_chain` is enabled and receiver hasn't been taken
454    /// - `None` if `auto_chain` is false or receiver was already taken
455    pub fn take_chain_rx(&mut self) -> Option<ChainOutputRx<Dst>> {
456        self.chain_rx.take()
457    }
458
459    /// Get the pending tracker for this pipeline.
460    ///
461    /// This can be shared with presence policies to enable duplicate prevention
462    /// for blocks currently in-flight through this pipeline.
463    pub fn pending_tracker(&self) -> &Arc<PendingTracker> {
464        &self.pending_tracker
465    }
466}
467
468// ============================================================================
469// Object Pipeline (for G4 / object storage destinations)
470// ============================================================================
471
472/// Configuration for an object storage pipeline.
473///
474/// Similar to `PipelineConfig` but designed for object storage destinations
475/// that don't use a `BlockManager`. The destination is `ObjectBlockOps`.
476#[derive(Clone)]
477pub struct ObjectPipelineConfig<Src: BlockMetadata> {
478    /// Policies to evaluate (all must pass)
479    pub policies: Vec<Arc<dyn OffloadPolicy<Src>>>,
480    /// Batch configuration
481    pub batch_config: BatchConfig,
482    /// Timeout for policy evaluation (fail-fast)
483    pub policy_timeout: Duration,
484    /// Channel capacity for evaluation input
485    pub eval_input_capacity: usize,
486    /// Channel capacity for batch input
487    pub batch_input_capacity: usize,
488    /// Channel capacity for transfer input
489    pub transfer_input_capacity: usize,
490    /// Sweep interval for cancellation task
491    pub sweep_interval: Duration,
492    /// Skip actual transfers (for testing)
493    pub skip_transfers: bool,
494    /// Maximum number of concurrent transfer batches
495    pub max_concurrent_transfers: usize,
496    /// Pending tracker for duplicate prevention
497    pub pending_tracker: Option<Arc<PendingTracker>>,
498    /// Maximum concurrent precondition awaits
499    pub max_concurrent_precondition_awaits: usize,
500    /// Lock manager for distributed locking (optional)
501    ///
502    /// When provided, the executor will:
503    /// - Create `.meta` files after successful transfers
504    /// - Release `.lock` files after transfer completion
505    pub lock_manager: Option<Arc<dyn ObjectLockManager>>,
506    /// Marker
507    _marker: PhantomData<Src>,
508}
509
510impl<Src: BlockMetadata> Default for ObjectPipelineConfig<Src> {
511    fn default() -> Self {
512        Self {
513            policies: Vec::new(),
514            batch_config: BatchConfig::default(),
515            policy_timeout: Duration::from_millis(100),
516            eval_input_capacity: 128,
517            batch_input_capacity: 256,
518            transfer_input_capacity: 8,
519            sweep_interval: Duration::from_millis(10),
520            skip_transfers: false,
521            max_concurrent_transfers: 1,
522            pending_tracker: None,
523            max_concurrent_precondition_awaits: 8,
524            lock_manager: None,
525            _marker: PhantomData,
526        }
527    }
528}
529
530/// Builder for object pipeline configuration.
531pub struct ObjectPipelineBuilder<Src: BlockMetadata> {
532    config: ObjectPipelineConfig<Src>,
533}
534
535impl<Src: BlockMetadata> ObjectPipelineBuilder<Src> {
536    /// Create a new object pipeline builder with defaults.
537    pub fn new() -> Self {
538        Self {
539            config: ObjectPipelineConfig::default(),
540        }
541    }
542
543    /// Add a policy to the pipeline.
544    pub fn policy(mut self, policy: Arc<dyn OffloadPolicy<Src>>) -> Self {
545        self.config.policies.push(policy);
546        self
547    }
548
549    /// Set batch size.
550    pub fn batch_size(mut self, size: usize) -> Self {
551        self.config.batch_config.max_batch_size = size;
552        self
553    }
554
555    /// Set minimum batch size for flush.
556    pub fn min_batch_size(mut self, size: usize) -> Self {
557        self.config.batch_config.min_batch_size = size;
558        self
559    }
560
561    /// Set batch flush interval.
562    pub fn flush_interval(mut self, interval: Duration) -> Self {
563        self.config.batch_config.flush_interval = interval;
564        self
565    }
566
567    /// Set policy timeout.
568    pub fn policy_timeout(mut self, timeout: Duration) -> Self {
569        self.config.policy_timeout = timeout;
570        self
571    }
572
573    /// Set the sweep interval for cancellation.
574    pub fn sweep_interval(mut self, interval: Duration) -> Self {
575        self.config.sweep_interval = interval;
576        self
577    }
578
579    /// Skip actual transfers (for testing).
580    pub fn skip_transfers(mut self, skip: bool) -> Self {
581        self.config.skip_transfers = skip;
582        self
583    }
584
585    /// Set maximum concurrent transfers.
586    pub fn max_concurrent_transfers(mut self, n: usize) -> Self {
587        self.config.max_concurrent_transfers = n.max(1);
588        self
589    }
590
591    /// Set the pending tracker for duplicate prevention.
592    pub fn pending_tracker(mut self, tracker: Arc<PendingTracker>) -> Self {
593        self.config.pending_tracker = Some(tracker);
594        self
595    }
596
597    /// Set the lock manager for distributed locking.
598    ///
599    /// When provided, the executor will create `.meta` files after successful
600    /// transfers and release `.lock` files after completion.
601    pub fn lock_manager(mut self, manager: Arc<dyn ObjectLockManager>) -> Self {
602        self.config.lock_manager = Some(manager);
603        self
604    }
605
606    /// Build the configuration.
607    pub fn build(self) -> ObjectPipelineConfig<Src> {
608        self.config
609    }
610}
611
612impl<Src: BlockMetadata> Default for ObjectPipelineBuilder<Src> {
613    fn default() -> Self {
614        Self::new()
615    }
616}
617
618/// A running pipeline instance for object storage destinations.
619///
620/// Similar to `Pipeline` but uses `ObjectTransferExecutor` for G4 (object storage)
621/// instead of `BlockTransferExecutor`. There is no destination `BlockManager`.
622#[allow(dead_code)]
623pub struct ObjectPipeline<Src: BlockMetadata> {
624    config: ObjectPipelineConfig<Src>,
625    /// Input queue for new blocks (CancellableQueue for sweep support)
626    pub(crate) eval_queue: Arc<CancellableQueue<PipelineInput<Src>>>,
627    /// Output channel for completed blocks
628    output_tx: Option<mpsc::Sender<PipelineOutput>>,
629    /// Watch channel for cancelled transfer IDs (triggers sweep)
630    cancel_tx: watch::Sender<HashSet<TransferId>>,
631    /// Tracker for pending (in-flight) transfers to prevent duplicates
632    pending_tracker: Arc<PendingTracker>,
633    /// Task handles for pipeline stages
634    _task_handles: Vec<JoinHandle<()>>,
635}
636
637impl<Src: BlockMetadata> ObjectPipeline<Src> {
638    /// Create a new object pipeline with the given configuration.
639    ///
640    /// # Arguments
641    /// * `config` - Pipeline configuration
642    /// * `object_ops` - Object storage operations (e.g., S3 client)
643    /// * `src_layout` - Source physical layout for reading block data
644    /// * `leader` - Instance leader for precondition events
645    /// * `runtime` - Tokio runtime handle for spawning background tasks
646    #[allow(clippy::too_many_arguments)]
647    pub fn new(
648        config: ObjectPipelineConfig<Src>,
649        object_ops: Arc<dyn ObjectBlockOps>,
650        src_layout: LogicalLayoutHandle,
651        leader: Arc<InstanceLeader>,
652        runtime: tokio::runtime::Handle,
653    ) -> Self {
654        // Create cancellable queues
655        let eval_queue: Arc<CancellableQueue<PipelineInput<Src>>> =
656            Arc::new(CancellableQueue::new());
657        let batch_queue: Arc<CancellableQueue<EvalResult<Src>>> = Arc::new(CancellableQueue::new());
658
659        // Create output channel
660        let (output_tx, _output_rx) = mpsc::channel(64);
661
662        // Create watch channel for cancelled transfer IDs
663        let (cancel_tx, cancel_rx) = watch::channel(HashSet::new());
664
665        // Create batch output channel (BatchCollector → PreconditionAwaiter)
666        let (batch_tx, batch_rx) = mpsc::channel(config.transfer_input_capacity);
667
668        // Create precondition output channel (PreconditionAwaiter → ObjectTransferExecutor)
669        let (precond_tx, precond_rx) = mpsc::channel(config.transfer_input_capacity);
670
671        // Use provided pending tracker or create a new one
672        let pending_tracker = config
673            .pending_tracker
674            .clone()
675            .unwrap_or_else(|| Arc::new(PendingTracker::new()));
676
677        // Spawn policy evaluator
678        let evaluator = PolicyEvaluator {
679            policies: config.policies.clone(),
680            timeout: config.policy_timeout,
681            input_queue: eval_queue.clone(),
682            output_queue: batch_queue.clone(),
683            cancel_rx: cancel_rx.clone(),
684            pending_tracker: pending_tracker.clone(),
685        };
686        let eval_handle = runtime.spawn(async move {
687            evaluator.run().await;
688        });
689
690        // Spawn batch collector
691        let collector_input_queue = batch_queue.clone();
692        let batch_config = config.batch_config.clone();
693        let collector_cancel_rx = cancel_rx.clone();
694        let batch_handle = runtime.spawn(async move {
695            let collector = BatchCollector::new(
696                batch_config,
697                collector_input_queue,
698                batch_tx,
699                collector_cancel_rx,
700            );
701            collector.run().await;
702        });
703
704        // Spawn precondition awaiter
705        let awaiter_leader = leader.clone();
706        let precond_handle = runtime.spawn(async move {
707            let awaiter = PreconditionAwaiter {
708                input_rx: batch_rx,
709                output_tx: precond_tx,
710                leader: awaiter_leader,
711            };
712            awaiter.run().await;
713        });
714
715        // Spawn object transfer executor
716        let executor = ObjectTransferExecutor::new(
717            precond_rx,
718            object_ops,
719            src_layout,
720            config.skip_transfers,
721            config.max_concurrent_transfers,
722            config.lock_manager.clone(),
723        );
724        let transfer_handle = runtime.spawn(async move {
725            executor.run().await;
726        });
727
728        // Spawn cancel sweeper
729        let sweeper_queues = vec![eval_queue.clone()];
730        let sweeper_batch_queue = batch_queue;
731        let sweeper_interval = config.sweep_interval;
732        let sweeper_cancel_rx = cancel_rx;
733        let sweeper_handle = runtime.spawn(async move {
734            cancel_sweeper(
735                sweeper_queues,
736                sweeper_batch_queue,
737                sweeper_cancel_rx,
738                sweeper_interval,
739            )
740            .await;
741        });
742
743        Self {
744            config,
745            eval_queue,
746            output_tx: Some(output_tx),
747            cancel_tx,
748            pending_tracker,
749            _task_handles: vec![
750                eval_handle,
751                batch_handle,
752                precond_handle,
753                transfer_handle,
754                sweeper_handle,
755            ],
756        }
757    }
758
759    /// Enqueue blocks for offloading through this pipeline.
760    pub(crate) fn enqueue(
761        &self,
762        transfer_id: TransferId,
763        source: SourceBlocks<Src>,
764        state: Arc<std::sync::Mutex<TransferState>>,
765    ) -> bool {
766        tracing::debug!(%transfer_id, num_blocks = source.len(), "ObjectPipeline: enqueueing blocks");
767        let input = PipelineInput {
768            transfer_id,
769            source,
770            state,
771        };
772        self.eval_queue.push(transfer_id, input)
773    }
774
775    /// Request cancellation for a transfer.
776    pub fn request_cancel(&self, transfer_id: TransferId) {
777        self.eval_queue.mark_cancelled(transfer_id);
778        self.cancel_tx.send_modify(|set| {
779            set.insert(transfer_id);
780        });
781    }
782
783    /// Get a clone of the output channel sender.
784    #[allow(dead_code)]
785    pub fn output_tx(&self) -> Option<mpsc::Sender<PipelineOutput>> {
786        self.output_tx.clone()
787    }
788
789    /// Get the pending tracker for this pipeline.
790    pub fn pending_tracker(&self) -> &Arc<PendingTracker> {
791        &self.pending_tracker
792    }
793}
794
795/// Sweeper task that removes cancelled items from queues.
796async fn cancel_sweeper<Src: BlockMetadata>(
797    input_queues: Vec<Arc<CancellableQueue<PipelineInput<Src>>>>,
798    batch_queue: Arc<CancellableQueue<EvalResult<Src>>>,
799    mut cancel_rx: watch::Receiver<HashSet<TransferId>>,
800    interval: Duration,
801) {
802    let mut ticker = tokio::time::interval(interval);
803    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
804
805    loop {
806        tokio::select! {
807            _ = ticker.tick() => {
808                // Sweep all queues
809                for queue in &input_queues {
810                    let removed = queue.sweep();
811                    if removed > 0 {
812                        tracing::debug!("Sweeper removed {} cancelled input items", removed);
813                    }
814                }
815
816                let batch_removed = batch_queue.sweep();
817                if batch_removed > 0 {
818                    tracing::debug!("Sweeper removed {} cancelled batch items", batch_removed);
819                }
820            }
821            result = cancel_rx.changed() => {
822                if result.is_err() {
823                    // Channel closed, shutdown
824                    break;
825                }
826                // New cancellation added, sweep immediately
827                for queue in &input_queues {
828                    queue.sweep();
829                }
830                batch_queue.sweep();
831            }
832        }
833    }
834}
835
836/// Policy evaluator stage.
837struct PolicyEvaluator<T: BlockMetadata> {
838    policies: Vec<Arc<dyn OffloadPolicy<T>>>,
839    timeout: Duration,
840    input_queue: Arc<CancellableQueue<PipelineInput<T>>>,
841    output_queue: Arc<CancellableQueue<EvalResult<T>>>,
842    cancel_rx: watch::Receiver<HashSet<TransferId>>,
843    /// Tracker for pending transfers - guards are created when blocks pass policy
844    pending_tracker: Arc<PendingTracker>,
845}
846
847impl<T: BlockMetadata> PolicyEvaluator<T> {
848    async fn run(mut self) {
849        loop {
850            while let Some(item) = self.input_queue.pop_valid() {
851                self.evaluate(item.data).await;
852            }
853
854            tokio::select! {
855                _ = self.input_queue.notified() => {}
856                result = self.cancel_rx.changed() => {
857                    if result.is_err() {
858                        break;
859                    }
860                }
861            }
862        }
863    }
864
865    async fn evaluate(&self, input: PipelineInput<T>) {
866        nvtx_range!("offload::policy");
867        let transfer_id = input.transfer_id;
868
869        // Set total_expected_blocks for per-transfer sentinel flush
870        let total_blocks = input.source.len();
871        {
872            let mut state = input.state.lock().unwrap();
873            state.total_expected_blocks = total_blocks;
874        }
875
876        // Check if already cancelled (via queue or via handle)
877        {
878            let state = input.state.lock().unwrap();
879            if state.is_cancel_requested() {
880                drop(state); // Release lock before calling set_cancelled
881                tracing::debug!(%transfer_id, "Transfer cancelled before evaluation");
882                let mut state = input.state.lock().unwrap();
883                state.set_cancelled();
884                return;
885            }
886        }
887
888        let mut passed = Vec::new();
889        let mut filtered = Vec::new();
890
891        // Process blocks based on source type
892        match input.source {
893            SourceBlocks::External(external_blocks) => {
894                // External blocks (e.g., G1 from vLLM) still need policy evaluation
895                // to check presence in destination tier
896                for ext in external_blocks {
897                    // Check for cancellation between blocks
898                    if self.check_cancelled(&input.state, transfer_id) {
899                        return;
900                    }
901
902                    // Create context with sequence_hash - block_id is known for External
903                    let ctx = EvalContext::from_external(ext.block_id, ext.sequence_hash);
904                    let pass = self.evaluate_policies(&ctx).await;
905
906                    if pass {
907                        // Create pending guard for duplicate prevention
908                        let pending_guard = self.pending_tracker.guard(ext.sequence_hash);
909                        passed.push(QueuedBlock {
910                            transfer_id,
911                            block_id: Some(ext.block_id),
912                            sequence_hash: ext.sequence_hash,
913                            source: SourceBlock::External(ext),
914                            state: input.state.clone(),
915                            pending_guard: Some(pending_guard),
916                        });
917                    } else {
918                        filtered.push(ext.block_id);
919                    }
920                }
921                tracing::debug!(%transfer_id, passed = passed.len(), filtered = filtered.len(), "External blocks evaluated");
922            }
923            SourceBlocks::Strong(strong_blocks) => {
924                // Strong blocks get full policy evaluation
925                for block in strong_blocks {
926                    // Check for cancellation between blocks
927                    if self.check_cancelled(&input.state, transfer_id) {
928                        return;
929                    }
930
931                    let ctx = EvalContext::new(block);
932                    let pass = self.evaluate_policies(&ctx).await;
933
934                    if pass {
935                        let block = ctx.block.expect("Strong block context always has block");
936                        // Create pending guard for duplicate prevention
937                        let pending_guard = self.pending_tracker.guard(ctx.sequence_hash);
938                        passed.push(QueuedBlock {
939                            transfer_id,
940                            block_id: Some(ctx.block_id),
941                            sequence_hash: ctx.sequence_hash,
942                            source: SourceBlock::Strong(block),
943                            state: input.state.clone(),
944                            pending_guard: Some(pending_guard),
945                        });
946                    } else {
947                        filtered.push(ctx.block_id);
948                    }
949                }
950            }
951            SourceBlocks::Weak(weak_blocks) => {
952                // Weak blocks get policy evaluation using metadata (deferred upgrade)
953                // block_id is unknown until upgrade at transfer time
954                for weak in weak_blocks {
955                    // Check for cancellation between blocks
956                    if self.check_cancelled(&input.state, transfer_id) {
957                        return;
958                    }
959
960                    let sequence_hash = weak.sequence_hash();
961                    let ctx = EvalContext::from_weak(BlockId::default(), sequence_hash);
962                    let pass = self.evaluate_policies(&ctx).await;
963
964                    if pass {
965                        // Create pending guard for duplicate prevention
966                        let pending_guard = self.pending_tracker.guard(sequence_hash);
967                        passed.push(QueuedBlock {
968                            transfer_id,
969                            block_id: None, // Determined at upgrade time
970                            sequence_hash,
971                            source: SourceBlock::Weak(weak),
972                            state: input.state.clone(),
973                            pending_guard: Some(pending_guard),
974                        });
975                    } else {
976                        // For weak blocks, we track by sequence_hash since block_id is unknown
977                        // We'll add sequence_hash tracking in TransferState if needed
978                        tracing::debug!(%transfer_id, ?sequence_hash, "Weak block filtered by policy");
979                    }
980                }
981            }
982        }
983
984        // Check for cancellation after evaluation
985        {
986            let state = input.state.lock().unwrap();
987            if state.is_cancel_requested() {
988                drop(state);
989                tracing::debug!(%transfer_id, "Transfer cancelled after evaluation");
990                let mut state = input.state.lock().unwrap();
991                state.set_cancelled();
992                return;
993            }
994        }
995
996        tracing::debug!(%transfer_id, passed = passed.len(), filtered = filtered.len(), "Policy evaluation complete");
997
998        // Update state with evaluation results
999        {
1000            let mut state = input.state.lock().unwrap();
1001            // Only track block_ids for blocks that have them (External/Strong)
1002            // Weak blocks don't have block_id until upgrade
1003            state.add_passed(passed.iter().filter_map(|b| b.block_id));
1004            state.add_filtered(filtered.iter().copied());
1005            state.set_status(TransferStatus::Queued);
1006        }
1007
1008        // Check if all blocks were filtered (transfer complete with no transfers)
1009        if passed.is_empty() {
1010            tracing::debug!(%transfer_id, "All blocks filtered, completing transfer");
1011            let mut state = input.state.lock().unwrap();
1012            state.set_complete();
1013            return;
1014        }
1015
1016        // Send to batch collector
1017        let result = EvalResult {
1018            transfer_id,
1019            passed_blocks: passed,
1020            filtered_ids: filtered,
1021            state: input.state,
1022        };
1023
1024        if !self.output_queue.push(transfer_id, result) {
1025            tracing::debug!(%transfer_id, "Push to output queue failed (cancelled)");
1026        }
1027    }
1028
1029    /// Check if transfer is cancelled and handle state update.
1030    fn check_cancelled(
1031        &self,
1032        state: &Arc<std::sync::Mutex<TransferState>>,
1033        transfer_id: TransferId,
1034    ) -> bool {
1035        let state_guard = state.lock().unwrap();
1036        if state_guard.is_cancel_requested() {
1037            drop(state_guard);
1038            tracing::debug!(%transfer_id, "Transfer cancelled mid-evaluation");
1039            let mut state_guard = state.lock().unwrap();
1040            state_guard.set_cancelled();
1041            true
1042        } else {
1043            false
1044        }
1045    }
1046
1047    async fn evaluate_policies(&self, ctx: &EvalContext<T>) -> bool {
1048        for policy in &self.policies {
1049            let eval_future = policy.evaluate(ctx);
1050            let timed_result = tokio::time::timeout(self.timeout, async {
1051                match eval_future {
1052                    Either::Left(ready) => ready.await,
1053                    Either::Right(boxed) => boxed.await,
1054                }
1055            })
1056            .await;
1057
1058            match timed_result {
1059                Ok(Ok(true)) => continue,
1060                Ok(Ok(false)) => return false,
1061                Ok(Err(e)) => {
1062                    tracing::warn!("Policy {} error: {}", policy.name(), e);
1063                    return false;
1064                }
1065                Err(_) => {
1066                    tracing::warn!("Policy {} timed out", policy.name());
1067                    return false;
1068                }
1069            }
1070        }
1071        true
1072    }
1073}
1074
1075// ============================================================================
1076// Block Upgrader Types
1077// ============================================================================
1078
1079/// A resolved block ready for transfer execution.
1080///
1081/// Created during the upgrade stage when `WeakBlock` references are upgraded
1082/// to `ImmutableBlock` guards. This type is used by both `BlockTransferExecutor`
1083/// and `ObjectTransferExecutor`.
1084pub struct ResolvedBlock<T: BlockMetadata> {
1085    /// Transfer ID this block belongs to
1086    pub transfer_id: TransferId,
1087    /// Block ID in the source layout
1088    pub block_id: BlockId,
1089    /// Sequence hash identifying the block content
1090    pub sequence_hash: SequenceHash,
1091    /// Guard holding the block - Some for Strong/Weak, None for External.
1092    /// The guard is held to prevent eviction during transfer.
1093    #[allow(dead_code)]
1094    pub guard: Option<ImmutableBlock<T>>,
1095    /// Transfer state for progress tracking
1096    pub(crate) state: Arc<std::sync::Mutex<TransferState>>,
1097}
1098
1099/// A batch of resolved blocks ready for transfer.
1100///
1101/// This is the output of the block upgrade stage and input to transfer executors.
1102pub struct ResolvedBatch<T: BlockMetadata> {
1103    /// Resolved blocks ready for transfer
1104    pub blocks: Vec<ResolvedBlock<T>>,
1105    /// Sequence hashes of blocks that were evicted during upgrade
1106    #[allow(dead_code)]
1107    pub evicted: Vec<SequenceHash>,
1108    /// Timing trace from the original batch (batch-level, not per-block)
1109    pub timing: TimingTrace,
1110}
1111
1112impl<T: BlockMetadata> ResolvedBatch<T> {
1113    /// Check if the batch has any resolved blocks.
1114    pub fn is_empty(&self) -> bool {
1115        self.blocks.is_empty()
1116    }
1117
1118    /// Get the number of resolved blocks.
1119    #[allow(dead_code)]
1120    pub fn len(&self) -> usize {
1121        self.blocks.len()
1122    }
1123}
1124
1125/// Upgrade a batch of queued blocks by resolving weak references.
1126///
1127/// This is the "block upgrader" stage that converts `TransferBatch` (containing
1128/// mixed `SourceBlock` types) into `ResolvedBatch` (containing only resolved
1129/// `ImmutableBlock` guards).
1130///
1131/// # Block Type Handling
1132///
1133/// - `Strong`: Already have a guard, pass through directly
1134/// - `External`: No guard needed, caller holds the reference
1135/// - `Weak`: Attempt upgrade; if evicted, record in `evicted` list
1136///
1137/// This function is synchronous CPU work that can run in an "on-deck" slot
1138/// while other transfers are executing.
1139pub fn upgrade_batch<T: BlockMetadata>(batch: TransferBatch<T>) -> ResolvedBatch<T> {
1140    let mut resolved: Vec<ResolvedBlock<T>> = Vec::with_capacity(batch.len());
1141    let mut evicted: Vec<SequenceHash> = Vec::new();
1142
1143    // Copy timing from batch and mark transfer start (O(1), not per-block)
1144    let mut timing = batch.timing;
1145    timing.mark_transfer_start();
1146
1147    for queued in batch.blocks {
1148        // Note: pending_guard is automatically dropped when QueuedBlock is processed,
1149        // which removes the sequence_hash from the pending set. This happens either
1150        // when the block is resolved and transferred, or when it's evicted/dropped.
1151        match queued.source {
1152            SourceBlock::Strong(block) => {
1153                resolved.push(ResolvedBlock {
1154                    transfer_id: queued.transfer_id,
1155                    block_id: block.block_id(),
1156                    sequence_hash: queued.sequence_hash,
1157                    guard: Some(block),
1158                    state: queued.state,
1159                });
1160            }
1161            SourceBlock::External(ext) => {
1162                resolved.push(ResolvedBlock {
1163                    transfer_id: queued.transfer_id,
1164                    block_id: ext.block_id,
1165                    sequence_hash: ext.sequence_hash,
1166                    guard: None,
1167                    state: queued.state,
1168                });
1169            }
1170            SourceBlock::Weak(weak) => match weak.upgrade() {
1171                Some(block) => {
1172                    resolved.push(ResolvedBlock {
1173                        transfer_id: queued.transfer_id,
1174                        block_id: block.block_id(),
1175                        sequence_hash: queued.sequence_hash,
1176                        guard: Some(block),
1177                        state: queued.state,
1178                    });
1179                }
1180                None => {
1181                    tracing::debug!(
1182                        sequence_hash = ?queued.sequence_hash,
1183                        "Weak block evicted before transfer"
1184                    );
1185                    evicted.push(queued.sequence_hash);
1186                }
1187            },
1188        }
1189    }
1190
1191    ResolvedBatch {
1192        blocks: resolved,
1193        evicted,
1194        timing,
1195    }
1196}
1197
1198// ============================================================================
1199// Precondition Awaiter
1200// ============================================================================
1201
1202/// Precondition awaiter stage.
1203///
1204/// Sits between BatchCollector and the transfer executors, awaiting precondition events
1205/// before forwarding batches. Spawns unbounded tasks to ensure all preconditions
1206/// are awaited - event awaiting is cheap (just waiting, no compute), so we never
1207/// skip awaiting a precondition to prevent deadlock scenarios.
1208struct PreconditionAwaiter<T: BlockMetadata> {
1209    input_rx: BatchOutputRx<T>,
1210    output_tx: mpsc::Sender<TransferBatch<T>>,
1211    leader: Arc<InstanceLeader>,
1212}
1213
1214impl<T: BlockMetadata> PreconditionAwaiter<T> {
1215    async fn run(mut self) {
1216        // NO SEMAPHORE - spawn unbounded tasks
1217        // Event awaiting is cheap, we must never skip awaiting a precondition
1218        while let Some(mut batch) = self.input_rx.recv().await {
1219            let output_tx = self.output_tx.clone();
1220            let nova = self.leader.messenger().clone();
1221
1222            // Spawn task for each batch - unbounded
1223            tokio::spawn(async move {
1224                nvtx_range!("offload::precondition");
1225                if let Some(event_handle) = batch.precondition {
1226                    tracing::debug!(?event_handle, "Awaiting precondition for batch");
1227
1228                    // Create awaiter (returns Result<LocalEventWaiter, Error>)
1229                    let awaiter_result = nova.events().awaiter(event_handle);
1230
1231                    match awaiter_result {
1232                        Ok(awaiter) => {
1233                            // Now await the LocalEventWaiter with timeout
1234                            match tokio::time::timeout(Duration::from_secs(300), awaiter).await {
1235                                Ok(Ok(())) => {
1236                                    tracing::debug!(?event_handle, "Precondition satisfied");
1237                                }
1238                                Ok(Err(poison)) => {
1239                                    tracing::error!(
1240                                        ?event_handle,
1241                                        ?poison,
1242                                        "Precondition poisoned, marking all blocks as failed"
1243                                    );
1244                                    // Mark all blocks as failed
1245                                    for queued in batch.blocks {
1246                                        let mut state = queued.state.lock().unwrap();
1247                                        state.set_error(format!(
1248                                            "precondition poisoned: {:?}",
1249                                            poison
1250                                        ));
1251                                    }
1252                                    return;
1253                                }
1254                                Err(_) => {
1255                                    tracing::error!(
1256                                        ?event_handle,
1257                                        "Precondition timeout after 30s"
1258                                    );
1259                                    // Mark all blocks as failed
1260                                    for queued in batch.blocks {
1261                                        let mut state = queued.state.lock().unwrap();
1262                                        state.set_error("precondition timeout".to_string());
1263                                    }
1264                                    return;
1265                                }
1266                            }
1267                        }
1268                        Err(e) => {
1269                            tracing::error!(?event_handle, ?e, "Failed to create awaiter");
1270                            // Mark all blocks as failed
1271                            for queued in batch.blocks {
1272                                let mut state = queued.state.lock().unwrap();
1273                                state.set_error(format!("failed to create awaiter: {}", e));
1274                            }
1275                            return;
1276                        }
1277                    }
1278                }
1279
1280                // Mark precondition complete (batch-level, O(1))
1281                batch.timing.mark_precondition_complete();
1282
1283                // Forward batch to transfer executor
1284                if let Err(e) = output_tx.send(batch).await {
1285                    tracing::error!("Failed to forward batch after precondition: {}", e);
1286                }
1287            });
1288        }
1289    }
1290}
1291
1292// ============================================================================
1293// Block Transfer Executor (for G2, G3 destinations)
1294// ============================================================================
1295
1296/// Block transfer executor stage for BlockManager-based destinations.
1297///
1298/// Executes transfers to destinations with a `BlockManager` (G2, G3).
1299/// Uses `leader.execute_local_transfer()` to copy block data between layouts.
1300///
1301/// For object storage destinations (G4), use `ObjectTransferExecutor` instead.
1302struct BlockTransferExecutor<Src: BlockMetadata, Dst: BlockMetadata> {
1303    input_rx: BatchOutputRx<Src>,
1304    leader: Arc<InstanceLeader>,
1305    dst_manager: Arc<BlockManager<Dst>>,
1306    src_layout: LogicalLayoutHandle,
1307    dst_layout: LogicalLayoutHandle,
1308    /// Skip actual transfers (for testing)
1309    skip_transfers: bool,
1310    /// Maximum concurrent transfers
1311    max_concurrent_transfers: usize,
1312    /// Channel to send registered blocks for chaining to downstream pipeline
1313    chain_tx: Option<mpsc::Sender<ChainOutput<Dst>>>,
1314    _src_marker: PhantomData<Src>,
1315}
1316
1317/// Shared state for BlockTransferExecutor that can be cloned across concurrent tasks.
1318struct SharedBlockExecutorState<Dst: BlockMetadata> {
1319    leader: Arc<InstanceLeader>,
1320    dst_manager: Arc<BlockManager<Dst>>,
1321    src_layout: LogicalLayoutHandle,
1322    dst_layout: LogicalLayoutHandle,
1323    skip_transfers: bool,
1324    chain_tx: Option<mpsc::Sender<ChainOutput<Dst>>>,
1325}
1326
1327impl<Src: BlockMetadata, Dst: BlockMetadata> BlockTransferExecutor<Src, Dst> {
1328    async fn run(mut self) {
1329        // N slots for active transfers
1330        let transfer_semaphore = Arc::new(Semaphore::new(self.max_concurrent_transfers));
1331        // 1 slot for preparation (upgrade) work - on-deck
1332        let prepare_semaphore = Arc::new(Semaphore::new(1));
1333
1334        // Extract shared state for concurrent tasks
1335        let shared = Arc::new(SharedBlockExecutorState {
1336            leader: self.leader.clone(),
1337            dst_manager: self.dst_manager.clone(),
1338            src_layout: self.src_layout,
1339            dst_layout: self.dst_layout,
1340            skip_transfers: self.skip_transfers,
1341            chain_tx: self.chain_tx.take(),
1342        });
1343
1344        while let Some(batch) = self.input_rx.recv().await {
1345            if batch.is_empty() {
1346                continue;
1347            }
1348
1349            // Wait for prepare slot (only 1 batch preparing at a time)
1350            // This is the "on-deck" slot for preparing while transfers run
1351            let prepare_permit = prepare_semaphore.clone().acquire_owned().await;
1352            if prepare_permit.is_err() {
1353                break; // Semaphore closed
1354            }
1355            let prepare_permit = prepare_permit.unwrap();
1356
1357            // Prepare stage: resolve/upgrade blocks (weak→strong)
1358            // This happens in the "on-deck" slot while other transfers may be running
1359            let upgraded = upgrade_batch(batch);
1360
1361            // Done preparing, release prepare slot for next batch
1362            drop(prepare_permit);
1363
1364            if upgraded.is_empty() {
1365                tracing::debug!("All blocks in batch evicted, skipping transfer");
1366                continue;
1367            }
1368
1369            // Now wait for transfer slot
1370            let transfer_permit = transfer_semaphore.clone().acquire_owned().await;
1371            if transfer_permit.is_err() {
1372                break; // Semaphore closed
1373            }
1374            let transfer_permit = transfer_permit.unwrap();
1375
1376            // Spawn transfer task
1377            let shared_clone = shared.clone();
1378            tokio::spawn(async move {
1379                let _permit = transfer_permit; // Hold permit until task completes
1380                if let Err(e) = Self::execute_transfer(&shared_clone, upgraded).await {
1381                    tracing::error!("BlockTransferExecutor: transfer failed: {}", e);
1382                }
1383            });
1384        }
1385
1386        // Wait for all in-flight transfers to complete by acquiring all permits
1387        let _ = transfer_semaphore
1388            .acquire_many(self.max_concurrent_transfers as u32)
1389            .await;
1390    }
1391
1392    /// Execute the actual transfer for resolved blocks.
1393    ///
1394    /// This is async I/O work that runs concurrently with other transfers.
1395    async fn execute_transfer(
1396        shared: &SharedBlockExecutorState<Dst>,
1397        mut batch: ResolvedBatch<Src>,
1398    ) -> anyhow::Result<()> {
1399        nvtx_range!("offload::transfer");
1400        if batch.is_empty() {
1401            return Ok(());
1402        }
1403
1404        let resolved = &batch.blocks;
1405
1406        // Collect block_ids and sequence_hashes from resolved blocks
1407        let src_block_ids: Vec<BlockId> = resolved.iter().map(|b| b.block_id).collect();
1408        let sequence_hashes: Vec<SequenceHash> = resolved.iter().map(|b| b.sequence_hash).collect();
1409
1410        // Collect states for completion tracking (group by transfer_id)
1411        let mut transfer_states: std::collections::HashMap<
1412            TransferId,
1413            (Arc<std::sync::Mutex<TransferState>>, Vec<BlockId>),
1414        > = std::collections::HashMap::new();
1415        for block in resolved {
1416            transfer_states
1417                .entry(block.transfer_id)
1418                .or_insert_with(|| (block.state.clone(), Vec::new()))
1419                .1
1420                .push(block.block_id);
1421        }
1422
1423        // Skip actual transfers when in test mode
1424        if !shared.skip_transfers {
1425            // Allocate destination blocks
1426            let dst_blocks = shared
1427                .dst_manager
1428                .allocate_blocks(resolved.len())
1429                .ok_or_else(|| {
1430                    anyhow::anyhow!("Failed to allocate {} destination blocks", resolved.len())
1431                })?;
1432
1433            let dst_block_ids: Vec<BlockId> = dst_blocks.iter().map(|b| b.block_id()).collect();
1434
1435            // Execute transfer via leader
1436            let start_xfer = Instant::now();
1437            let notification = shared.leader.execute_local_transfer(
1438                shared.src_layout,
1439                shared.dst_layout,
1440                src_block_ids.clone(),
1441                dst_block_ids.clone(),
1442                TransferOptions::default(),
1443            )?;
1444
1445            // `execute_local_transfer` reserves the physical/simulated
1446            // transfer synchronously and returns an awaitable completion
1447            // notification. Only now is the handle a safe marker for
1448            // virtual-time accounting.
1449            for (state, block_ids) in transfer_states.values() {
1450                let mut state_guard = state.lock().unwrap();
1451                state_guard.set_status(TransferStatus::Transferring);
1452                state_guard.mark_in_flight(block_ids.iter().copied());
1453            }
1454
1455            // Wait for transfer completion
1456            notification.await?;
1457            let end_xfer = Instant::now();
1458
1459            // Register each transferred block in the destination tier
1460            let registered_blocks: Vec<ImmutableBlock<Dst>> = dst_blocks
1461                .into_iter()
1462                .zip(sequence_hashes.iter())
1463                .map(|(dst_block, seq_hash)| {
1464                    let complete = dst_block
1465                        .stage(*seq_hash, shared.dst_manager.block_size())
1466                        .expect("block size mismatch");
1467                    shared.dst_manager.register_block(complete)
1468                })
1469                .collect();
1470
1471            let registration_timepoint = Instant::now();
1472
1473            // Compute timing statistics from batch timing (O(1), not per-block)
1474            let unique_transfer_ids: std::collections::HashSet<_> =
1475                resolved.iter().map(|b| b.transfer_id).collect();
1476
1477            let policy_ms = batch
1478                .timing
1479                .policy_duration()
1480                .map(|d| d.as_millis() as u64)
1481                .unwrap_or(0);
1482            let precondition_ms = batch
1483                .timing
1484                .precondition_duration()
1485                .map(|d| d.as_millis() as u64)
1486                .unwrap_or(0);
1487            let total_ms = batch
1488                .timing
1489                .total_duration()
1490                .map(|d| d.as_millis() as u64)
1491                .unwrap_or(0);
1492
1493            tracing::info!(
1494                blocks = resolved.len(),
1495                containers = unique_transfer_ids.len(),
1496                policy_ms,
1497                precondition_ms,
1498                xfer_ms = end_xfer.duration_since(start_xfer).as_millis() as u64,
1499                registration_ms =
1500                    registration_timepoint.duration_since(end_xfer).as_millis() as u64,
1501                total_ms,
1502                src = std::any::type_name::<Src>(),
1503                dst = std::any::type_name::<Dst>(),
1504                "Batch transfer complete"
1505            );
1506
1507            // Send registered blocks to downstream pipeline if chaining is enabled
1508            if let Some(chain_tx) = &shared.chain_tx {
1509                #[allow(clippy::type_complexity)]
1510                let mut chain_outputs: std::collections::HashMap<
1511                    TransferId,
1512                    (
1513                        Arc<std::sync::Mutex<TransferState>>,
1514                        Vec<ImmutableBlock<Dst>>,
1515                    ),
1516                > = std::collections::HashMap::new();
1517
1518                for (registered, resolved_block) in
1519                    registered_blocks.into_iter().zip(resolved.iter())
1520                {
1521                    chain_outputs
1522                        .entry(resolved_block.transfer_id)
1523                        .or_insert_with(|| (resolved_block.state.clone(), Vec::new()))
1524                        .1
1525                        .push(registered);
1526                }
1527
1528                for (transfer_id, (state, blocks)) in chain_outputs {
1529                    let output = ChainOutput {
1530                        transfer_id,
1531                        blocks,
1532                        state,
1533                    };
1534                    if chain_tx.send(output).await.is_err() {
1535                        tracing::warn!(
1536                            %transfer_id,
1537                            "Chain channel closed, downstream pipeline unavailable"
1538                        );
1539                    } else {
1540                        tracing::debug!(
1541                            %transfer_id,
1542                            "Sent blocks to chain output for downstream processing"
1543                        );
1544                    }
1545                }
1546            }
1547        } else {
1548            for (state, block_ids) in transfer_states.values() {
1549                let mut state_guard = state.lock().unwrap();
1550                state_guard.set_status(TransferStatus::Transferring);
1551                state_guard.mark_in_flight(block_ids.iter().copied());
1552            }
1553        }
1554
1555        // Mark transfer complete (batch-level, O(1))
1556        batch.timing.mark_transfer_complete();
1557
1558        // Mark blocks as completed in each transfer state
1559        for (transfer_id, (state, block_ids)) in transfer_states {
1560            let mut state_guard = state.lock().unwrap();
1561            state_guard.mark_completed(block_ids);
1562
1563            let total = state_guard.passed_blocks.len() + state_guard.filtered_out.len();
1564            let done = state_guard.completed.len() + state_guard.filtered_out.len();
1565            tracing::debug!(
1566                %transfer_id,
1567                total,
1568                done,
1569                passed = state_guard.passed_blocks.len(),
1570                filtered = state_guard.filtered_out.len(),
1571                completed = state_guard.completed.len(),
1572                "Transfer batch progress"
1573            );
1574            if done >= total && total > 0 {
1575                state_guard.set_complete();
1576            }
1577        }
1578
1579        Ok(())
1580    }
1581}
1582
1583// ============================================================================
1584// Object Transfer Executor (for G4 / object storage destinations)
1585// ============================================================================
1586
1587/// Object transfer executor stage for object storage destinations.
1588///
1589/// Executes transfers to object storage (G4) via `ObjectBlockOps::put_blocks()`.
1590/// Unlike `BlockTransferExecutor`, this does not require a destination `BlockManager`.
1591///
1592/// # Source Requirements
1593///
1594/// The source blocks must be `ImmutableBlock<Src>` (post-upgrade). The executor:
1595/// 1. Receives `ResolvedBlock<Src>` from the upgrade stage
1596/// 2. Extracts `SequenceHash` as the object key
1597/// 3. Calls `ObjectBlockOps::put_blocks()` with the source layout
1598///
1599/// # Lock Management
1600///
1601/// When a `lock_manager` is provided, after successful transfers:
1602/// 1. Creates `.meta` file to mark block as offloaded
1603/// 2. Releases `.lock` file to allow other instances to proceed
1604///
1605/// # No Destination Registration
1606///
1607/// Object storage is external - there's no local `BlockManager<G4>` to register with.
1608/// The object is simply stored at the key derived from `SequenceHash`.
1609pub struct ObjectTransferExecutor<Src: BlockMetadata> {
1610    /// Input channel from the batch/precondition stage
1611    input_rx: BatchOutputRx<Src>,
1612    /// Object storage operations
1613    object_ops: Arc<dyn ObjectBlockOps>,
1614    /// Source logical layout handle for reading block data
1615    /// The ObjectBlockOps implementation resolves this to a physical layout
1616    src_layout: LogicalLayoutHandle,
1617    /// Skip actual transfers (for testing)
1618    skip_transfers: bool,
1619    /// Maximum concurrent transfer batches
1620    max_concurrent_transfers: usize,
1621    /// Optional lock manager for creating meta files and releasing locks
1622    lock_manager: Option<Arc<dyn ObjectLockManager>>,
1623}
1624
1625/// Shared state for ObjectTransferExecutor that can be cloned across concurrent tasks.
1626struct SharedObjectExecutorState {
1627    object_ops: Arc<dyn ObjectBlockOps>,
1628    src_layout: LogicalLayoutHandle,
1629    skip_transfers: bool,
1630    lock_manager: Option<Arc<dyn ObjectLockManager>>,
1631}
1632
1633impl<Src: BlockMetadata> ObjectTransferExecutor<Src> {
1634    /// Create a new object transfer executor.
1635    #[allow(dead_code)]
1636    pub fn new(
1637        input_rx: BatchOutputRx<Src>,
1638        object_ops: Arc<dyn ObjectBlockOps>,
1639        src_layout: LogicalLayoutHandle,
1640        skip_transfers: bool,
1641        max_concurrent_transfers: usize,
1642        lock_manager: Option<Arc<dyn ObjectLockManager>>,
1643    ) -> Self {
1644        Self {
1645            input_rx,
1646            object_ops,
1647            src_layout,
1648            skip_transfers,
1649            max_concurrent_transfers,
1650            lock_manager,
1651        }
1652    }
1653
1654    /// Run the executor loop.
1655    pub async fn run(mut self) {
1656        // N slots for active transfers
1657        let transfer_semaphore = Arc::new(Semaphore::new(self.max_concurrent_transfers));
1658        // 1 slot for preparation (upgrade) work - on-deck
1659        let prepare_semaphore = Arc::new(Semaphore::new(1));
1660
1661        // Extract shared state for concurrent tasks
1662        let shared = Arc::new(SharedObjectExecutorState {
1663            object_ops: self.object_ops.clone(),
1664            src_layout: self.src_layout,
1665            skip_transfers: self.skip_transfers,
1666            lock_manager: self.lock_manager.clone(),
1667        });
1668
1669        while let Some(batch) = self.input_rx.recv().await {
1670            if batch.is_empty() {
1671                continue;
1672            }
1673
1674            // Wait for prepare slot (only 1 batch preparing at a time)
1675            let prepare_permit = prepare_semaphore.clone().acquire_owned().await;
1676            if prepare_permit.is_err() {
1677                break; // Semaphore closed
1678            }
1679            let prepare_permit = prepare_permit.unwrap();
1680
1681            // Prepare stage: resolve/upgrade blocks (weak→strong)
1682            let upgraded = upgrade_batch(batch);
1683
1684            // Done preparing, release prepare slot for next batch
1685            drop(prepare_permit);
1686
1687            if upgraded.is_empty() {
1688                tracing::debug!("All blocks in batch evicted, skipping object transfer");
1689                continue;
1690            }
1691
1692            // Now wait for transfer slot
1693            let transfer_permit = transfer_semaphore.clone().acquire_owned().await;
1694            if transfer_permit.is_err() {
1695                break; // Semaphore closed
1696            }
1697            let transfer_permit = transfer_permit.unwrap();
1698
1699            // Spawn transfer task
1700            let shared_clone = shared.clone();
1701            tokio::spawn(async move {
1702                let _permit = transfer_permit; // Hold permit until task completes
1703                if let Err(e) = Self::execute_transfer(&shared_clone, upgraded).await {
1704                    tracing::error!("ObjectTransferExecutor: transfer failed: {}", e);
1705                }
1706            });
1707        }
1708
1709        // Wait for all in-flight transfers to complete by acquiring all permits
1710        let _ = transfer_semaphore
1711            .acquire_many(self.max_concurrent_transfers as u32)
1712            .await;
1713    }
1714
1715    /// Execute the actual transfer for resolved blocks to object storage.
1716    async fn execute_transfer(
1717        shared: &SharedObjectExecutorState,
1718        mut batch: ResolvedBatch<Src>,
1719    ) -> anyhow::Result<()> {
1720        nvtx_range!("offload::transfer");
1721        if batch.is_empty() {
1722            return Ok(());
1723        }
1724
1725        let resolved = &batch.blocks;
1726
1727        // Collect keys (sequence hashes) and block_ids from resolved blocks
1728        let keys: Vec<SequenceHash> = resolved.iter().map(|b| b.sequence_hash).collect();
1729        let block_ids: Vec<BlockId> = resolved.iter().map(|b| b.block_id).collect();
1730
1731        // Collect states for completion tracking (group by transfer_id)
1732        let mut transfer_states: std::collections::HashMap<
1733            TransferId,
1734            (Arc<std::sync::Mutex<TransferState>>, Vec<BlockId>),
1735        > = std::collections::HashMap::new();
1736        for block in resolved {
1737            transfer_states
1738                .entry(block.transfer_id)
1739                .or_insert_with(|| (block.state.clone(), Vec::new()))
1740                .1
1741                .push(block.block_id);
1742        }
1743
1744        // Track successfully transferred sequence hashes for lock management
1745        let mut successful_hashes: Vec<SequenceHash> = Vec::new();
1746
1747        // Skip actual transfers when in test mode
1748        if !shared.skip_transfers {
1749            // Execute object put via ObjectBlockOps
1750            let results = shared
1751                .object_ops
1752                .put_blocks(keys.clone(), shared.src_layout, block_ids)
1753                .await;
1754
1755            // Guard: put_blocks must return exactly one result per input block.
1756            // If mismatched, mark all blocks as failed since we can't correlate results.
1757            if results.len() != keys.len() {
1758                tracing::error!(
1759                    expected = keys.len(),
1760                    actual = results.len(),
1761                    "put_blocks returned mismatched result count"
1762                );
1763                for (_transfer_id, (state, block_ids)) in transfer_states {
1764                    let mut state_guard = state.lock().unwrap();
1765                    state_guard.mark_failed(block_ids);
1766                    state_guard
1767                        .set_error("put_blocks returned mismatched result count".to_string());
1768                }
1769                return Ok(());
1770            }
1771
1772            // Log results and track successful transfers
1773            let mut success_count = 0;
1774            let mut fail_count = 0;
1775
1776            for result in results {
1777                match result {
1778                    Ok(hash) => {
1779                        success_count += 1;
1780                        successful_hashes.push(hash);
1781                    }
1782                    Err(hash) => {
1783                        fail_count += 1;
1784                        tracing::warn!(?hash, "Failed to transfer block to object storage");
1785                    }
1786                }
1787            }
1788
1789            if fail_count > 0 {
1790                tracing::warn!(
1791                    success = success_count,
1792                    failed = fail_count,
1793                    "Object transfer partially failed"
1794                );
1795            } else {
1796                tracing::debug!(
1797                    num_blocks = success_count,
1798                    "Successfully transferred blocks to object storage"
1799                );
1800            }
1801
1802            // todo: merge the else part of this conditional and perhaps add the event tap for the successful transfers
1803            // for block transfers we emit an event as part of registration; however, we don't register g4 blocks in the
1804            // same way; therefore, we need a new convention on how we inform the broader system of the object creation
1805
1806            // Create meta files and release locks for successful transfers
1807            if let Some(lock_manager) = &shared.lock_manager {
1808                for hash in &successful_hashes {
1809                    // Create meta file to mark block as offloaded
1810                    if let Err(e) = lock_manager.create_meta(*hash).await {
1811                        tracing::error!(?hash, error = %e, "Failed to create meta file");
1812                    }
1813
1814                    // Release lock
1815                    if let Err(e) = lock_manager.release_lock(*hash).await {
1816                        tracing::error!(?hash, error = %e, "Failed to release lock");
1817                    }
1818                }
1819                tracing::debug!(
1820                    num_blocks = successful_hashes.len(),
1821                    "Created meta files and released locks"
1822                );
1823            }
1824        } else {
1825            // In skip mode, still do lock management if configured
1826            if let Some(lock_manager) = &shared.lock_manager {
1827                for hash in &keys {
1828                    if let Err(e) = lock_manager.create_meta(*hash).await {
1829                        tracing::error!(?hash, error = %e, "Failed to create meta file");
1830                    }
1831                    if let Err(e) = lock_manager.release_lock(*hash).await {
1832                        tracing::error!(?hash, error = %e, "Failed to release lock");
1833                    }
1834                }
1835            }
1836        }
1837
1838        // Mark transfer complete (batch-level, O(1))
1839        batch.timing.mark_transfer_complete();
1840
1841        // Compute timing statistics from batch timing
1842        let unique_transfer_ids: std::collections::HashSet<_> =
1843            resolved.iter().map(|b| b.transfer_id).collect();
1844
1845        let policy_ms = batch
1846            .timing
1847            .policy_duration()
1848            .map(|d| d.as_millis() as u64)
1849            .unwrap_or(0);
1850        let precondition_ms = batch
1851            .timing
1852            .precondition_duration()
1853            .map(|d| d.as_millis() as u64)
1854            .unwrap_or(0);
1855        let transfer_ms = batch
1856            .timing
1857            .transfer_duration()
1858            .map(|d| d.as_millis() as u64)
1859            .unwrap_or(0);
1860        let total_ms = batch
1861            .timing
1862            .total_duration()
1863            .map(|d| d.as_millis() as u64)
1864            .unwrap_or(0);
1865
1866        tracing::info!(
1867            blocks = resolved.len(),
1868            containers = unique_transfer_ids.len(),
1869            policy_ms,
1870            precondition_ms,
1871            transfer_ms,
1872            total_ms,
1873            src = std::any::type_name::<Src>(),
1874            dst = "G4-object",
1875            "Object batch transfer complete"
1876        );
1877
1878        // Build success lookup for filtering completion tracking.
1879        //
1880        // INVARIANT: SequenceHash values within a batch must be unique. This is
1881        // enforced by PendingTracker in PolicyEvaluator — each block's pending guard
1882        // is inserted into a DashSet before the next block is evaluated, so duplicate
1883        // hashes are filtered out. If this invariant is violated, success/failure
1884        // correlation becomes ambiguous because put_blocks() returns Result<SequenceHash, _>
1885        // without block-level identity (and S3 uses buffer_unordered, losing input order).
1886        let block_to_hash: std::collections::HashMap<BlockId, SequenceHash> = resolved
1887            .iter()
1888            .map(|b| (b.block_id, b.sequence_hash))
1889            .collect();
1890        let success_set: std::collections::HashSet<SequenceHash> =
1891            successful_hashes.into_iter().collect();
1892
1893        debug_assert_eq!(
1894            block_to_hash.len(),
1895            resolved.len(),
1896            "duplicate BlockId in batch — block_to_hash would lose entries"
1897        );
1898        debug_assert_eq!(
1899            resolved
1900                .iter()
1901                .map(|b| b.sequence_hash)
1902                .collect::<std::collections::HashSet<_>>()
1903                .len(),
1904            resolved.len(),
1905            "duplicate SequenceHash in batch — hash-based success correlation is ambiguous"
1906        );
1907
1908        // Mark blocks as completed/failed in each transfer state
1909        for (transfer_id, (state, block_ids)) in transfer_states {
1910            let mut state_guard = state.lock().unwrap();
1911
1912            if shared.skip_transfers {
1913                // In test/skip mode, all blocks are considered successful
1914                state_guard.mark_completed(block_ids);
1915            } else {
1916                let (succeeded, failed): (Vec<_>, Vec<_>) = block_ids.into_iter().partition(|id| {
1917                    block_to_hash
1918                        .get(id)
1919                        .is_some_and(|h| success_set.contains(h))
1920                });
1921                state_guard.mark_completed(succeeded);
1922                if !failed.is_empty() {
1923                    tracing::warn!(
1924                        %transfer_id,
1925                        failed_count = failed.len(),
1926                        "Marking blocks as failed in transfer state"
1927                    );
1928                    state_guard.mark_failed(failed);
1929                }
1930            }
1931
1932            let total = state_guard.passed_blocks.len() + state_guard.filtered_out.len();
1933            let done = state_guard.completed.len()
1934                + state_guard.failed.len()
1935                + state_guard.filtered_out.len();
1936            tracing::debug!(
1937                %transfer_id,
1938                total,
1939                done,
1940                passed = state_guard.passed_blocks.len(),
1941                filtered = state_guard.filtered_out.len(),
1942                completed = state_guard.completed.len(),
1943                failed = state_guard.failed.len(),
1944                "Object transfer batch progress"
1945            );
1946            if done >= total && total > 0 {
1947                let failed_count = state_guard.failed.len();
1948                if failed_count == 0 {
1949                    state_guard.set_complete();
1950                } else {
1951                    state_guard.set_error(format!(
1952                        "{failed_count} blocks failed to transfer to object storage",
1953                    ));
1954                }
1955            }
1956        }
1957
1958        Ok(())
1959    }
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964    use super::*;
1965
1966    #[test]
1967    fn test_pipeline_builder() {
1968        let config = PipelineBuilder::<(), ()>::new()
1969            .batch_size(32)
1970            .min_batch_size(8)
1971            .policy_timeout(Duration::from_millis(50))
1972            .auto_chain(true)
1973            .sweep_interval(Duration::from_millis(5))
1974            .build();
1975
1976        assert_eq!(config.batch_config.max_batch_size, 32);
1977        assert_eq!(config.batch_config.min_batch_size, 8);
1978        assert_eq!(config.policy_timeout, Duration::from_millis(50));
1979        assert!(config.auto_chain);
1980        assert_eq!(config.sweep_interval, Duration::from_millis(5));
1981    }
1982
1983    #[test]
1984    fn test_pipeline_config_default() {
1985        let config = PipelineConfig::<(), ()>::default();
1986        assert!(config.policies.is_empty());
1987        assert!(!config.auto_chain);
1988        assert_eq!(config.sweep_interval, Duration::from_millis(10));
1989    }
1990
1991    /// Mock ObjectBlockOps that fails specific hashes.
1992    struct FailableObjectBlockOps {
1993        fail_hashes: std::collections::HashSet<SequenceHash>,
1994    }
1995
1996    impl crate::object::ObjectBlockOps for FailableObjectBlockOps {
1997        fn has_blocks(
1998            &self,
1999            keys: Vec<SequenceHash>,
2000        ) -> futures::future::BoxFuture<'static, Vec<(SequenceHash, Option<usize>)>> {
2001            Box::pin(async move { keys.into_iter().map(|h| (h, Some(1))).collect() })
2002        }
2003
2004        fn put_blocks(
2005            &self,
2006            keys: Vec<SequenceHash>,
2007            _layout: LogicalLayoutHandle,
2008            _block_ids: Vec<BlockId>,
2009        ) -> futures::future::BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
2010            let fail_set = self.fail_hashes.clone();
2011            Box::pin(async move {
2012                keys.into_iter()
2013                    .map(|h| if fail_set.contains(&h) { Err(h) } else { Ok(h) })
2014                    .collect()
2015            })
2016        }
2017
2018        fn get_blocks(
2019            &self,
2020            keys: Vec<SequenceHash>,
2021            _layout: LogicalLayoutHandle,
2022            _block_ids: Vec<BlockId>,
2023        ) -> futures::future::BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
2024            Box::pin(async move { keys.into_iter().map(Ok).collect() })
2025        }
2026    }
2027
2028    fn test_hash(n: u64) -> SequenceHash {
2029        SequenceHash::new(n, None, 0)
2030    }
2031
2032    #[tokio::test]
2033    async fn test_execute_transfer_partial_failure() {
2034        use crate::offload::handle::{TransferState, TransferStatus};
2035
2036        let hash_ok_1 = test_hash(1);
2037        let hash_fail = test_hash(2);
2038        let hash_ok_2 = test_hash(3);
2039
2040        let fail_hashes = [hash_fail].into_iter().collect();
2041        let object_ops: Arc<dyn crate::object::ObjectBlockOps> =
2042            Arc::new(FailableObjectBlockOps { fail_hashes });
2043
2044        let shared = SharedObjectExecutorState {
2045            object_ops,
2046            src_layout: LogicalLayoutHandle::G2,
2047            skip_transfers: false,
2048            lock_manager: None,
2049        };
2050
2051        let transfer_id = crate::offload::handle::TransferId::new();
2052        let (mut state, handle) = TransferState::new(transfer_id, vec![10, 20, 30]);
2053        state.add_passed(vec![10, 20, 30]);
2054        state.mark_in_flight(vec![10, 20, 30]);
2055        let state_arc = Arc::new(std::sync::Mutex::new(state));
2056
2057        let blocks = vec![
2058            ResolvedBlock::<crate::G2> {
2059                transfer_id,
2060                block_id: 10,
2061                sequence_hash: hash_ok_1,
2062                guard: None,
2063                state: state_arc.clone(),
2064            },
2065            ResolvedBlock::<crate::G2> {
2066                transfer_id,
2067                block_id: 20,
2068                sequence_hash: hash_fail,
2069                guard: None,
2070                state: state_arc.clone(),
2071            },
2072            ResolvedBlock::<crate::G2> {
2073                transfer_id,
2074                block_id: 30,
2075                sequence_hash: hash_ok_2,
2076                guard: None,
2077                state: state_arc.clone(),
2078            },
2079        ];
2080
2081        let mut timing = TimingTrace::new();
2082        timing.mark_policy_complete();
2083        timing.mark_precondition_complete();
2084
2085        let batch = ResolvedBatch {
2086            blocks,
2087            evicted: Vec::new(),
2088            timing,
2089        };
2090
2091        ObjectTransferExecutor::<crate::G2>::execute_transfer(&shared, batch)
2092            .await
2093            .expect("execute_transfer should succeed");
2094
2095        // Verify: block 20 (hash_fail) should be in failed, not completed
2096        let state_guard = state_arc.lock().unwrap();
2097        assert_eq!(state_guard.completed, vec![10, 30]);
2098        assert_eq!(state_guard.failed, vec![20]);
2099        assert_eq!(state_guard.in_flight.len(), 0);
2100        assert_eq!(state_guard.status, TransferStatus::Failed);
2101        assert!(state_guard.error.is_some());
2102
2103        // Handle should reflect the same
2104        drop(state_guard);
2105        assert_eq!(handle.completed_blocks(), vec![10, 30]);
2106        assert_eq!(handle.failed_blocks(), vec![20]);
2107    }
2108
2109    #[tokio::test]
2110    async fn test_execute_transfer_all_success() {
2111        use crate::offload::handle::{TransferState, TransferStatus};
2112
2113        let hash1 = test_hash(1);
2114        let hash2 = test_hash(2);
2115
2116        let object_ops: Arc<dyn crate::object::ObjectBlockOps> = Arc::new(FailableObjectBlockOps {
2117            fail_hashes: std::collections::HashSet::new(),
2118        });
2119
2120        let shared = SharedObjectExecutorState {
2121            object_ops,
2122            src_layout: LogicalLayoutHandle::G2,
2123            skip_transfers: false,
2124            lock_manager: None,
2125        };
2126
2127        let transfer_id = crate::offload::handle::TransferId::new();
2128        let (mut state, handle) = TransferState::new(transfer_id, vec![10, 20]);
2129        state.add_passed(vec![10, 20]);
2130        state.mark_in_flight(vec![10, 20]);
2131        let state_arc = Arc::new(std::sync::Mutex::new(state));
2132
2133        let blocks = vec![
2134            ResolvedBlock::<crate::G2> {
2135                transfer_id,
2136                block_id: 10,
2137                sequence_hash: hash1,
2138                guard: None,
2139                state: state_arc.clone(),
2140            },
2141            ResolvedBlock::<crate::G2> {
2142                transfer_id,
2143                block_id: 20,
2144                sequence_hash: hash2,
2145                guard: None,
2146                state: state_arc.clone(),
2147            },
2148        ];
2149
2150        let mut timing = TimingTrace::new();
2151        timing.mark_policy_complete();
2152        timing.mark_precondition_complete();
2153
2154        let batch = ResolvedBatch {
2155            blocks,
2156            evicted: Vec::new(),
2157            timing,
2158        };
2159
2160        ObjectTransferExecutor::<crate::G2>::execute_transfer(&shared, batch)
2161            .await
2162            .expect("execute_transfer should succeed");
2163
2164        let state_guard = state_arc.lock().unwrap();
2165        assert_eq!(state_guard.completed, vec![10, 20]);
2166        assert!(state_guard.failed.is_empty());
2167        assert_eq!(state_guard.status, TransferStatus::Complete);
2168
2169        drop(state_guard);
2170        assert_eq!(handle.completed_blocks(), vec![10, 20]);
2171        assert!(handle.failed_blocks().is_empty());
2172    }
2173
2174    /// Mixed batch: two transfer_ids, one partially fails, the other fully succeeds.
2175    #[tokio::test]
2176    async fn test_execute_transfer_mixed_transfers() {
2177        use crate::offload::handle::{TransferState, TransferStatus};
2178
2179        let hash_a1 = test_hash(10);
2180        let hash_a2_fail = test_hash(20); // transfer A, will fail
2181        let hash_b1 = test_hash(30);
2182        let hash_b2 = test_hash(40);
2183
2184        let fail_hashes = [hash_a2_fail].into_iter().collect();
2185        let object_ops: Arc<dyn crate::object::ObjectBlockOps> =
2186            Arc::new(FailableObjectBlockOps { fail_hashes });
2187
2188        let shared = SharedObjectExecutorState {
2189            object_ops,
2190            src_layout: LogicalLayoutHandle::G2,
2191            skip_transfers: false,
2192            lock_manager: None,
2193        };
2194
2195        // Transfer A: blocks 100, 200 (200 will fail)
2196        let tid_a = crate::offload::handle::TransferId::new();
2197        let (mut state_a, handle_a) = TransferState::new(tid_a, vec![100, 200]);
2198        state_a.add_passed(vec![100, 200]);
2199        state_a.mark_in_flight(vec![100, 200]);
2200        let state_a_arc = Arc::new(std::sync::Mutex::new(state_a));
2201
2202        // Transfer B: blocks 300, 400 (both succeed)
2203        let tid_b = crate::offload::handle::TransferId::new();
2204        let (mut state_b, handle_b) = TransferState::new(tid_b, vec![300, 400]);
2205        state_b.add_passed(vec![300, 400]);
2206        state_b.mark_in_flight(vec![300, 400]);
2207        let state_b_arc = Arc::new(std::sync::Mutex::new(state_b));
2208
2209        let blocks = vec![
2210            ResolvedBlock::<crate::G2> {
2211                transfer_id: tid_a,
2212                block_id: 100,
2213                sequence_hash: hash_a1,
2214                guard: None,
2215                state: state_a_arc.clone(),
2216            },
2217            ResolvedBlock::<crate::G2> {
2218                transfer_id: tid_a,
2219                block_id: 200,
2220                sequence_hash: hash_a2_fail,
2221                guard: None,
2222                state: state_a_arc.clone(),
2223            },
2224            ResolvedBlock::<crate::G2> {
2225                transfer_id: tid_b,
2226                block_id: 300,
2227                sequence_hash: hash_b1,
2228                guard: None,
2229                state: state_b_arc.clone(),
2230            },
2231            ResolvedBlock::<crate::G2> {
2232                transfer_id: tid_b,
2233                block_id: 400,
2234                sequence_hash: hash_b2,
2235                guard: None,
2236                state: state_b_arc.clone(),
2237            },
2238        ];
2239
2240        let mut timing = TimingTrace::new();
2241        timing.mark_policy_complete();
2242        timing.mark_precondition_complete();
2243
2244        let batch = ResolvedBatch {
2245            blocks,
2246            evicted: Vec::new(),
2247            timing,
2248        };
2249
2250        ObjectTransferExecutor::<crate::G2>::execute_transfer(&shared, batch)
2251            .await
2252            .expect("execute_transfer should succeed");
2253
2254        // Transfer A: block 100 succeeded, block 200 failed
2255        let sa = state_a_arc.lock().unwrap();
2256        assert_eq!(sa.completed, vec![100]);
2257        assert_eq!(sa.failed, vec![200]);
2258        assert_eq!(sa.status, TransferStatus::Failed);
2259        assert!(sa.error.is_some());
2260        drop(sa);
2261
2262        assert_eq!(handle_a.completed_blocks(), vec![100]);
2263        assert_eq!(handle_a.failed_blocks(), vec![200]);
2264
2265        // Transfer B: both succeeded
2266        let sb = state_b_arc.lock().unwrap();
2267        assert_eq!(sb.completed, vec![300, 400]);
2268        assert!(sb.failed.is_empty());
2269        assert_eq!(sb.status, TransferStatus::Complete);
2270        drop(sb);
2271
2272        assert_eq!(handle_b.completed_blocks(), vec![300, 400]);
2273        assert!(handle_b.failed_blocks().is_empty());
2274    }
2275}