Skip to main content

kvbm_engine/offload/
batch.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Batch collection and accumulation for offload transfers.
5//!
6//! The batch collector accumulates blocks that pass policy evaluation and
7//! groups them into batches for efficient transfer execution.
8
9use std::collections::HashSet;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use tokio::sync::{mpsc, watch};
14use velo::EventHandle;
15
16use crate::{BlockId, SequenceHash};
17use kvbm_logical::blocks::BlockMetadata;
18
19use super::handle::TransferId;
20use super::pending::PendingGuard;
21use super::queue::CancellableQueue;
22use super::source::SourceBlock;
23
24/// Timing trace for tracking block progression through pipeline stages.
25///
26/// Each block carries a timing trace that records when it passed through
27/// each stage. This enables per-container and batch-level timing analysis.
28#[derive(Debug, Clone)]
29pub struct TimingTrace {
30    /// When the block was initially enqueued into the pipeline
31    pub enqueued_at: Instant,
32    /// When policy evaluation completed for this block
33    pub policy_complete_at: Option<Instant>,
34    /// When the precondition (e.g., forward pass) completed
35    pub precondition_complete_at: Option<Instant>,
36    /// When the block was added to a transfer batch
37    pub batched_at: Option<Instant>,
38    /// When the transfer operation started
39    pub transfer_start_at: Option<Instant>,
40    /// When the transfer operation completed
41    pub transfer_complete_at: Option<Instant>,
42}
43
44impl TimingTrace {
45    /// Create a new timing trace, recording the current time as enqueue time.
46    pub fn new() -> Self {
47        Self {
48            enqueued_at: Instant::now(),
49            policy_complete_at: None,
50            precondition_complete_at: None,
51            batched_at: None,
52            transfer_start_at: None,
53            transfer_complete_at: None,
54        }
55    }
56
57    /// Mark policy evaluation complete.
58    pub fn mark_policy_complete(&mut self) {
59        self.policy_complete_at = Some(Instant::now());
60    }
61
62    /// Mark precondition complete.
63    pub fn mark_precondition_complete(&mut self) {
64        self.precondition_complete_at = Some(Instant::now());
65    }
66
67    /// Mark block as batched.
68    pub fn mark_batched(&mut self) {
69        self.batched_at = Some(Instant::now());
70    }
71
72    /// Mark transfer start.
73    pub fn mark_transfer_start(&mut self) {
74        self.transfer_start_at = Some(Instant::now());
75    }
76
77    /// Mark transfer complete.
78    pub fn mark_transfer_complete(&mut self) {
79        self.transfer_complete_at = Some(Instant::now());
80    }
81
82    /// Get total time from enqueue to transfer complete (if available).
83    pub fn total_duration(&self) -> Option<Duration> {
84        self.transfer_complete_at
85            .map(|end| end.duration_since(self.enqueued_at))
86    }
87
88    /// Get policy evaluation duration (if available).
89    pub fn policy_duration(&self) -> Option<Duration> {
90        self.policy_complete_at
91            .map(|end| end.duration_since(self.enqueued_at))
92    }
93
94    /// Get precondition wait duration (if available).
95    pub fn precondition_duration(&self) -> Option<Duration> {
96        match (self.policy_complete_at, self.precondition_complete_at) {
97            (Some(start), Some(end)) => Some(end.duration_since(start)),
98            _ => None,
99        }
100    }
101
102    /// Get transfer duration (if available).
103    pub fn transfer_duration(&self) -> Option<Duration> {
104        match (self.transfer_start_at, self.transfer_complete_at) {
105            (Some(start), Some(end)) => Some(end.duration_since(start)),
106            _ => None,
107        }
108    }
109}
110
111impl Default for TimingTrace {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117/// Configuration for batch collection.
118#[derive(Debug, Clone)]
119pub struct BatchConfig {
120    /// Maximum blocks per batch
121    pub max_batch_size: usize,
122    /// Time to wait before flushing a partial batch
123    pub flush_interval: Duration,
124    /// Minimum batch size before flush (unless timeout)
125    pub min_batch_size: usize,
126}
127
128impl Default for BatchConfig {
129    fn default() -> Self {
130        Self {
131            max_batch_size: 1024,
132            flush_interval: Duration::from_millis(10),
133            min_batch_size: 8,
134        }
135    }
136}
137
138impl BatchConfig {
139    /// Create a new batch config with specified max size.
140    pub fn with_max_size(mut self, size: usize) -> Self {
141        self.max_batch_size = size;
142        self
143    }
144
145    /// Set the flush interval.
146    pub fn with_flush_interval(mut self, interval: Duration) -> Self {
147        self.flush_interval = interval;
148        self
149    }
150
151    /// Set the minimum batch size.
152    pub fn with_min_size(mut self, size: usize) -> Self {
153        self.min_batch_size = size;
154        self
155    }
156}
157
158/// A block that passed policy evaluation and is queued for transfer.
159#[allow(dead_code)]
160pub struct QueuedBlock<T: BlockMetadata> {
161    /// Transfer ID this block belongs to
162    pub transfer_id: TransferId,
163    /// Block ID - Some for External/Strong, None for Weak (determined at upgrade)
164    pub block_id: Option<BlockId>,
165    /// Sequence hash
166    pub sequence_hash: SequenceHash,
167    /// Source block - Strong/External pass through, Weak upgraded just before transfer
168    pub source: SourceBlock<T>,
169    /// Transfer state for completion tracking
170    pub(crate) state: Arc<std::sync::Mutex<TransferState>>,
171    /// RAII guard that removes this block from pending set on drop.
172    ///
173    /// This ensures duplicate prevention tracking is automatically cleaned up
174    /// when the block completes transfer, is cancelled, or errors out.
175    pub pending_guard: Option<PendingGuard>,
176}
177
178impl<T: BlockMetadata> std::fmt::Debug for QueuedBlock<T> {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("QueuedBlock")
181            .field("transfer_id", &self.transfer_id)
182            .field("block_id", &self.block_id)
183            .field("sequence_hash", &self.sequence_hash)
184            .finish()
185    }
186}
187
188/// A batch of blocks ready for transfer execution.
189pub struct TransferBatch<T: BlockMetadata> {
190    /// Blocks in this batch
191    pub blocks: Vec<QueuedBlock<T>>,
192    /// Optional precondition event that must be satisfied before processing.
193    /// If Some, the pipeline will await this event before executing the transfer.
194    pub precondition: Option<EventHandle>,
195    /// Timing trace for performance monitoring (batch-level, not per-block).
196    pub timing: TimingTrace,
197}
198
199impl<T: BlockMetadata> TransferBatch<T> {
200    /// Create a new empty batch.
201    pub fn new() -> Self {
202        Self {
203            blocks: Vec::new(),
204            precondition: None,
205            timing: TimingTrace::new(),
206        }
207    }
208
209    /// Create with pre-allocated capacity.
210    pub fn with_capacity(capacity: usize) -> Self {
211        Self {
212            blocks: Vec::with_capacity(capacity),
213            precondition: None,
214            timing: TimingTrace::new(),
215        }
216    }
217
218    /// Set the precondition event for this batch.
219    #[allow(dead_code)]
220    pub fn with_precondition(mut self, precondition: EventHandle) -> Self {
221        self.precondition = Some(precondition);
222        self
223    }
224
225    /// Add a block to this batch.
226    pub fn push(&mut self, block: QueuedBlock<T>) {
227        self.blocks.push(block);
228    }
229
230    /// Get the number of blocks in this batch.
231    pub fn len(&self) -> usize {
232        self.blocks.len()
233    }
234
235    /// Check if batch is empty.
236    pub fn is_empty(&self) -> bool {
237        self.blocks.is_empty()
238    }
239
240    /// Get block IDs in this batch (only for blocks with known IDs).
241    ///
242    /// Weak blocks may have `None` for block_id until upgraded.
243    /// The TransferExecutor resolves actual block_ids at transfer time.
244    #[allow(dead_code)]
245    pub fn block_ids(&self) -> Vec<BlockId> {
246        self.blocks.iter().filter_map(|b| b.block_id).collect()
247    }
248
249    /// Get sequence hashes in this batch.
250    #[allow(dead_code)]
251    pub fn sequence_hashes(&self) -> Vec<SequenceHash> {
252        self.blocks.iter().map(|b| b.sequence_hash).collect()
253    }
254
255    /// Get unique transfer IDs in this batch.
256    #[allow(dead_code)]
257    pub fn transfer_ids(&self) -> Vec<TransferId> {
258        let mut ids: Vec<TransferId> = self.blocks.iter().map(|b| b.transfer_id).collect();
259        ids.sort_by_key(|id| id.as_uuid());
260        ids.dedup();
261        ids
262    }
263
264    /// Take all blocks out of this batch.
265    #[allow(dead_code)]
266    pub fn take(&mut self) -> Vec<QueuedBlock<T>> {
267        std::mem::take(&mut self.blocks)
268    }
269
270    /// Drain blocks for the given transfer ID (for cancellation).
271    #[allow(dead_code)]
272    pub fn drain_transfer(&mut self, transfer_id: TransferId) -> Vec<QueuedBlock<T>> {
273        let mut kept = Vec::new();
274        let mut drained = Vec::new();
275        for block in std::mem::take(&mut self.blocks) {
276            if block.transfer_id == transfer_id {
277                drained.push(block);
278            } else {
279                kept.push(block);
280            }
281        }
282        self.blocks = kept;
283        drained
284    }
285}
286
287impl<T: BlockMetadata> Default for TransferBatch<T> {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293use super::handle::TransferState;
294
295/// Result of policy evaluation - blocks ready for batching.
296#[allow(dead_code)]
297pub struct EvalResult<T: BlockMetadata> {
298    /// Transfer ID
299    pub transfer_id: TransferId,
300    /// Blocks that passed all policies
301    pub passed_blocks: Vec<QueuedBlock<T>>,
302    /// Block IDs that were filtered out
303    pub filtered_ids: Vec<BlockId>,
304    /// Transfer state for completion tracking
305    pub(crate) state: Arc<std::sync::Mutex<TransferState>>,
306}
307
308/// Output from the batch collector to transfer executor.
309pub type BatchOutput<T> = mpsc::Sender<TransferBatch<T>>;
310/// Receiver side of batch output channel.
311pub type BatchOutputRx<T> = mpsc::Receiver<TransferBatch<T>>;
312
313/// Extract the common precondition from a batch of blocks.
314///
315/// If all blocks share the same precondition, returns it.
316/// Otherwise returns `None`.
317fn extract_common_precondition<T: BlockMetadata>(blocks: &[QueuedBlock<T>]) -> Option<EventHandle> {
318    blocks.first().and_then(|first_block| {
319        let first_precondition = first_block.state.lock().unwrap().precondition;
320        let all_same = blocks
321            .iter()
322            .all(|block| block.state.lock().unwrap().precondition == first_precondition);
323        if all_same { first_precondition } else { None }
324    })
325}
326
327/// Batch collector that accumulates blocks and flushes batches.
328///
329/// The collector accumulates blocks from policy evaluation (via `CancellableQueue`)
330/// and groups them into batches based on the configuration. Batches are flushed when:
331/// - `max_batch_size` is reached
332/// - `flush_interval` expires and `min_batch_size` is met
333/// - Shutdown is requested
334pub struct BatchCollector<T: BlockMetadata> {
335    config: BatchConfig,
336    /// Input queue from policy evaluator
337    input_queue: Arc<CancellableQueue<EvalResult<T>>>,
338    /// Output channel to transfer executor
339    output_tx: BatchOutput<T>,
340    /// Cancel watch receiver
341    cancel_rx: watch::Receiver<HashSet<TransferId>>,
342    /// Current batch being built
343    current_batch: TransferBatch<T>,
344}
345
346impl<T: BlockMetadata> BatchCollector<T> {
347    /// Create a new batch collector.
348    pub fn new(
349        config: BatchConfig,
350        input_queue: Arc<CancellableQueue<EvalResult<T>>>,
351        output_tx: BatchOutput<T>,
352        cancel_rx: watch::Receiver<HashSet<TransferId>>,
353    ) -> Self {
354        let max_batch_size = config.max_batch_size;
355        Self {
356            config,
357            input_queue,
358            output_tx,
359            cancel_rx,
360            current_batch: TransferBatch::with_capacity(max_batch_size),
361        }
362    }
363
364    /// Run the batch collector loop.
365    pub async fn run(mut self) {
366        let mut flush_timer = tokio::time::interval(self.config.flush_interval);
367        flush_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
368
369        loop {
370            while let Some(item) = self.input_queue.pop_valid() {
371                self.handle_eval_result(item.data).await;
372            }
373
374            tokio::select! {
375                _ = self.input_queue.notified() => {}
376                // Periodic flush timer
377                _ = flush_timer.tick() => {
378                    self.try_flush().await;
379                }
380                // Check for shutdown
381                result = self.cancel_rx.changed() => {
382                    if result.is_err() {
383                        // Channel closed, flush and exit
384                        self.flush_if_not_empty().await;
385                        break;
386                    }
387                }
388            }
389        }
390    }
391
392    /// Handle an evaluation result.
393    ///
394    /// Adds passed blocks to the current batch and flushes when:
395    /// - max_batch_size is reached, OR
396    /// - all blocks for a transfer have been processed (per-transfer sentinel flush)
397    async fn handle_eval_result(&mut self, result: EvalResult<T>) {
398        // Count blocks processed in this eval result (both passed and filtered)
399        let blocks_in_eval = result.passed_blocks.len() + result.filtered_ids.len();
400
401        // Add passed blocks to current batch
402        for block in result.passed_blocks {
403            self.current_batch.push(block);
404
405            // Flush if we've reached max batch size
406            if self.current_batch.len() >= self.config.max_batch_size {
407                self.flush().await;
408            }
409        }
410
411        // Update transfer state and check if transfer is complete (sentinel flush)
412        let should_flush = {
413            let mut state = result.state.lock().unwrap();
414            state.blocks_processed += blocks_in_eval;
415            // Flush when all blocks for this transfer have been processed
416            state.blocks_processed >= state.total_expected_blocks && state.total_expected_blocks > 0
417        };
418
419        // Flush immediately when a transfer completes to avoid waiting for min_batch_size
420        if should_flush && !self.current_batch.is_empty() {
421            tracing::debug!(
422                transfer_id = %result.transfer_id,
423                batch_size = self.current_batch.len(),
424                "Per-transfer sentinel flush"
425            );
426            self.flush().await;
427        }
428    }
429
430    /// Try to flush if minimum batch size is reached.
431    async fn try_flush(&mut self) {
432        if self.current_batch.len() >= self.config.min_batch_size {
433            self.flush().await;
434        }
435    }
436
437    /// Flush current batch if not empty.
438    async fn flush_if_not_empty(&mut self) {
439        if !self.current_batch.is_empty() {
440            self.flush().await;
441        }
442    }
443
444    /// Flush the current batch to the output channel.
445    async fn flush(&mut self) {
446        nvtx_range!("offload::batch");
447        if self.current_batch.is_empty() {
448            return;
449        }
450
451        let mut batch = std::mem::replace(
452            &mut self.current_batch,
453            TransferBatch::with_capacity(self.config.max_batch_size),
454        );
455
456        // Mark batch as ready (single O(1) call, not per-block)
457        batch.timing.mark_batched();
458
459        batch.precondition = extract_common_precondition(&batch.blocks);
460
461        // Send to transfer executor
462        if self.output_tx.send(batch).await.is_err() {
463            // Output channel closed, log and continue
464            tracing::warn!("Batch output channel closed");
465        }
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn test_batch_config_default() {
475        let config = BatchConfig::default();
476        assert_eq!(config.max_batch_size, 1024);
477        assert_eq!(config.min_batch_size, 8);
478    }
479
480    #[test]
481    fn test_batch_config_builder() {
482        let config = BatchConfig::default()
483            .with_max_size(128)
484            .with_min_size(16)
485            .with_flush_interval(Duration::from_millis(50));
486
487        assert_eq!(config.max_batch_size, 128);
488        assert_eq!(config.min_batch_size, 16);
489        assert_eq!(config.flush_interval, Duration::from_millis(50));
490    }
491
492    #[test]
493    fn test_transfer_batch() {
494        let batch: TransferBatch<()> = TransferBatch::new();
495        assert!(batch.is_empty());
496        assert_eq!(batch.len(), 0);
497    }
498
499    #[tokio::test]
500    async fn test_batch_collector_empty_input() {
501        let input_queue = Arc::new(CancellableQueue::<EvalResult<()>>::new());
502        let (output_tx, mut output_rx) = mpsc::channel::<TransferBatch<()>>(10);
503        let (cancel_tx, cancel_rx) = watch::channel(HashSet::new());
504
505        let collector =
506            BatchCollector::new(BatchConfig::default(), input_queue, output_tx, cancel_rx);
507
508        // Drop cancel sender to close channel (triggers shutdown)
509        drop(cancel_tx);
510
511        // Run collector
512        tokio::spawn(async move {
513            collector.run().await;
514        });
515
516        // Should receive nothing (empty input)
517        let result = tokio::time::timeout(Duration::from_millis(50), output_rx.recv()).await;
518        assert!(result.is_err() || result.unwrap().is_none());
519    }
520
521    #[test]
522    fn test_transfer_batch_with_capacity() {
523        let batch: TransferBatch<()> = TransferBatch::with_capacity(128);
524        assert!(batch.is_empty());
525        assert_eq!(batch.len(), 0);
526    }
527
528    #[test]
529    fn test_batch_config_with_methods() {
530        let config = BatchConfig::default()
531            .with_max_size(256)
532            .with_min_size(32)
533            .with_flush_interval(Duration::from_millis(100));
534
535        assert_eq!(config.max_batch_size, 256);
536        assert_eq!(config.min_batch_size, 32);
537        assert_eq!(config.flush_interval, Duration::from_millis(100));
538    }
539
540    #[test]
541    fn test_transfer_batch_methods() {
542        let mut batch: TransferBatch<()> = TransferBatch::new();
543
544        // Note: We can't easily create QueuedBlock without the full pipeline setup,
545        // so this test just verifies the batch structure methods work on empty batches
546        assert!(batch.block_ids().is_empty());
547        assert!(batch.sequence_hashes().is_empty());
548        assert!(batch.transfer_ids().is_empty());
549
550        // Verify take() works
551        let taken = batch.take();
552        assert!(taken.is_empty());
553        assert!(batch.is_empty());
554    }
555
556    #[test]
557    fn test_batch_precondition() {
558        let batch: TransferBatch<()> = TransferBatch::new();
559        assert!(batch.precondition.is_none());
560
561        // Note: with_precondition requires an EventHandle which is complex to create
562        // in a unit test, so we just verify the field exists and is None by default
563    }
564}