use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch};
use velo::EventHandle;
use crate::{BlockId, SequenceHash};
use kvbm_logical::blocks::BlockMetadata;
use super::handle::TransferId;
use super::pending::PendingGuard;
use super::queue::CancellableQueue;
use super::source::SourceBlock;
#[derive(Debug, Clone)]
pub struct TimingTrace {
pub enqueued_at: Instant,
pub policy_complete_at: Option<Instant>,
pub precondition_complete_at: Option<Instant>,
pub batched_at: Option<Instant>,
pub transfer_start_at: Option<Instant>,
pub transfer_complete_at: Option<Instant>,
}
impl TimingTrace {
pub fn new() -> Self {
Self {
enqueued_at: Instant::now(),
policy_complete_at: None,
precondition_complete_at: None,
batched_at: None,
transfer_start_at: None,
transfer_complete_at: None,
}
}
pub fn mark_policy_complete(&mut self) {
self.policy_complete_at = Some(Instant::now());
}
pub fn mark_precondition_complete(&mut self) {
self.precondition_complete_at = Some(Instant::now());
}
pub fn mark_batched(&mut self) {
self.batched_at = Some(Instant::now());
}
pub fn mark_transfer_start(&mut self) {
self.transfer_start_at = Some(Instant::now());
}
pub fn mark_transfer_complete(&mut self) {
self.transfer_complete_at = Some(Instant::now());
}
pub fn total_duration(&self) -> Option<Duration> {
self.transfer_complete_at
.map(|end| end.duration_since(self.enqueued_at))
}
pub fn policy_duration(&self) -> Option<Duration> {
self.policy_complete_at
.map(|end| end.duration_since(self.enqueued_at))
}
pub fn precondition_duration(&self) -> Option<Duration> {
match (self.policy_complete_at, self.precondition_complete_at) {
(Some(start), Some(end)) => Some(end.duration_since(start)),
_ => None,
}
}
pub fn transfer_duration(&self) -> Option<Duration> {
match (self.transfer_start_at, self.transfer_complete_at) {
(Some(start), Some(end)) => Some(end.duration_since(start)),
_ => None,
}
}
}
impl Default for TimingTrace {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct BatchConfig {
pub max_batch_size: usize,
pub flush_interval: Duration,
pub min_batch_size: usize,
}
impl Default for BatchConfig {
fn default() -> Self {
Self {
max_batch_size: 1024,
flush_interval: Duration::from_millis(10),
min_batch_size: 8,
}
}
}
impl BatchConfig {
pub fn with_max_size(mut self, size: usize) -> Self {
self.max_batch_size = size;
self
}
pub fn with_flush_interval(mut self, interval: Duration) -> Self {
self.flush_interval = interval;
self
}
pub fn with_min_size(mut self, size: usize) -> Self {
self.min_batch_size = size;
self
}
}
#[allow(dead_code)]
pub struct QueuedBlock<T: BlockMetadata> {
pub transfer_id: TransferId,
pub block_id: Option<BlockId>,
pub sequence_hash: SequenceHash,
pub source: SourceBlock<T>,
pub(crate) state: Arc<std::sync::Mutex<TransferState>>,
pub pending_guard: Option<PendingGuard>,
}
impl<T: BlockMetadata> std::fmt::Debug for QueuedBlock<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QueuedBlock")
.field("transfer_id", &self.transfer_id)
.field("block_id", &self.block_id)
.field("sequence_hash", &self.sequence_hash)
.finish()
}
}
pub struct TransferBatch<T: BlockMetadata> {
pub blocks: Vec<QueuedBlock<T>>,
pub precondition: Option<EventHandle>,
pub timing: TimingTrace,
}
impl<T: BlockMetadata> TransferBatch<T> {
pub fn new() -> Self {
Self {
blocks: Vec::new(),
precondition: None,
timing: TimingTrace::new(),
}
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
blocks: Vec::with_capacity(capacity),
precondition: None,
timing: TimingTrace::new(),
}
}
#[allow(dead_code)]
pub fn with_precondition(mut self, precondition: EventHandle) -> Self {
self.precondition = Some(precondition);
self
}
pub fn push(&mut self, block: QueuedBlock<T>) {
self.blocks.push(block);
}
pub fn len(&self) -> usize {
self.blocks.len()
}
pub fn is_empty(&self) -> bool {
self.blocks.is_empty()
}
#[allow(dead_code)]
pub fn block_ids(&self) -> Vec<BlockId> {
self.blocks.iter().filter_map(|b| b.block_id).collect()
}
#[allow(dead_code)]
pub fn sequence_hashes(&self) -> Vec<SequenceHash> {
self.blocks.iter().map(|b| b.sequence_hash).collect()
}
#[allow(dead_code)]
pub fn transfer_ids(&self) -> Vec<TransferId> {
let mut ids: Vec<TransferId> = self.blocks.iter().map(|b| b.transfer_id).collect();
ids.sort_by_key(|id| id.as_uuid());
ids.dedup();
ids
}
#[allow(dead_code)]
pub fn take(&mut self) -> Vec<QueuedBlock<T>> {
std::mem::take(&mut self.blocks)
}
#[allow(dead_code)]
pub fn drain_transfer(&mut self, transfer_id: TransferId) -> Vec<QueuedBlock<T>> {
let mut kept = Vec::new();
let mut drained = Vec::new();
for block in std::mem::take(&mut self.blocks) {
if block.transfer_id == transfer_id {
drained.push(block);
} else {
kept.push(block);
}
}
self.blocks = kept;
drained
}
}
impl<T: BlockMetadata> Default for TransferBatch<T> {
fn default() -> Self {
Self::new()
}
}
use super::handle::TransferState;
#[allow(dead_code)]
pub struct EvalResult<T: BlockMetadata> {
pub transfer_id: TransferId,
pub passed_blocks: Vec<QueuedBlock<T>>,
pub filtered_ids: Vec<BlockId>,
pub(crate) state: Arc<std::sync::Mutex<TransferState>>,
}
pub type BatchOutput<T> = mpsc::Sender<TransferBatch<T>>;
pub type BatchOutputRx<T> = mpsc::Receiver<TransferBatch<T>>;
fn extract_common_precondition<T: BlockMetadata>(blocks: &[QueuedBlock<T>]) -> Option<EventHandle> {
blocks.first().and_then(|first_block| {
let first_precondition = first_block.state.lock().unwrap().precondition;
let all_same = blocks
.iter()
.all(|block| block.state.lock().unwrap().precondition == first_precondition);
if all_same { first_precondition } else { None }
})
}
pub struct BatchCollector<T: BlockMetadata> {
config: BatchConfig,
input_queue: Arc<CancellableQueue<EvalResult<T>>>,
output_tx: BatchOutput<T>,
cancel_rx: watch::Receiver<HashSet<TransferId>>,
current_batch: TransferBatch<T>,
}
impl<T: BlockMetadata> BatchCollector<T> {
pub fn new(
config: BatchConfig,
input_queue: Arc<CancellableQueue<EvalResult<T>>>,
output_tx: BatchOutput<T>,
cancel_rx: watch::Receiver<HashSet<TransferId>>,
) -> Self {
let max_batch_size = config.max_batch_size;
Self {
config,
input_queue,
output_tx,
cancel_rx,
current_batch: TransferBatch::with_capacity(max_batch_size),
}
}
pub async fn run(mut self) {
let mut flush_timer = tokio::time::interval(self.config.flush_interval);
flush_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
while let Some(item) = self.input_queue.pop_valid() {
self.handle_eval_result(item.data).await;
}
tokio::select! {
_ = self.input_queue.notified() => {}
_ = flush_timer.tick() => {
self.try_flush().await;
}
result = self.cancel_rx.changed() => {
if result.is_err() {
self.flush_if_not_empty().await;
break;
}
}
}
}
}
async fn handle_eval_result(&mut self, result: EvalResult<T>) {
let blocks_in_eval = result.passed_blocks.len() + result.filtered_ids.len();
for block in result.passed_blocks {
self.current_batch.push(block);
if self.current_batch.len() >= self.config.max_batch_size {
self.flush().await;
}
}
let should_flush = {
let mut state = result.state.lock().unwrap();
state.blocks_processed += blocks_in_eval;
state.blocks_processed >= state.total_expected_blocks && state.total_expected_blocks > 0
};
if should_flush && !self.current_batch.is_empty() {
tracing::debug!(
transfer_id = %result.transfer_id,
batch_size = self.current_batch.len(),
"Per-transfer sentinel flush"
);
self.flush().await;
}
}
async fn try_flush(&mut self) {
if self.current_batch.len() >= self.config.min_batch_size {
self.flush().await;
}
}
async fn flush_if_not_empty(&mut self) {
if !self.current_batch.is_empty() {
self.flush().await;
}
}
async fn flush(&mut self) {
nvtx_range!("offload::batch");
if self.current_batch.is_empty() {
return;
}
let mut batch = std::mem::replace(
&mut self.current_batch,
TransferBatch::with_capacity(self.config.max_batch_size),
);
batch.timing.mark_batched();
batch.precondition = extract_common_precondition(&batch.blocks);
if self.output_tx.send(batch).await.is_err() {
tracing::warn!("Batch output channel closed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_batch_config_default() {
let config = BatchConfig::default();
assert_eq!(config.max_batch_size, 1024);
assert_eq!(config.min_batch_size, 8);
}
#[test]
fn test_batch_config_builder() {
let config = BatchConfig::default()
.with_max_size(128)
.with_min_size(16)
.with_flush_interval(Duration::from_millis(50));
assert_eq!(config.max_batch_size, 128);
assert_eq!(config.min_batch_size, 16);
assert_eq!(config.flush_interval, Duration::from_millis(50));
}
#[test]
fn test_transfer_batch() {
let batch: TransferBatch<()> = TransferBatch::new();
assert!(batch.is_empty());
assert_eq!(batch.len(), 0);
}
#[tokio::test]
async fn test_batch_collector_empty_input() {
let input_queue = Arc::new(CancellableQueue::<EvalResult<()>>::new());
let (output_tx, mut output_rx) = mpsc::channel::<TransferBatch<()>>(10);
let (cancel_tx, cancel_rx) = watch::channel(HashSet::new());
let collector =
BatchCollector::new(BatchConfig::default(), input_queue, output_tx, cancel_rx);
drop(cancel_tx);
tokio::spawn(async move {
collector.run().await;
});
let result = tokio::time::timeout(Duration::from_millis(50), output_rx.recv()).await;
assert!(result.is_err() || result.unwrap().is_none());
}
#[test]
fn test_transfer_batch_with_capacity() {
let batch: TransferBatch<()> = TransferBatch::with_capacity(128);
assert!(batch.is_empty());
assert_eq!(batch.len(), 0);
}
#[test]
fn test_batch_config_with_methods() {
let config = BatchConfig::default()
.with_max_size(256)
.with_min_size(32)
.with_flush_interval(Duration::from_millis(100));
assert_eq!(config.max_batch_size, 256);
assert_eq!(config.min_batch_size, 32);
assert_eq!(config.flush_interval, Duration::from_millis(100));
}
#[test]
fn test_transfer_batch_methods() {
let mut batch: TransferBatch<()> = TransferBatch::new();
assert!(batch.block_ids().is_empty());
assert!(batch.sequence_hashes().is_empty());
assert!(batch.transfer_ids().is_empty());
let taken = batch.take();
assert!(taken.is_empty());
assert!(batch.is_empty());
}
#[test]
fn test_batch_precondition() {
let batch: TransferBatch<()> = TransferBatch::new();
assert!(batch.precondition.is_none());
}
}