use std::collections::VecDeque;
use arrow::array::{Array, Int64Array, RecordBatch};
use chronix_engine::index::SegmentCatalogEntry;
use chronix_engine::segment::FieldPredicate;
use chronix_query::plan::{
QueryPlan, TagFilter, TimeRange, extract_field_predicates, extract_scan,
};
use crate::error::{DbError, Result};
use super::Chronix;
use super::query::SegmentFilterCtx;
pub(super) const CHUNK_ROWS: usize = 65_536;
const PARALLEL_THRESHOLD: usize = 4;
struct Bucket {
segments: Vec<SegmentCatalogEntry>,
memtable: Option<RecordBatch>,
}
pub struct BatchStream<'a> {
db: &'a Chronix,
plan: QueryPlan,
measurement: String,
projection: Vec<String>,
tag_filters: Vec<TagFilter>,
time_range: TimeRange,
field_predicates: Vec<FieldPredicate>,
scan_columns: Option<Vec<String>>,
tag_col_names: Option<Vec<String>>,
buckets: VecDeque<Bucket>,
pending: VecDeque<RecordBatch>,
max_total_bytes: usize,
emitted_bytes: usize,
remaining: Option<usize>,
to_skip: usize,
done: bool,
deadline: Option<(std::time::Instant, std::time::Duration)>,
pruning_stats: chronix_query::pruning::PruningStats,
_segment_lease: super::leases::SegmentLease<'a>,
}
impl BatchStream<'_> {
#[must_use]
pub fn with_max_total_bytes(mut self, max_bytes: usize) -> Self {
self.max_total_bytes = max_bytes;
self
}
#[must_use]
pub fn without_deadline(mut self) -> Self {
self.deadline = None;
self
}
#[must_use]
fn with_limit(mut self, limit: usize, offset: usize) -> Self {
self.remaining = Some(limit);
self.to_skip = offset;
self
}
fn clip(&mut self, batch: RecordBatch) -> Option<RecordBatch> {
let Some(remaining) = self.remaining.as_mut() else {
return Some(batch);
};
let rows = batch.num_rows();
if self.to_skip >= rows {
self.to_skip -= rows;
return None;
}
let start = self.to_skip;
self.to_skip = 0;
let take = (rows - start).min(*remaining);
*remaining -= take;
if take == 0 {
self.done = true;
return None;
}
Some(batch.slice(start, take))
}
#[must_use]
pub fn pruning_stats(&self) -> chronix_query::pruning::PruningStats {
self.pruning_stats.clone()
}
#[must_use]
pub fn buckets_remaining(&self) -> usize {
self.buckets.len()
}
fn materialise(&self, bucket: Bucket) -> Result<Vec<RecordBatch>> {
let tag_filter_refs: Vec<(&str, &str)> = self
.tag_filters
.iter()
.map(|f| (f.key.as_str(), f.value.as_str()))
.collect();
let tag_refs: Option<Vec<&str>> = self
.tag_col_names
.as_ref()
.map(|v| v.iter().map(String::as_str).collect());
let tombs = self.db.tombstones.read();
let segments_dir = self.db.segments_dir();
let ctx = SegmentFilterCtx {
scan_columns: self.scan_columns.as_ref(),
time_range: &self.time_range,
tag_filter_refs: &tag_filter_refs,
field_predicates: &self.field_predicates,
measurement: &self.measurement,
projection: &self.projection,
plan: &self.plan,
tombstones: &tombs,
tag_col_names: tag_refs.as_deref(),
segments_dir: &segments_dir,
#[cfg(feature = "field-encryption")]
key_provider: self
.db
.field_encryption
.as_ref()
.map(|e| std::sync::Arc::clone(&e.reader)),
};
let mut sources: Vec<RecordBatch> = Vec::with_capacity(bucket.segments.len() + 1);
if bucket.segments.len() >= PARALLEL_THRESHOLD {
use rayon::prelude::*;
let read: Vec<Result<Option<RecordBatch>>> = bucket
.segments
.par_iter()
.map(|entry| Chronix::read_segment_filtered(entry, &ctx))
.collect();
for r in read {
if let Some(batch) = r? {
sources.push(batch);
}
}
} else {
for entry in &bucket.segments {
if let Some(batch) = Chronix::read_segment_filtered(entry, &ctx)? {
sources.push(batch);
}
}
}
if let Some(mem) = bucket.memtable
&& mem.num_rows() > 0
{
let filtered = chronix_query::filter::filter_batch(
&mem,
self.time_range.start,
self.time_range.end,
&tag_filter_refs,
)?;
let filtered = Chronix::apply_projection_and_tombstone(
&filtered,
&self.measurement,
&self.projection,
&self.plan,
&tombs,
tag_refs.as_deref(),
None,
)?;
if filtered.num_rows() > 0 {
sources.push(filtered);
}
}
if sources.is_empty() {
return Ok(Vec::new());
}
Ok(chronix_query::dedup::sort_merge_dedup_chunked(
sources,
&self.measurement,
CHUNK_ROWS,
None,
)?)
}
}
impl Iterator for BatchStream<'_> {
type Item = Result<RecordBatch>;
fn next(&mut self) -> Option<Self::Item> {
loop {
if self.done {
return None;
}
if let Some(batch) = self.pending.pop_front() {
let batch = match self.clip(batch) {
Some(b) => b,
None => continue,
};
if self.max_total_bytes > 0 {
self.emitted_bytes += batch.get_array_memory_size();
if self.emitted_bytes > self.max_total_bytes {
self.done = true;
let max = self.max_total_bytes;
let total = self.emitted_bytes;
return Some(Err(DbError::Internal(format!(
"streaming query result too large: {total} bytes exceeds limit of \
{max} bytes (config max_query_result_bytes). Narrow the time range \
or add filters."
))));
}
}
return Some(Ok(self.db.stamp_roles(&self.measurement, batch)));
}
if self.remaining == Some(0) {
self.done = true;
return None;
}
let bucket = self.buckets.pop_front()?;
if let Some((expiry, budget)) = self.deadline
&& std::time::Instant::now() >= expiry
{
self.done = true;
return Some(Err(DbError::QueryTimeout(budget)));
}
match self.materialise(bucket) {
Ok(chunks) => self.pending.extend(chunks),
Err(e) => {
self.done = true;
return Some(Err(e));
}
}
}
}
}
impl Chronix {
pub fn execute_iter(&self, plan: &QueryPlan) -> Result<BatchStream<'_>> {
self.check_open()?;
if let QueryPlan::Limit {
source,
limit,
offset,
} = plan
&& matches!(source.as_ref(), QueryPlan::Scan { .. })
{
return Ok(self.execute_iter(source)?.with_limit(*limit, *offset));
}
if !matches!(plan, QueryPlan::Scan { .. }) {
return Err(DbError::Internal(
"execute_iter requires a Scan or Limit(Scan) plan; aggregate, downsample and \
window plans must see their whole input — use execute() or execute_stream()"
.into(),
));
}
let (measurement, tag_filters, projection, time_range) = extract_scan(plan)
.ok_or_else(|| DbError::Internal("invalid query plan: no scan node found".into()))?;
let memtable_points: Vec<chronix_core::Point> =
self.shards
.scan_measurement(measurement, time_range.start, time_range.end);
let memtable_batch = chronix_query::points_to_record_batch(&memtable_points)?;
drop(memtable_points);
let (matching_entries, pruning_stats, segment_lease) =
self.prune_segments(measurement, tag_filters, time_range);
tracing::debug!(
measurement,
total = pruning_stats.segments_total,
pruned_time = pruning_stats.pruned_by_time,
surviving = matching_entries.len(),
"execute_iter segment pruning"
);
let buckets = build_buckets(matching_entries, memtable_batch)?;
let tag_col_names: Option<Vec<String>> = self
.schema(measurement)
.map(|ms| ms.tag_names().into_iter().map(String::from).collect());
Ok(BatchStream {
db: self,
scan_columns: Self::compute_scan_columns(plan, tag_filters, projection),
measurement: measurement.to_string(),
projection: projection.to_vec(),
tag_filters: tag_filters.to_vec(),
time_range: *time_range,
field_predicates: extract_field_predicates(plan).to_vec(),
tag_col_names,
plan: plan.clone(),
buckets: buckets.into(),
pending: VecDeque::new(),
max_total_bytes: 0,
emitted_bytes: 0,
remaining: None,
to_skip: 0,
done: false,
deadline: {
let budget = self.config.query_timeout;
(!budget.is_zero()).then(|| (std::time::Instant::now() + budget, budget))
},
pruning_stats,
_segment_lease: segment_lease,
})
}
}
fn ensure_ts_sorted(batch: RecordBatch) -> Result<RecordBatch> {
let Some(col) = batch.column_by_name(chronix_core::TIME_COLUMN) else {
return Ok(batch);
};
let Some(ts) = col.as_any().downcast_ref::<Int64Array>() else {
return Ok(batch);
};
if ts.values().windows(2).all(|w| w[0] <= w[1]) {
return Ok(batch);
}
let indices = arrow::compute::sort_to_indices(col, None, None)
.map_err(|e| DbError::Internal(e.to_string()))?;
let cols = batch
.columns()
.iter()
.map(|c| arrow::compute::take(c.as_ref(), &indices, None))
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| DbError::Internal(e.to_string()))?;
RecordBatch::try_new(batch.schema(), cols).map_err(|e| DbError::Internal(e.to_string()))
}
fn build_buckets(entries: Vec<SegmentCatalogEntry>, memtable: RecordBatch) -> Result<Vec<Bucket>> {
struct Range {
lo: i64,
hi: i64,
segments: Vec<(usize, SegmentCatalogEntry)>,
}
let mut ranges: Vec<Range> = Vec::new();
let mut ordered: Vec<(usize, SegmentCatalogEntry)> = entries.into_iter().enumerate().collect();
ordered.sort_by_key(|(idx, e)| (e.min_timestamp, *idx));
for (idx, entry) in ordered {
match ranges.last_mut() {
Some(range) if entry.min_timestamp <= range.hi => {
range.hi = range.hi.max(entry.max_timestamp);
range.segments.push((idx, entry));
}
_ => ranges.push(Range {
lo: entry.min_timestamp,
hi: entry.max_timestamp,
segments: vec![(idx, entry)],
}),
}
}
let memtable = ensure_ts_sorted(memtable)?;
let ts: &[i64] = memtable
.column_by_name(chronix_core::TIME_COLUMN)
.and_then(|c| c.as_any().downcast_ref::<Int64Array>())
.map_or(&[][..], |a| a.values().as_ref());
if memtable.num_rows() != ts.len() {
return Err(DbError::Internal(format!(
"memtable batch has {} rows but a timestamp column of {} readable values; \
a scan cannot be bucketed without a timestamp per row",
memtable.num_rows(),
ts.len()
)));
}
let n = ts.len();
let mut buckets = Vec::with_capacity(ranges.len() + 1);
let mut pos = 0usize;
for range in ranges {
let gap_start = pos;
while pos < n && ts[pos] < range.lo {
pos += 1;
}
if pos > gap_start {
buckets.push(Bucket {
segments: Vec::new(),
memtable: Some(memtable.slice(gap_start, pos - gap_start)),
});
}
let in_start = pos;
while pos < n && ts[pos] <= range.hi {
pos += 1;
}
let mut segments = range.segments;
segments.sort_by_key(|(idx, _)| *idx);
buckets.push(Bucket {
segments: segments.into_iter().map(|(_, e)| e).collect(),
memtable: (pos > in_start).then(|| memtable.slice(in_start, pos - in_start)),
});
}
if pos < memtable.num_rows() {
buckets.push(Bucket {
segments: Vec::new(),
memtable: Some(memtable.slice(pos, memtable.num_rows() - pos)),
});
}
Ok(buckets)
}