use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use arrow::array::{ArrayRef, RecordBatch, RecordBatchOptions, StringArray};
use arrow::datatypes::Schema;
use re_dataframe::external::re_chunk::{Chunk, ChunkId, LatestAtQuery};
use re_dataframe::external::re_chunk_store::{
ChunkStore, ChunkTrackingMode, GarbageCollectionOptions,
};
use re_dataframe::utils::align_record_batch_to_schema;
use re_dataframe::{
ChunkStoreConfig, ChunkStoreHandle, QueryCache, QueryEngine, QueryExpression, QueryHandle,
StorageEngine, TimelineName,
};
use re_log_types::{AbsoluteTimeRange, ApplicationId, StoreId, StoreKind, TimeInt};
use re_protos::cloud::v1alpha1::ext::ScanSegmentTableDataframe;
use re_protos::common::v1alpha1::ext::SegmentId;
use re_redap_client::{ApiError, ApiResult};
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::{Instrument as _, instrument};
use crate::chunk_fetcher::SortedChunksWithSegment;
use crate::dataframe_query_common::{
DEFAULT_BATCH_BYTES, DEFAULT_BATCH_ROWS, IndexValuesMap, prepend_string_column_schema,
};
use crate::pipeline_budget::PipelineBudget;
use crate::segment_chunk_manifest::SegmentChunkManifest;
const FLUSH_BATCH_ROWS: usize = DEFAULT_BATCH_ROWS;
const FLUSH_BATCH_BYTES: usize = DEFAULT_BATCH_BYTES as usize;
#[tracing::instrument(level = "trace", skip_all, fields(segment_id = %segment_id))]
async fn send_next_row_batch(
query_handle: &mut QueryHandle<StorageEngine>,
segment_id: &SegmentId,
target_schema: &Arc<Schema>,
output_channel: &Sender<RecordBatch>,
rows_sent: &mut usize,
limit_rows: Option<usize>,
) -> ApiResult<Option<()>> {
if limit_rows.is_some_and(|l| *rows_sent >= l) {
return Ok(None);
}
let max_rows_this_batch = limit_rows
.map(|l| l.saturating_sub(*rows_sent).min(FLUSH_BATCH_ROWS))
.unwrap_or(FLUSH_BATCH_ROWS);
if max_rows_this_batch == 0 {
return Ok(None);
}
let query_schema = Arc::clone(query_handle.schema());
let num_fields = query_schema.fields.len();
let next = query_handle
.next_n_rows_async(max_rows_this_batch, FLUSH_BATCH_BYTES)
.await;
if next.num_rows == 0 {
return Ok(None);
}
if num_fields != next.columns.len() {
return Err(ApiError::internal(
"Unexpected number of columns returned from query",
));
}
let total_rows = next.num_rows;
let mut columns: Vec<ArrayRef> = Vec::with_capacity(num_fields + 1);
let sid_array =
Arc::new(StringArray::from(vec![segment_id.to_string(); total_rows])) as ArrayRef;
columns.push(sid_array);
columns.extend(next.columns);
let output_batch = {
re_tracing::profile_scope!("build_and_align_batch");
let batch_schema = Arc::new(prepend_string_column_schema(
&query_schema,
ScanSegmentTableDataframe::COLUMN_RERUN_SEGMENT_ID_NAME,
));
let batch = RecordBatch::try_new_with_options(
batch_schema,
columns,
&RecordBatchOptions::default().with_row_count(Some(total_rows)),
)
.map_err(|err| {
ApiError::deserialization_with_source(
None,
err,
"building output record batch from chunk-store rows",
)
})?;
align_record_batch_to_schema(&batch, target_schema).map_err(|err| {
ApiError::internal_with_source(None, err, "DataFusion schema mismatch error")
})?
};
let output_batch = if let Some(limit_rows) = limit_rows {
let remaining = limit_rows.saturating_sub(*rows_sent);
if remaining == 0 {
return Ok(None);
}
if output_batch.num_rows() > remaining {
output_batch.slice(0, remaining)
} else {
output_batch
}
} else {
output_batch
};
*rows_sent += output_batch.num_rows();
output_channel
.send(output_batch)
.await
.map_err(|err| ApiError::internal_with_source(None, err, "output channel closed"))?;
Ok(Some(()))
}
pub(super) enum CpuWorkerMsg {
SegmentChunkCount { segment_id: SegmentId, count: usize },
SegmentManifest {
segment_id: SegmentId,
manifest: Box<SegmentChunkManifest>,
},
Chunks(SortedChunksWithSegment),
}
struct CurrentStores {
segment_id: SegmentId,
store: ChunkStoreHandle,
engine: QueryEngine<StorageEngine>,
query_expression: QueryExpression,
filtered_index_timeline: Option<TimelineName>,
pipeline_budget: Arc<PipelineBudget>,
expected_chunks: Option<usize>,
received_chunks: usize,
manifest: Option<SegmentChunkManifest>,
processed_through_time: Option<TimeInt>,
last_horizon: Option<TimeInt>,
max_arrived_time_max: Option<TimeInt>,
protected_chunks_scratch: ahash::HashSet<ChunkId>,
}
impl CurrentStores {
#[tracing::instrument(level = "debug", skip_all, fields(segment_id = %segment_id))]
fn new(
segment_id: SegmentId,
query_expression: &QueryExpression,
index_values: &IndexValuesMap,
pipeline_budget: Arc<PipelineBudget>,
) -> Self {
let store_id = StoreId::random(
StoreKind::Recording,
ApplicationId::from(segment_id.as_ref()),
);
let config = ChunkStoreConfig::ALL_DISABLED; let store = ChunkStore::new_handle(store_id.clone(), config);
let query_cache = QueryCache::new_handle(store.clone());
let engine = QueryEngine::new(store.clone(), query_cache);
let mut individual_query = query_expression.clone();
let values = index_values
.as_ref()
.and_then(|index_values| index_values.get(&segment_id));
if let Some(values) = values {
individual_query.using_index_values = Some(values.clone());
}
let filtered_index_timeline = individual_query.filtered_index;
Self {
segment_id,
store,
engine,
query_expression: individual_query,
filtered_index_timeline,
pipeline_budget,
expected_chunks: None,
received_chunks: 0,
manifest: None,
processed_through_time: None,
last_horizon: None,
max_arrived_time_max: None,
protected_chunks_scratch: ahash::HashSet::default(),
}
}
fn store_bytes(&self) -> u64 {
self.store.read().stats().total().total_size_bytes
}
fn is_complete(&self) -> bool {
self.expected_chunks
.is_some_and(|expected| self.received_chunks >= expected)
}
fn record_arrival(&mut self, chunk: &Chunk) {
let Some(timeline) = self.filtered_index_timeline.as_ref() else {
return;
};
let Some(time_col) = chunk.timelines().get(timeline) else {
return; };
let entity_path = chunk.entity_path();
let time_range = time_col.time_range();
let time_min = time_range.min();
let time_max = time_range.max();
self.max_arrived_time_max = Some(
self.max_arrived_time_max
.map_or(time_max, |prev| prev.max(time_max)),
);
let Some(manifest) = self.manifest.as_mut() else {
return;
};
if !manifest.record_arrival(entity_path, time_min) {
re_log::debug_panic!(
"manifest/chunk divergence: chunk for entity {entity_path} at time_min={} on \
timeline {timeline} was not announced in chunk_info; safe_horizon may have \
advanced past it, in which case its rows will be excluded by the row range \
filter and never emit",
time_min.as_i64(),
);
re_log::error_once!(
"manifest/chunk divergence: chunk for entity {entity_path} at time_min={} on \
timeline {timeline} not found in manifest; safe horizon may be inaccurate",
time_min.as_i64(),
);
}
}
async fn flush_incremental(
&mut self,
projected_schema: &Arc<Schema>,
output_channel: &Sender<RecordBatch>,
rows_sent: &mut usize,
limit_rows: Option<usize>,
) -> ApiResult<()> {
if self.query_expression.using_index_values.is_some() {
return Ok(());
}
let Some(horizon) = self.manifest.as_ref().and_then(|m| m.safe_horizon()) else {
return Ok(()); };
re_log::debug_assert!(
self.last_horizon.is_none_or(|prev| horizon >= prev),
"safe_horizon regressed: prev={:?}, new={}",
self.last_horizon.map(|h| h.as_i64()),
horizon.as_i64(),
);
self.last_horizon = Some(horizon);
if let Some(last) = self.processed_through_time
&& horizon <= last
{
return Ok(());
}
if self
.max_arrived_time_max
.is_none_or(|tmax| self.processed_through_time.is_some_and(|p| tmax <= p))
{
self.gc_up_to_horizon(horizon);
self.processed_through_time = Some(horizon);
return Ok(());
}
self.emit_up_to(
Some(horizon),
projected_schema,
output_channel,
rows_sent,
limit_rows,
)
.await?;
self.gc_up_to_horizon(horizon);
Ok(())
}
async fn emit_up_to(
&mut self,
horizon: Option<TimeInt>,
projected_schema: &Arc<Schema>,
output_channel: &Sender<RecordBatch>,
rows_sent: &mut usize,
limit_rows: Option<usize>,
) -> ApiResult<()> {
let range_min = match self.processed_through_time {
Some(t) => {
if t == TimeInt::MAX {
return Ok(());
}
t.inc()
}
None => TimeInt::MIN,
};
let range_max = horizon.unwrap_or(TimeInt::MAX);
if range_min > range_max {
return Ok(());
}
let mut q = self.query_expression.clone();
q.filtered_index_range = Some(AbsoluteTimeRange::new(range_min, range_max));
let mut handle: QueryHandle<StorageEngine> = self.engine.query(q);
while send_next_row_batch(
&mut handle,
&self.segment_id,
projected_schema,
output_channel,
rows_sent,
limit_rows,
)
.await?
.is_some()
{}
self.processed_through_time = Some(range_max);
Ok(())
}
fn gc_up_to_horizon(&mut self, horizon: TimeInt) {
let Some(timeline_name) = self.filtered_index_timeline else {
return;
};
let bytes_before = self.store_bytes();
self.protected_chunks_scratch.clear();
{
let store = self.store.read();
let query = LatestAtQuery::new(timeline_name, horizon);
for entity_path in store.all_entities() {
let results = store.latest_at_relevant_chunks_for_all_components(
ChunkTrackingMode::Ignore,
&query,
&entity_path,
true, );
for chunk in &results.chunks {
self.protected_chunks_scratch.insert(chunk.id());
}
}
}
let mut options = GarbageCollectionOptions::gc_everything();
options.protected_chunks = std::mem::take(&mut self.protected_chunks_scratch);
options.protected_time_ranges.insert(
timeline_name,
AbsoluteTimeRange::new(horizon.inc(), TimeInt::MAX),
);
options.perform_deep_deletions = true;
let _ = self.store.write().gc(&options);
std::mem::swap(
&mut self.protected_chunks_scratch,
&mut options.protected_chunks,
);
let bytes_after = self.store_bytes();
let freed = bytes_before.saturating_sub(bytes_after);
if freed > 0 {
self.pipeline_budget.release(freed as usize);
}
}
#[instrument(level = "debug", skip_all)]
async fn flush(
mut self,
projected_schema: &Arc<Schema>,
output_channel: &Sender<RecordBatch>,
rows_sent: &mut usize,
limit_rows: Option<usize>,
) -> ApiResult<()> {
self.emit_up_to(
None,
projected_schema,
output_channel,
rows_sent,
limit_rows,
)
.await?;
Ok(())
}
}
impl Drop for CurrentStores {
fn drop(&mut self) {
self.pipeline_budget.release(self.store_bytes() as usize);
}
}
#[instrument(level = "info", skip_all)]
pub(super) async fn chunk_store_cpu_worker_thread(
mut input_channel: Receiver<ApiResult<CpuWorkerMsg>>,
output_channel: Sender<RecordBatch>,
query_expression: QueryExpression,
projected_schema: Arc<Schema>,
index_values: IndexValuesMap,
limit_rows: Option<usize>,
pipeline_budget: Arc<PipelineBudget>,
) -> ApiResult<()> {
let mut current_stores: HashMap<SegmentId, CurrentStores> = HashMap::new();
let mut ready_pending: HashMap<SegmentId, CurrentStores> = HashMap::new();
let mut completed_segments: HashSet<SegmentId> = HashSet::new();
let mut emit_order: VecDeque<SegmentId> = VecDeque::new();
let mut rows_sent: usize = 0;
loop {
let recv_span = tracing::trace_span!("waiting_for_chunks");
let Some(msg) = input_channel.recv().instrument(recv_span).await else {
break;
};
match msg? {
CpuWorkerMsg::SegmentChunkCount { segment_id, count } => {
if completed_segments.contains(&segment_id)
|| ready_pending.contains_key(&segment_id)
{
tracing::warn!(
%segment_id,
count,
"duplicate SegmentChunkCount for already-completed segment; dropping. \
Server is expected to be authoritative; indicates protocol mismatch or retry."
);
continue;
}
if index_values
.as_ref()
.is_some_and(|iv| !iv.contains_key(&segment_id))
{
continue;
}
let stores = match current_stores.entry(segment_id.clone()) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
emit_order.push_back(segment_id.clone());
e.insert(CurrentStores::new(
segment_id.clone(),
&query_expression,
&index_values,
pipeline_budget.clone(),
))
}
};
if let Some(prev) = stores.expected_chunks {
if prev != count {
tracing::warn!(
%segment_id, prev, count,
"conflicting SegmentChunkCount for in-flight segment; keeping first. \
Indicates IO-side duplicate announce or server protocol mismatch."
);
}
} else {
stores.expected_chunks = Some(count);
}
if stores.is_complete() {
let taken = current_stores
.remove(&segment_id)
.expect("just inserted via entry()");
ready_pending.insert(segment_id, taken);
if maybe_emit_head(
&mut emit_order,
&mut current_stores,
&mut ready_pending,
&mut completed_segments,
&projected_schema,
&output_channel,
&mut rows_sent,
limit_rows,
)
.await?
{
return Ok(());
}
}
}
CpuWorkerMsg::SegmentManifest {
segment_id,
manifest,
} => {
if completed_segments.contains(&segment_id)
|| ready_pending.contains_key(&segment_id)
{
tracing::warn!(
%segment_id,
"SegmentManifest for already-completed segment; dropping. \
Indicates a protocol mismatch or retry."
);
continue;
}
if index_values
.as_ref()
.is_some_and(|iv| !iv.contains_key(&segment_id))
{
continue;
}
let stores = match current_stores.entry(segment_id.clone()) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
emit_order.push_back(segment_id.clone());
e.insert(CurrentStores::new(
segment_id.clone(),
&query_expression,
&index_values,
pipeline_budget.clone(),
))
}
};
if stores.manifest.is_some() {
re_log::debug_panic!(
"duplicate SegmentManifest for segment {segment_id}; ignoring \
second manifest. Indicates a protocol mismatch in the IO loop."
);
re_log::error_once!(
"duplicate SegmentManifest for segment {segment_id}; ignoring \
second manifest. Indicates a protocol mismatch in the IO loop."
);
} else {
stores.manifest = Some(*manifest);
}
if maybe_emit_head(
&mut emit_order,
&mut current_stores,
&mut ready_pending,
&mut completed_segments,
&projected_schema,
&output_channel,
&mut rows_sent,
limit_rows,
)
.await?
{
return Ok(());
}
}
CpuWorkerMsg::Chunks((segment_id, chunks)) => {
if chunks.is_empty() {
continue;
}
if completed_segments.contains(&segment_id)
|| ready_pending.contains_key(&segment_id)
{
tracing::warn!(
%segment_id,
n = chunks.len(),
"received Chunks for already-completed segment; dropping. \
Server over-reported chunks past the announced count."
);
continue;
}
if index_values
.as_ref()
.is_some_and(|iv| !iv.contains_key(&segment_id))
{
continue;
}
let n_chunks = chunks.len();
let stores = match current_stores.entry(segment_id.clone()) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
emit_order.push_back(segment_id.clone());
e.insert(CurrentStores::new(
segment_id.clone(),
&query_expression,
&index_values,
pipeline_budget.clone(),
))
}
};
{
let _insert_span = tracing::debug_span!(
"insert_chunks",
segment_id = %stores.segment_id,
n = n_chunks,
)
.entered();
re_tracing::profile_scope!("insert_chunks");
for chunk in chunks {
let chunk = Arc::new(chunk);
stores.store.write().insert_chunk(&chunk).map_err(|err| {
ApiError::internal_with_source(
None,
err,
"inserting chunk into in-memory store",
)
})?;
stores.record_arrival(&chunk);
}
stores.received_chunks += n_chunks;
}
let complete = stores.is_complete();
if complete {
let taken = current_stores
.remove(&segment_id)
.expect("just inserted via entry()");
ready_pending.insert(segment_id, taken);
}
if maybe_emit_head(
&mut emit_order,
&mut current_stores,
&mut ready_pending,
&mut completed_segments,
&projected_schema,
&output_channel,
&mut rows_sent,
limit_rows,
)
.await?
{
return Ok(());
}
}
}
}
let consumer_cancelled = output_channel.is_closed();
while let Some(seg) = emit_order.pop_front() {
if let Some(stores) = ready_pending.remove(&seg) {
stores
.flush(
&projected_schema,
&output_channel,
&mut rows_sent,
limit_rows,
)
.await?;
if limit_rows.is_some_and(|l| rows_sent >= l) {
return Ok(());
}
} else if let Some(stores) = current_stores.remove(&seg) {
let received = stores.received_chunks;
let expected = stores
.expected_chunks
.map_or_else(|| "?".to_owned(), |n| n.to_string());
if consumer_cancelled {
re_log::debug!(
"abandoning incomplete segment {seg} after consumer cancellation: \
received {received}/{expected} announced chunks",
);
} else {
re_log::warn!(
"abandoning incomplete segment {seg} at end-of-stream: \
received {received}/{expected} announced chunks; partial rows \
would risk emitting carry-forward values whose source chunks \
never arrived",
);
}
drop(stores);
}
}
Ok(())
}
async fn maybe_emit_head(
emit_order: &mut VecDeque<SegmentId>,
current_stores: &mut HashMap<SegmentId, CurrentStores>,
ready_pending: &mut HashMap<SegmentId, CurrentStores>,
completed_segments: &mut HashSet<SegmentId>,
projected_schema: &Arc<Schema>,
output_channel: &Sender<RecordBatch>,
rows_sent: &mut usize,
limit_rows: Option<usize>,
) -> ApiResult<bool> {
loop {
let Some(head) = emit_order.front().cloned() else {
return Ok(false);
};
if let Some(stores) = ready_pending.remove(&head) {
emit_order.pop_front().expect("front existed");
completed_segments.insert(head);
stores
.flush(projected_schema, output_channel, rows_sent, limit_rows)
.await?;
if limit_rows.is_some_and(|l| *rows_sent >= l) {
return Ok(true);
}
continue;
}
if let Some(stores) = current_stores.get_mut(&head) {
stores
.flush_incremental(projected_schema, output_channel, rows_sent, limit_rows)
.await?;
if limit_rows.is_some_and(|l| *rows_sent >= l) {
return Ok(true);
}
}
return Ok(false);
}
}
#[cfg(test)]
mod tests {
use re_dataframe::external::re_chunk::Chunk;
use super::*;
#[tokio::test]
async fn test_current_stores_drop_refunds_budget() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let releases_before = budget.total_releases();
{
let _stores = CurrentStores::new(
SegmentId::from("drop-refund-test"),
&QueryExpression::default(),
&None,
budget.clone(),
);
}
assert_eq!(
budget.total_releases(),
releases_before + 1,
"Drop must call release exactly once",
);
}
#[tokio::test]
async fn test_current_stores_is_complete_gates_on_expected_chunks() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = CurrentStores::new(
SegmentId::from("complete-gate-test"),
&QueryExpression::default(),
&None,
budget.clone(),
);
assert!(!stores.is_complete());
stores.received_chunks = 5;
assert!(!stores.is_complete());
stores.expected_chunks = Some(5);
assert!(stores.is_complete());
let mut other = CurrentStores::new(
SegmentId::from("complete-gate-other"),
&QueryExpression::default(),
&None,
budget.clone(),
);
other.expected_chunks = Some(10);
other.received_chunks = 3;
assert!(!other.is_complete());
}
fn empty_chunk() -> Chunk {
use re_dataframe::external::re_chunk::Chunk as ChunkBuilder;
use re_log_types::EntityPath;
ChunkBuilder::builder(EntityPath::root()).build().unwrap()
}
async fn drive_worker(
input_rx: Receiver<ApiResult<CpuWorkerMsg>>,
output_tx: Sender<RecordBatch>,
mut output_rx: Receiver<RecordBatch>,
) -> (ApiResult<()>, usize) {
let budget = Arc::new(PipelineBudget::new(1 << 30, 4));
let schema = Arc::new(Schema::empty());
let worker = chunk_store_cpu_worker_thread(
input_rx,
output_tx,
QueryExpression::default(),
schema,
None,
None,
budget,
);
let drainer = async {
let mut n = 0;
while output_rx.recv().await.is_some() {
n += 1;
}
n
};
tokio::join!(worker, drainer)
}
#[tokio::test]
async fn test_cpu_worker_handles_interleaved_segments() {
let (input_tx, input_rx) = tokio::sync::mpsc::channel::<ApiResult<CpuWorkerMsg>>(16);
let (output_tx, output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(16);
input_tx
.send(Ok(CpuWorkerMsg::SegmentChunkCount {
segment_id: SegmentId::from("A"),
count: 2,
}))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::SegmentChunkCount {
segment_id: SegmentId::from("B"),
count: 2,
}))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("A"),
vec![empty_chunk()],
))))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("B"),
vec![empty_chunk()],
))))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("A"),
vec![empty_chunk()],
))))
.await
.unwrap(); input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("B"),
vec![empty_chunk()],
))))
.await
.unwrap(); drop(input_tx);
let (result, _n_batches) = drive_worker(input_rx, output_tx, output_rx).await;
assert!(result.is_ok(), "worker must complete cleanly: {result:?}");
}
#[tokio::test]
async fn test_cpu_worker_drops_chunks_for_already_completed_segment() {
let (input_tx, input_rx) = tokio::sync::mpsc::channel::<ApiResult<CpuWorkerMsg>>(8);
let (output_tx, output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(8);
input_tx
.send(Ok(CpuWorkerMsg::SegmentChunkCount {
segment_id: SegmentId::from("A"),
count: 1,
}))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("A"),
vec![empty_chunk()],
))))
.await
.unwrap(); input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("A"),
vec![empty_chunk()],
))))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::SegmentChunkCount {
segment_id: SegmentId::from("A"),
count: 99,
}))
.await
.unwrap();
drop(input_tx);
let (result, _n_batches) = drive_worker(input_rx, output_tx, output_rx).await;
assert!(
result.is_ok(),
"worker must drop over-count chunks and a duplicate SegmentChunkCount without erroring: {result:?}"
);
}
#[tokio::test]
async fn test_maybe_emit_head_preserves_announcement_order() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 4));
let schema = Arc::new(Schema::empty());
let (output_tx, _output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(8);
let mut emit_order: VecDeque<SegmentId> = VecDeque::new();
emit_order.push_back(SegmentId::from("A"));
emit_order.push_back(SegmentId::from("B"));
emit_order.push_back(SegmentId::from("C"));
let mut current_stores: HashMap<SegmentId, CurrentStores> = HashMap::new();
let mut ready_pending: HashMap<SegmentId, CurrentStores> = HashMap::new();
let mut completed_segments: HashSet<SegmentId> = HashSet::new();
let mut rows_sent: usize = 0;
ready_pending.insert(
SegmentId::from("B"),
CurrentStores::new(
SegmentId::from("B"),
&QueryExpression::default(),
&None,
budget.clone(),
),
);
ready_pending.insert(
SegmentId::from("C"),
CurrentStores::new(
SegmentId::from("C"),
&QueryExpression::default(),
&None,
budget.clone(),
),
);
let hit_limit = maybe_emit_head(
&mut emit_order,
&mut current_stores,
&mut ready_pending,
&mut completed_segments,
&schema,
&output_tx,
&mut rows_sent,
None,
)
.await
.unwrap();
assert!(!hit_limit);
assert_eq!(
emit_order.len(),
3,
"head A blocks B and C from draining: {emit_order:?}"
);
assert_eq!(ready_pending.len(), 2);
assert!(completed_segments.is_empty());
ready_pending.insert(
SegmentId::from("A"),
CurrentStores::new(
SegmentId::from("A"),
&QueryExpression::default(),
&None,
budget.clone(),
),
);
let hit_limit = maybe_emit_head(
&mut emit_order,
&mut current_stores,
&mut ready_pending,
&mut completed_segments,
&schema,
&output_tx,
&mut rows_sent,
None,
)
.await
.unwrap();
assert!(!hit_limit);
assert!(emit_order.is_empty(), "all three drained: {emit_order:?}");
assert!(ready_pending.is_empty());
assert_eq!(completed_segments.len(), 3);
}
#[tokio::test]
async fn test_cpu_worker_flushes_when_count_arrives_after_chunks() {
let (input_tx, input_rx) = tokio::sync::mpsc::channel::<ApiResult<CpuWorkerMsg>>(8);
let (output_tx, output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(8);
input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("A"),
vec![empty_chunk()],
))))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("A"),
vec![empty_chunk()],
))))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::SegmentChunkCount {
segment_id: SegmentId::from("A"),
count: 2,
}))
.await
.unwrap();
drop(input_tx);
let (result, _n_batches) = drive_worker(input_rx, output_tx, output_rx).await;
assert!(
result.is_ok(),
"worker must tolerate count after chunks: {result:?}"
);
}
fn temporal_chunk(entity: &str, timeline_name: &str, time: i64) -> Chunk {
use re_dataframe::external::re_chunk::RowId;
use re_log_types::Timeline;
use re_log_types::example_components::{MyLabel, MyPoints};
let timepoint = [(Timeline::new_sequence(timeline_name), time)];
let labels = &[MyLabel(format!("{entity}@{time}"))];
Chunk::builder(entity)
.with_component_batches(
RowId::new(),
timepoint,
[(MyPoints::descriptor_labels(), labels as _)],
)
.build()
.unwrap()
}
fn stores_with_timeline(
segment_id: &str,
timeline_name: &str,
budget: Arc<PipelineBudget>,
) -> CurrentStores {
let query_expression = QueryExpression {
filtered_index: Some(TimelineName::new(timeline_name)),
..Default::default()
};
let mut stores = CurrentStores::new(
SegmentId::from(segment_id),
&query_expression,
&None,
budget,
);
let mut manifest = SegmentChunkManifest::new();
manifest.lock();
stores.manifest = Some(manifest);
stores
}
#[test]
fn test_gc_up_to_horizon_preserves_carry_forward() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = stores_with_timeline("seg", "frame", budget.clone());
for chunk in [
temporal_chunk("/a", "frame", 10),
temporal_chunk("/b", "frame", 20),
temporal_chunk("/b", "frame", 100),
] {
stores.store.write().insert_chunk(&Arc::new(chunk)).unwrap();
}
let bytes_before = stores.store_bytes();
let releases_before = budget.total_releases();
let n_chunks_before = stores.store.read().num_physical_chunks();
assert_eq!(n_chunks_before, 3);
stores.gc_up_to_horizon(TimeInt::new_temporal(50));
let bytes_after = stores.store_bytes();
let n_chunks_after = stores.store.read().num_physical_chunks();
assert_eq!(
n_chunks_after, 3,
"carry-forward chunks must survive GC under latest-at semantics",
);
assert_eq!(
bytes_after, bytes_before,
"no bytes freed when every chunk is protected",
);
assert_eq!(
budget.total_releases(),
releases_before,
"release must not fire when freed == 0",
);
}
#[test]
fn test_gc_up_to_horizon_drops_superseded_chunks() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = stores_with_timeline("seg", "frame", budget.clone());
for chunk in [
temporal_chunk("/a", "frame", 10),
temporal_chunk("/a", "frame", 20),
temporal_chunk("/a", "frame", 30),
] {
stores.store.write().insert_chunk(&Arc::new(chunk)).unwrap();
}
let bytes_before = stores.store_bytes();
let releases_before = budget.total_releases();
assert_eq!(stores.store.read().num_physical_chunks(), 3);
stores.gc_up_to_horizon(TimeInt::new_temporal(50));
let bytes_after = stores.store_bytes();
assert_eq!(
stores.store.read().num_physical_chunks(),
1,
"only the latest-at chunk (@30) must remain",
);
assert!(
bytes_after < bytes_before,
"GC must free bytes when chunks are dropped (before={bytes_before}, after={bytes_after})",
);
assert_eq!(
budget.total_releases(),
releases_before + 1,
"release must fire exactly once when freed > 0",
);
}
#[test]
fn test_gc_up_to_horizon_reuses_scratch_capacity() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = stores_with_timeline("seg", "frame", budget);
for i in 0..64 {
stores
.store
.write()
.insert_chunk(&Arc::new(temporal_chunk(&format!("/e{i}"), "frame", 0)))
.unwrap();
}
assert_eq!(
stores.protected_chunks_scratch.capacity(),
0,
"fresh CurrentStores starts with zero scratch capacity",
);
stores.gc_up_to_horizon(TimeInt::new_temporal(50));
let cap_after_first = stores.protected_chunks_scratch.capacity();
assert!(
cap_after_first > 0,
"scratch must retain capacity after gc (got {cap_after_first})",
);
stores.gc_up_to_horizon(TimeInt::new_temporal(60));
assert!(
stores.protected_chunks_scratch.capacity() >= cap_after_first,
"capacity must not shrink across calls (before={cap_after_first}, after={})",
stores.protected_chunks_scratch.capacity(),
);
}
#[test]
fn test_gc_up_to_horizon_noop_without_filtered_index() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = CurrentStores::new(
SegmentId::from("seg"),
&QueryExpression::default(),
&None,
budget.clone(),
);
let mut manifest = SegmentChunkManifest::new();
manifest.lock();
stores.manifest = Some(manifest);
assert!(stores.filtered_index_timeline.is_none());
stores
.store
.write()
.insert_chunk(&Arc::new(temporal_chunk("/a", "frame", 10)))
.unwrap();
let bytes_before = stores.store_bytes();
let releases_before = budget.total_releases();
stores.gc_up_to_horizon(TimeInt::new_temporal(50));
assert_eq!(
stores.store_bytes(),
bytes_before,
"no-op leaves bytes unchanged"
);
assert_eq!(budget.total_releases(), releases_before, "no release call");
}
#[tokio::test]
async fn test_flush_incremental_fast_skip_without_manifest() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = CurrentStores::new(
SegmentId::from("seg"),
&QueryExpression::default(),
&None,
budget,
);
assert!(stores.manifest.is_none());
let schema = Arc::new(Schema::empty());
let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(4);
let mut rows_sent = 0usize;
stores
.flush_incremental(&schema, &output_tx, &mut rows_sent, None)
.await
.unwrap();
assert_eq!(rows_sent, 0);
assert!(output_rx.try_recv().is_err());
}
#[tokio::test]
async fn test_flush_incremental_fast_skip_when_horizon_not_advanced() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = stores_with_timeline("seg", "frame", budget);
stores.processed_through_time = Some(TimeInt::MAX);
let schema = Arc::new(Schema::empty());
let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(4);
let mut rows_sent = 0usize;
stores
.flush_incremental(&schema, &output_tx, &mut rows_sent, None)
.await
.unwrap();
assert_eq!(rows_sent, 0);
assert!(output_rx.try_recv().is_err());
}
#[tokio::test]
async fn test_flush_incremental_fast_skip_when_no_arrivals_in_range() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = stores_with_timeline("seg", "frame", budget);
assert!(stores.max_arrived_time_max.is_none());
assert!(stores.processed_through_time.is_none());
let schema = Arc::new(Schema::empty());
let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(4);
let mut rows_sent = 0usize;
stores
.flush_incremental(&schema, &output_tx, &mut rows_sent, None)
.await
.unwrap();
assert_eq!(rows_sent, 0);
assert!(output_rx.try_recv().is_err());
assert_eq!(
stores.processed_through_time,
Some(TimeInt::MAX),
"path 1b must advance processed_through_time to the new horizon",
);
}
#[test]
#[ignore = "microbench — run explicitly with --run-ignored only --no-capture"]
fn bench_gc_up_to_horizon_scaling() {
use std::time::Instant;
for n_entities in [100, 500] {
for m_chunks_per_entity in [1_000, 2_000, 3_000, 4_000, 5_000] {
let budget = Arc::new(PipelineBudget::new(1 << 40, 1));
let mut stores = stores_with_timeline("seg", "frame", budget);
let setup_start = Instant::now();
for i in 0..n_entities {
let entity = format!("/e{i}");
for j in 0..m_chunks_per_entity {
let t = j as i64;
stores
.store
.write()
.insert_chunk(&Arc::new(temporal_chunk(&entity, "frame", t)))
.unwrap();
}
}
let setup = setup_start.elapsed();
stores.gc_up_to_horizon(TimeInt::new_temporal(1_000));
let n_calls: u32 = 20;
let bench_start = Instant::now();
for k in 0..n_calls {
let h = 1_000 + i64::from(k);
stores.gc_up_to_horizon(TimeInt::new_temporal(h));
}
let elapsed = bench_start.elapsed();
let per_call = elapsed / n_calls;
println!(
"n_entities={n_entities:>6} chunks_per_entity={m_chunks_per_entity:>2} \
total_chunks={total:>7} setup={setup:?} per_gc_call={per_call:?} \
total_bench={elapsed:?}",
total = n_entities * m_chunks_per_entity,
);
}
}
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "manifest/chunk divergence")]
fn test_record_arrival_divergent_chunk_debug_panics() {
use re_log_types::EntityPath;
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = CurrentStores::new(
SegmentId::from("seg"),
&QueryExpression {
filtered_index: Some(TimelineName::new("frame")),
..Default::default()
},
&None,
budget,
);
let mut manifest = SegmentChunkManifest::new();
manifest.expect_chunk(EntityPath::from("/a"), TimeInt::new_temporal(10));
manifest.lock();
stores.manifest = Some(manifest);
let chunk = temporal_chunk("/a", "frame", 20);
stores.record_arrival(&chunk);
}
#[test]
fn test_record_arrival_tracks_time_max_without_manifest() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 1));
let mut stores = CurrentStores::new(
SegmentId::from("seg"),
&QueryExpression {
filtered_index: Some(TimelineName::new("frame")),
..Default::default()
},
&None,
budget,
);
assert!(stores.manifest.is_none());
assert!(stores.max_arrived_time_max.is_none());
stores.record_arrival(&temporal_chunk("/a", "frame", 10));
stores.record_arrival(&temporal_chunk("/b", "frame", 30));
stores.record_arrival(&temporal_chunk("/c", "frame", 20));
assert_eq!(
stores.max_arrived_time_max,
Some(TimeInt::new_temporal(30)),
"max_arrived_time_max must reflect pre-manifest temporal arrivals",
);
}
async fn drive_worker_with_budget(
budget: Arc<PipelineBudget>,
input_rx: Receiver<ApiResult<CpuWorkerMsg>>,
output_tx: Sender<RecordBatch>,
mut output_rx: Receiver<RecordBatch>,
) -> (ApiResult<()>, usize) {
let schema = Arc::new(Schema::empty());
let worker = chunk_store_cpu_worker_thread(
input_rx,
output_tx,
QueryExpression::default(),
schema,
None,
None,
budget,
);
let drainer = async {
let mut n = 0;
while output_rx.recv().await.is_some() {
n += 1;
}
n
};
tokio::join!(worker, drainer)
}
#[tokio::test]
async fn test_cpu_worker_abandons_incomplete_segment_on_eos() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 4));
let releases_before = budget.total_releases();
let (input_tx, input_rx) = tokio::sync::mpsc::channel::<ApiResult<CpuWorkerMsg>>(8);
let (output_tx, output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(8);
input_tx
.send(Ok(CpuWorkerMsg::SegmentChunkCount {
segment_id: SegmentId::from("A"),
count: 5,
}))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("A"),
vec![empty_chunk(), empty_chunk()],
))))
.await
.unwrap();
drop(input_tx);
let (result, n_batches) =
drive_worker_with_budget(budget.clone(), input_rx, output_tx, output_rx).await;
result.expect("worker must terminate cleanly on incomplete segment EOS");
assert_eq!(
n_batches, 0,
"incomplete segment must not be flushed at end-of-stream",
);
assert_eq!(
budget.total_releases(),
releases_before + 1,
"Drop for CurrentStores must run exactly once for the abandoned segment",
);
}
#[tokio::test]
#[cfg(debug_assertions)]
#[should_panic(expected = "duplicate SegmentManifest")]
async fn test_duplicate_segment_manifest_debug_panics() {
let (input_tx, input_rx) = tokio::sync::mpsc::channel::<ApiResult<CpuWorkerMsg>>(8);
let (output_tx, output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(8);
for _ in 0..2 {
input_tx
.send(Ok(CpuWorkerMsg::SegmentManifest {
segment_id: SegmentId::from("A"),
manifest: Box::new(SegmentChunkManifest::new()),
}))
.await
.unwrap();
}
drop(input_tx);
let (_result, _n_batches) = drive_worker(input_rx, output_tx, output_rx).await;
}
#[tokio::test]
async fn test_cpu_worker_abandons_incomplete_segment_on_consumer_cancel() {
let budget = Arc::new(PipelineBudget::new(1 << 30, 4));
let releases_before = budget.total_releases();
let (input_tx, input_rx) = tokio::sync::mpsc::channel::<ApiResult<CpuWorkerMsg>>(8);
let (output_tx, output_rx) = tokio::sync::mpsc::channel::<RecordBatch>(8);
input_tx
.send(Ok(CpuWorkerMsg::SegmentChunkCount {
segment_id: SegmentId::from("A"),
count: 5,
}))
.await
.unwrap();
input_tx
.send(Ok(CpuWorkerMsg::Chunks((
SegmentId::from("A"),
vec![empty_chunk()],
))))
.await
.unwrap();
drop(output_rx);
drop(input_tx);
let schema = Arc::new(Schema::empty());
let result = chunk_store_cpu_worker_thread(
input_rx,
output_tx,
QueryExpression::default(),
schema,
None,
None,
budget.clone(),
)
.await;
result.expect("worker must terminate cleanly on consumer cancellation");
assert_eq!(
budget.total_releases(),
releases_before + 1,
"Drop for CurrentStores must run exactly once for the cancelled segment",
);
}
}