use crate::client::admin::FlussAdmin;
use crate::client::table::RecordBatchLogScanner;
use crate::error::{Error, Result};
use crate::metadata::{TableBucket, TablePath};
use crate::record::ScanBatch;
use crate::rpc::message::OffsetSpec;
use crate::{PartitionId, TableId};
use arrow::record_batch::RecordBatch;
use arrow_schema::SchemaRef;
use futures::{Stream, future::try_join_all};
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::{Duration, Instant};
const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Debug)]
pub enum RecordBatchReadOutcome {
Batch(ScanBatch),
TimedOut,
Finished,
}
#[derive(Debug, Clone)]
pub struct BoundedLogReadRange {
pub bucket: TableBucket,
pub starting_offset: i64,
pub stopping_offset: i64,
}
#[derive(Debug)]
pub struct BoundedCollectOutcome {
pub batches: Vec<ScanBatch>,
pub complete: bool,
}
pub struct RecordBatchLogReader {
scanner: RecordBatchLogScanner,
stopping_offsets: HashMap<TableBucket, i64>,
buffer: VecDeque<ScanBatch>,
schema: SchemaRef,
}
struct ReaderActivationGuard<'a> {
scanner: &'a RecordBatchLogScanner,
clear_on_drop: bool,
}
impl<'a> ReaderActivationGuard<'a> {
fn acquire(scanner: &'a RecordBatchLogScanner) -> Result<Self> {
scanner.try_set_reader_active()?;
Ok(Self {
scanner,
clear_on_drop: true,
})
}
fn keep_active(mut self) {
self.clear_on_drop = false;
}
}
impl Drop for ReaderActivationGuard<'_> {
fn drop(&mut self) {
if self.clear_on_drop {
self.scanner.clear_reader_active();
}
}
}
impl RecordBatchLogReader {
pub async fn new_until_latest(
scanner: RecordBatchLogScanner,
admin: &FlussAdmin,
) -> Result<Self> {
let activation = ReaderActivationGuard::acquire(&scanner)?;
let subscribed = scanner.get_subscribed_buckets();
if subscribed.is_empty() {
return Err(Error::IllegalArgument {
message: "No buckets subscribed. Call subscribe() before creating a reader."
.to_string(),
});
}
validate_read_buckets(
scanner.table_id(),
scanner.is_partitioned(),
scanner.num_buckets(),
subscribed.iter().map(|(bucket, _)| bucket),
)?;
let stopping_offsets = query_latest_offsets(admin, &scanner, &subscribed).await?;
unsubscribe_completed_buckets(
&scanner,
subscribed
.iter()
.map(|(bucket, _)| bucket)
.filter(|bucket| !stopping_offsets.contains_key(*bucket)),
);
let schema = scanner.schema();
activation.keep_active();
Ok(Self {
scanner,
stopping_offsets,
buffer: VecDeque::new(),
schema,
})
}
pub fn new_until_offsets(
scanner: RecordBatchLogScanner,
mut stopping_offsets: HashMap<TableBucket, i64>,
) -> Result<Self> {
let activation = ReaderActivationGuard::acquire(&scanner)?;
validate_read_buckets(
scanner.table_id(),
scanner.is_partitioned(),
scanner.num_buckets(),
stopping_offsets.keys(),
)?;
let completed =
validate_stopping_offsets(scanner.get_subscribed_buckets(), &mut stopping_offsets)?;
unsubscribe_completed_buckets(&scanner, &completed);
let schema = scanner.schema();
activation.keep_active();
Ok(Self {
scanner,
stopping_offsets,
buffer: VecDeque::new(),
schema,
})
}
pub async fn new_from_ranges(
scanner: RecordBatchLogScanner,
ranges: Vec<BoundedLogReadRange>,
) -> Result<Self> {
let activation = ReaderActivationGuard::acquire(&scanner)?;
validate_read_ranges(
scanner.table_id(),
scanner.is_partitioned(),
scanner.num_buckets(),
&ranges,
)?;
if !scanner.get_subscribed_buckets().is_empty() {
return Err(Error::IllegalArgument {
message: "new_from_ranges requires a scanner without existing subscriptions."
.to_string(),
});
}
let mut stopping_offsets = HashMap::with_capacity(ranges.len());
let mut bucket_offsets: HashMap<i32, i64> = HashMap::new();
let mut partition_bucket_offsets: HashMap<(PartitionId, i32), i64> = HashMap::new();
for range in ranges {
if range.stopping_offset == 0 || range.starting_offset == range.stopping_offset {
continue;
}
match range.bucket.partition_id() {
Some(partition_id) => {
partition_bucket_offsets.insert(
(partition_id, range.bucket.bucket_id()),
range.starting_offset,
);
}
None => {
bucket_offsets.insert(range.bucket.bucket_id(), range.starting_offset);
}
}
stopping_offsets.insert(range.bucket, range.stopping_offset);
}
if !bucket_offsets.is_empty() {
scanner
.subscribe_buckets_for_reader(&bucket_offsets)
.await?;
}
if !partition_bucket_offsets.is_empty() {
scanner
.subscribe_partition_buckets_for_reader(&partition_bucket_offsets)
.await?;
}
let schema = scanner.schema();
activation.keep_active();
Ok(Self {
scanner,
stopping_offsets,
buffer: VecDeque::new(),
schema,
})
}
pub async fn new_between_timestamps(
scanner: RecordBatchLogScanner,
admin: &FlussAdmin,
buckets: &[TableBucket],
starting_timestamp_ms: i64,
stopping_timestamp_ms: i64,
) -> Result<Self> {
if starting_timestamp_ms > stopping_timestamp_ms {
return Err(Error::IllegalArgument {
message: "starting_timestamp_ms must not exceed stopping_timestamp_ms.".to_string(),
});
}
validate_read_buckets(
scanner.table_id(),
scanner.is_partitioned(),
scanner.num_buckets(),
buckets,
)?;
if buckets.is_empty() {
return Self::new_from_ranges(scanner, Vec::new()).await;
}
let resolver = OffsetResolver::new(admin, &scanner).await?;
let starting_offsets = resolver
.resolve(buckets, OffsetSpec::Timestamp(starting_timestamp_ms))
.await?;
let stopping_offsets = resolver
.resolve(buckets, OffsetSpec::Timestamp(stopping_timestamp_ms))
.await?;
let mut ranges = Vec::with_capacity(buckets.len());
for bucket in buckets {
let (Some(&starting_offset), Some(&stopping_offset)) =
(starting_offsets.get(bucket), stopping_offsets.get(bucket))
else {
return Err(Error::UnexpectedError {
message: format!(
"Timestamp offset lookup did not return an offset for {bucket:?}."
),
source: None,
});
};
ranges.push(BoundedLogReadRange {
bucket: bucket.clone(),
starting_offset,
stopping_offset,
});
}
Self::new_from_ranges(scanner, ranges).await
}
pub fn schema(&self) -> SchemaRef {
self.schema.clone()
}
pub async fn collect_all_batches_with_timeout(
&mut self,
timeout: Duration,
) -> Result<BoundedCollectOutcome> {
let start = Instant::now();
let mut batches = Vec::new();
loop {
let remaining = timeout.saturating_sub(start.elapsed());
match self.next_batch_with_timeout(remaining).await? {
RecordBatchReadOutcome::Batch(batch) => batches.push(batch),
RecordBatchReadOutcome::TimedOut => {
return Ok(BoundedCollectOutcome {
batches,
complete: false,
});
}
RecordBatchReadOutcome::Finished => {
return Ok(BoundedCollectOutcome {
batches,
complete: true,
});
}
}
}
}
pub async fn collect_all_batches(&mut self) -> Result<Vec<ScanBatch>> {
let mut out = Vec::new();
while let Some(b) = self.next_batch().await? {
out.push(b);
}
Ok(out)
}
pub async fn next_batch(&mut self) -> Result<Option<ScanBatch>> {
loop {
match self.next_batch_with_timeout(DEFAULT_POLL_TIMEOUT).await? {
RecordBatchReadOutcome::Batch(batch) => return Ok(Some(batch)),
RecordBatchReadOutcome::TimedOut => continue,
RecordBatchReadOutcome::Finished => return Ok(None),
}
}
}
pub async fn next_batch_with_timeout(
&mut self,
timeout: Duration,
) -> Result<RecordBatchReadOutcome> {
let start = Instant::now();
loop {
if let Some(batch) = self.buffer.pop_front() {
return Ok(RecordBatchReadOutcome::Batch(batch));
}
if self.stopping_offsets.is_empty() {
return Ok(RecordBatchReadOutcome::Finished);
}
let elapsed = start.elapsed();
if elapsed >= timeout {
return Ok(RecordBatchReadOutcome::TimedOut);
}
let scan_batches = self.scanner.poll(timeout - elapsed).await?;
if scan_batches.is_empty() {
return Ok(RecordBatchReadOutcome::TimedOut);
}
let completed =
filter_batches(scan_batches, &mut self.stopping_offsets, &mut self.buffer);
for tb in completed {
if let Some(partition_id) = tb.partition_id() {
self.scanner
.unsubscribe_partition_sync(partition_id, tb.bucket_id());
} else {
self.scanner.unsubscribe_sync(tb.bucket_id());
}
}
}
}
pub fn into_stream(self) -> impl Stream<Item = Result<ScanBatch>> + Send {
futures::stream::try_unfold(self, |mut reader| async move {
Ok(reader.next_batch().await?.map(|batch| (batch, reader)))
})
}
pub fn to_record_batch_reader(
self,
handle: tokio::runtime::Handle,
) -> SyncRecordBatchLogReader {
SyncRecordBatchLogReader {
reader: self,
handle,
}
}
}
impl Drop for RecordBatchLogReader {
fn drop(&mut self) {
for (tb, _) in self.stopping_offsets.drain() {
if let Some(partition_id) = tb.partition_id() {
self.scanner
.unsubscribe_partition_sync(partition_id, tb.bucket_id());
} else {
self.scanner.unsubscribe_sync(tb.bucket_id());
}
}
self.scanner.clear_reader_active();
}
}
pub struct SyncRecordBatchLogReader {
reader: RecordBatchLogReader,
handle: tokio::runtime::Handle,
}
impl Iterator for SyncRecordBatchLogReader {
type Item = std::result::Result<RecordBatch, arrow::error::ArrowError>;
fn next(&mut self) -> Option<Self::Item> {
match self.handle.block_on(self.reader.next_batch()) {
Ok(Some(scan_batch)) => Some(Ok(scan_batch.into_batch())),
Ok(None) => None,
Err(e) => Some(Err(arrow::error::ArrowError::ExternalError(Box::new(e)))),
}
}
}
impl arrow::record_batch::RecordBatchReader for SyncRecordBatchLogReader {
fn schema(&self) -> SchemaRef {
self.reader.schema()
}
}
fn validate_read_buckets<'a>(
table_id: TableId,
is_partitioned: bool,
num_buckets: i32,
buckets: impl IntoIterator<Item = &'a TableBucket>,
) -> Result<()> {
let mut seen = HashSet::new();
for bucket in buckets {
if bucket.table_id() != table_id {
return Err(Error::IllegalArgument {
message: format!("Bounded read bucket {bucket:?} is not part of table {table_id}."),
});
}
if bucket.partition_id().is_some() != is_partitioned {
return Err(Error::IllegalArgument {
message: if is_partitioned {
format!("Bounded read bucket {bucket:?} is missing a partition id.")
} else {
format!(
"Bounded read bucket {bucket:?} carries a partition id for a non-partitioned table."
)
},
});
}
if bucket.bucket_id() < 0 || bucket.bucket_id() >= num_buckets {
return Err(Error::IllegalArgument {
message: format!(
"Bounded read bucket id {} is out of range for a table with {num_buckets} buckets.",
bucket.bucket_id()
),
});
}
if !seen.insert(bucket) {
return Err(Error::IllegalArgument {
message: format!("Duplicate bucket {bucket:?} in a bounded read."),
});
}
}
Ok(())
}
fn validate_read_ranges(
table_id: TableId,
is_partitioned: bool,
num_buckets: i32,
ranges: &[BoundedLogReadRange],
) -> Result<()> {
validate_read_buckets(
table_id,
is_partitioned,
num_buckets,
ranges.iter().map(|range| &range.bucket),
)?;
for range in ranges {
if range.starting_offset < 0 && range.starting_offset != crate::client::EARLIEST_OFFSET {
return Err(Error::IllegalArgument {
message: format!(
"Read range for {:?} has unsupported negative starting offset {}.",
range.bucket, range.starting_offset
),
});
}
if range.stopping_offset < 0 {
return Err(Error::IllegalArgument {
message: format!(
"Read range for {:?} has negative stopping offset {}.",
range.bucket, range.stopping_offset
),
});
}
if range.starting_offset > range.stopping_offset {
return Err(Error::IllegalArgument {
message: format!(
"Read range for {:?} has starting offset {} above stopping offset {}.",
range.bucket, range.starting_offset, range.stopping_offset
),
});
}
}
Ok(())
}
fn validate_stopping_offsets(
subscriptions: Vec<(TableBucket, i64)>,
stopping_offsets: &mut HashMap<TableBucket, i64>,
) -> Result<Vec<TableBucket>> {
let subscribed: HashMap<TableBucket, i64> = subscriptions.into_iter().collect();
for (bucket, start) in &subscribed {
if *start < 0 && *start != crate::client::EARLIEST_OFFSET {
return Err(Error::IllegalArgument {
message: format!(
"Scanner subscription for {bucket:?} has unsupported negative starting offset {start}."
),
});
}
}
for bucket in stopping_offsets.keys() {
if !subscribed.contains_key(bucket) {
return Err(Error::IllegalArgument {
message: format!(
"Stopping offset for {bucket:?} has no matching scanner subscription."
),
});
}
}
for bucket in subscribed.keys() {
if !stopping_offsets.contains_key(bucket) {
return Err(Error::IllegalArgument {
message: format!(
"Scanner subscription for {bucket:?} has no matching stopping offset."
),
});
}
}
for (bucket, stop) in stopping_offsets.iter() {
if *stop < 0 {
return Err(Error::IllegalArgument {
message: format!("Stopping offset for {bucket:?} must not be negative."),
});
}
}
let mut completed = Vec::new();
stopping_offsets.retain(|bucket, stop| {
let should_read = *stop > 0
&& subscribed
.get(bucket)
.is_none_or(|start| *start < 0 || start < stop);
if !should_read {
completed.push(bucket.clone());
}
should_read
});
Ok(completed)
}
fn unsubscribe_completed_buckets<'a>(
scanner: &RecordBatchLogScanner,
buckets: impl IntoIterator<Item = &'a TableBucket>,
) {
for bucket in buckets {
if let Some(partition_id) = bucket.partition_id() {
scanner.unsubscribe_partition_sync(partition_id, bucket.bucket_id());
} else {
scanner.unsubscribe_sync(bucket.bucket_id());
}
}
}
struct OffsetResolver<'a> {
admin: &'a FlussAdmin,
table_path: &'a TablePath,
table_id: TableId,
partition_names: Option<HashMap<PartitionId, String>>,
}
impl<'a> OffsetResolver<'a> {
async fn new(admin: &'a FlussAdmin, scanner: &'a RecordBatchLogScanner) -> Result<Self> {
let partition_names = if scanner.is_partitioned() {
let partition_infos = admin.list_partition_infos(scanner.table_path()).await?;
Some(
partition_infos
.into_iter()
.map(|info| (info.get_partition_id(), info.get_partition_name()))
.collect(),
)
} else {
None
};
Ok(Self {
admin,
table_path: scanner.table_path(),
table_id: scanner.table_id(),
partition_names,
})
}
async fn resolve(
&self,
buckets: &[TableBucket],
spec: OffsetSpec,
) -> Result<HashMap<TableBucket, i64>> {
let Some(partition_names) = self.partition_names.as_ref() else {
let bucket_ids: Vec<i32> = buckets.iter().map(|tb| tb.bucket_id()).collect();
let offsets = self
.admin
.list_offsets(self.table_path, &bucket_ids, spec)
.await?;
return Ok(offsets
.into_iter()
.map(|(bucket_id, offset)| (TableBucket::new(self.table_id, bucket_id), offset))
.collect());
};
let mut bucket_ids_by_partition: HashMap<PartitionId, Vec<i32>> = HashMap::new();
for bucket in buckets {
if let Some(partition_id) = bucket.partition_id() {
bucket_ids_by_partition
.entry(partition_id)
.or_default()
.push(bucket.bucket_id());
}
}
let fetches = bucket_ids_by_partition
.into_iter()
.map(|(partition_id, bucket_ids)| {
let spec = spec.clone();
async move {
let partition_name = partition_names.get(&partition_id).ok_or_else(|| {
Error::UnexpectedError {
message: format!("Unknown partition_id: {partition_id}"),
source: None,
}
})?;
let offsets = self
.admin
.list_partition_offsets(self.table_path, partition_name, &bucket_ids, spec)
.await?;
Ok::<_, Error>((partition_id, offsets))
}
});
let mut resolved: HashMap<TableBucket, i64> = HashMap::new();
for (partition_id, offsets) in try_join_all(fetches).await? {
for (bucket_id, offset) in offsets {
let bucket =
TableBucket::new_with_partition(self.table_id, Some(partition_id), bucket_id);
resolved.insert(bucket, offset);
}
}
Ok(resolved)
}
}
async fn query_latest_offsets(
admin: &FlussAdmin,
scanner: &RecordBatchLogScanner,
subscribed: &[(TableBucket, i64)],
) -> Result<HashMap<TableBucket, i64>> {
let table_id = scanner.table_id();
let buckets: Vec<TableBucket> = subscribed.iter().map(|(tb, _)| tb.clone()).collect();
let latest_offsets = OffsetResolver::new(admin, scanner)
.await?
.resolve(&buckets, OffsetSpec::Latest)
.await?;
let mut stopping_offsets = HashMap::with_capacity(latest_offsets.len());
for (bucket, subscribed_offset) in subscribed {
let latest_offset =
latest_offsets
.get(bucket)
.copied()
.ok_or_else(|| Error::UnexpectedError {
message: format!(
"Latest offset lookup did not return an offset for {bucket:?}."
),
source: None,
})?;
if latest_offset < 0 {
return Err(Error::UnexpectedError {
message: format!(
"Server returned negative latest offset {latest_offset} for {bucket:?} of table {table_id}."
),
source: None,
});
}
if latest_offset == 0 {
continue;
}
if *subscribed_offset < latest_offset {
stopping_offsets.insert(bucket.clone(), latest_offset);
}
}
Ok(stopping_offsets)
}
fn filter_batches(
scan_batches: Vec<ScanBatch>,
stopping_offsets: &mut HashMap<TableBucket, i64>,
buffer: &mut VecDeque<ScanBatch>,
) -> Vec<TableBucket> {
let mut completed = Vec::new();
for scan_batch in scan_batches {
let bucket = scan_batch.bucket().clone();
let Some(&stop_at) = stopping_offsets.get(&bucket) else {
continue;
};
let base_offset = scan_batch.base_offset();
let last_offset = scan_batch.last_offset();
if base_offset >= stop_at {
stopping_offsets.remove(&bucket);
completed.push(bucket);
continue;
}
let kept_batch = if last_offset >= stop_at {
let num_to_keep = (stop_at - base_offset) as usize;
let b = scan_batch.into_batch();
let limit = num_to_keep.min(b.num_rows());
ScanBatch::new(bucket.clone(), b.slice(0, limit), base_offset)
} else {
scan_batch
};
if kept_batch.batch().num_rows() > 0 {
buffer.push_back(kept_batch);
}
if last_offset >= stop_at - 1 {
stopping_offsets.remove(&bucket);
completed.push(bucket);
}
}
completed
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::Int32Array;
use arrow_schema::{DataType, Field, Schema};
use std::sync::Arc;
fn test_schema() -> SchemaRef {
Arc::new(Schema::new(vec![Field::new("v", DataType::Int32, false)]))
}
fn make_batch(values: &[i32]) -> RecordBatch {
RecordBatch::try_new(
test_schema(),
vec![Arc::new(Int32Array::from(values.to_vec()))],
)
.unwrap()
}
fn make_scan_batch(bucket: TableBucket, base_offset: i64, values: &[i32]) -> ScanBatch {
ScanBatch::new(bucket, make_batch(values), base_offset)
}
fn bucket(id: i32) -> TableBucket {
TableBucket::new(1, id)
}
#[test]
fn validate_stopping_offsets_rejects_unsubscribed_bucket() {
let mut offsets = HashMap::from([(bucket(1), 10)]);
let result = validate_stopping_offsets(vec![(bucket(0), 0)], &mut offsets);
assert!(matches!(result, Err(Error::IllegalArgument { .. })));
}
fn range(
bucket: TableBucket,
starting_offset: i64,
stopping_offset: i64,
) -> BoundedLogReadRange {
BoundedLogReadRange {
bucket,
starting_offset,
stopping_offset,
}
}
#[test]
fn validate_read_ranges_accepts_empty_and_non_empty_ranges() {
let ranges = vec![range(bucket(0), 5, 5), range(bucket(1), 0, 10)];
validate_read_ranges(1, false, 2, &ranges).unwrap();
}
#[test]
fn validate_read_ranges_rejects_bucket_of_another_table() {
let ranges = vec![range(TableBucket::new(2, 0), 0, 10)];
let result = validate_read_ranges(1, false, 1, &ranges);
assert!(matches!(result, Err(Error::IllegalArgument { .. })));
}
#[test]
fn validate_read_ranges_rejects_partition_mode_mismatch() {
let unpartitioned = vec![range(bucket(0), 0, 10)];
let partitioned = vec![range(TableBucket::new_with_partition(1, Some(7), 0), 0, 10)];
assert!(matches!(
validate_read_ranges(1, true, 1, &unpartitioned),
Err(Error::IllegalArgument { .. })
));
assert!(matches!(
validate_read_ranges(1, false, 1, &partitioned),
Err(Error::IllegalArgument { .. })
));
}
#[test]
fn validate_read_ranges_rejects_out_of_range_bucket() {
let ranges = vec![range(bucket(2), 0, 10)];
let result = validate_read_ranges(1, false, 2, &ranges);
assert!(matches!(result, Err(Error::IllegalArgument { .. })));
}
#[test]
fn validate_read_ranges_rejects_duplicate_bucket() {
let ranges = vec![range(bucket(0), 0, 10), range(bucket(0), 10, 20)];
let result = validate_read_ranges(1, false, 1, &ranges);
assert!(matches!(result, Err(Error::IllegalArgument { .. })));
}
#[test]
fn validate_read_ranges_rejects_inverted_range() {
let ranges = vec![range(bucket(0), 20, 10)];
let result = validate_read_ranges(1, false, 1, &ranges);
assert!(matches!(result, Err(Error::IllegalArgument { .. })));
}
#[test]
fn validate_read_ranges_rejects_negative_stopping_offset() {
let ranges = vec![range(bucket(0), crate::client::EARLIEST_OFFSET, -1)];
let result = validate_read_ranges(1, false, 1, &ranges);
assert!(matches!(result, Err(Error::IllegalArgument { .. })));
}
#[test]
fn validate_read_ranges_rejects_unknown_negative_starting_offset() {
let ranges = vec![range(bucket(0), -1, 10)];
let result = validate_read_ranges(1, false, 1, &ranges);
assert!(matches!(result, Err(Error::IllegalArgument { .. })));
}
#[test]
fn validate_stopping_offsets_prunes_completed_range() {
let mut offsets = HashMap::from([(bucket(0), 10), (bucket(1), 20)]);
let completed =
validate_stopping_offsets(vec![(bucket(0), 10), (bucket(1), 15)], &mut offsets)
.unwrap();
assert!(!offsets.contains_key(&bucket(0)));
assert_eq!(offsets.get(&bucket(1)), Some(&20));
assert_eq!(completed, vec![bucket(0)]);
}
#[test]
fn validate_stopping_offsets_prunes_zero_stop_with_symbolic_start() {
let mut offsets = HashMap::from([(bucket(0), 0)]);
let completed = validate_stopping_offsets(
vec![(bucket(0), crate::client::EARLIEST_OFFSET)],
&mut offsets,
)
.unwrap();
assert!(!offsets.contains_key(&bucket(0)));
assert_eq!(completed, vec![bucket(0)]);
}
#[test]
fn validate_stopping_offsets_rejects_subscription_without_stop() {
let mut offsets = HashMap::from([(bucket(0), 10)]);
let result = validate_stopping_offsets(vec![(bucket(0), 0), (bucket(1), 0)], &mut offsets);
assert!(matches!(result, Err(Error::IllegalArgument { .. })));
}
#[test]
fn filter_batch_entirely_before_stop() {
let mut offsets = HashMap::from([(bucket(0), 100)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert_eq!(buffer.len(), 1);
assert_eq!(buffer[0].batch().num_rows(), 3);
assert!(offsets.contains_key(&bucket(0)));
assert!(completed.is_empty());
}
#[test]
fn filter_batch_crossing_stop_offset_is_sliced() {
let mut offsets = HashMap::from([(bucket(0), 12)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3, 4, 5])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert_eq!(buffer.len(), 1);
assert_eq!(buffer[0].batch().num_rows(), 2);
assert!(!offsets.contains_key(&bucket(0)));
assert_eq!(completed, vec![bucket(0)]);
}
#[test]
fn filter_batch_at_or_after_stop_offset_is_skipped() {
let mut offsets = HashMap::from([(bucket(0), 10)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert!(buffer.is_empty());
assert!(!offsets.contains_key(&bucket(0)));
assert_eq!(completed, vec![bucket(0)]);
}
#[test]
fn filter_batch_ending_exactly_at_stop_minus_one() {
let mut offsets = HashMap::from([(bucket(0), 13)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert_eq!(buffer.len(), 1);
assert_eq!(buffer[0].batch().num_rows(), 3);
assert!(!offsets.contains_key(&bucket(0)));
assert_eq!(completed, vec![bucket(0)]);
}
#[test]
fn filter_unknown_bucket_is_ignored() {
let mut offsets = HashMap::from([(bucket(0), 100)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(99), 0, &[1, 2])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert!(buffer.is_empty());
assert!(offsets.contains_key(&bucket(0)));
assert!(completed.is_empty());
}
#[test]
fn filter_multiple_buckets_independent_tracking() {
let mut offsets = HashMap::from([(bucket(0), 12), (bucket(1), 5)]);
let mut buffer = VecDeque::new();
let batches = vec![
make_scan_batch(bucket(0), 10, &[1, 2, 3]), make_scan_batch(bucket(1), 0, &[10, 20, 30]), ];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert_eq!(buffer.len(), 2);
assert_eq!(buffer[0].batch().num_rows(), 2); assert_eq!(buffer[1].batch().num_rows(), 3); assert!(!offsets.contains_key(&bucket(0))); assert!(offsets.contains_key(&bucket(1))); assert_eq!(completed, vec![bucket(0)]);
}
#[test]
fn filter_empty_batch_at_stop() {
let mut offsets = HashMap::from([(bucket(0), 5)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 5, &[])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert!(buffer.is_empty());
assert!(!offsets.contains_key(&bucket(0)));
assert_eq!(completed, vec![bucket(0)]);
}
#[test]
fn filter_drops_empty_batch_before_stop() {
let mut offsets = HashMap::from([(bucket(0), 100)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 5, &[])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert!(buffer.is_empty());
assert!(offsets.contains_key(&bucket(0)));
assert!(completed.is_empty());
}
#[test]
fn filter_single_row_batch_before_stop() {
let mut offsets = HashMap::from([(bucket(0), 10)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 5, &[42])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert_eq!(buffer.len(), 1);
assert_eq!(buffer[0].batch().num_rows(), 1);
assert!(offsets.contains_key(&bucket(0)));
assert!(completed.is_empty());
}
#[test]
fn filter_single_row_batch_at_stop_boundary() {
let mut offsets = HashMap::from([(bucket(0), 5)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 4, &[42])];
let completed = filter_batches(batches, &mut offsets, &mut buffer);
assert_eq!(buffer.len(), 1);
assert_eq!(buffer[0].batch().num_rows(), 1);
assert!(!offsets.contains_key(&bucket(0)));
assert_eq!(completed, vec![bucket(0)]);
}
#[test]
fn filter_preserves_scan_batch_metadata() {
let mut offsets = HashMap::from([(bucket(3), 100)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(3), 42, &[1, 2])];
filter_batches(batches, &mut offsets, &mut buffer);
let sb = &buffer[0];
assert_eq!(*sb.bucket(), bucket(3));
assert_eq!(sb.base_offset(), 42);
}
#[test]
fn filter_sliced_batch_preserves_base_offset() {
let mut offsets = HashMap::from([(bucket(0), 12)]);
let mut buffer = VecDeque::new();
let batches = vec![make_scan_batch(bucket(0), 10, &[1, 2, 3, 4, 5])];
filter_batches(batches, &mut offsets, &mut buffer);
let sb = &buffer[0];
assert_eq!(sb.base_offset(), 10);
assert_eq!(*sb.bucket(), bucket(0));
}
}