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