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