1use std::collections::HashMap;
7
8use super::compression::{
9 delta_decode_timestamps, delta_encode_timestamps, xor_decode_values, xor_encode_values,
10};
11use crate::catalog::AnalyticalStorageConfig;
12use crate::storage::index::{BloomSegment, HasBloom, ZoneDecision, ZoneMap, ZonePredicate};
13use crate::storage::schema::types::DataType;
14use crate::storage::unified::column_block::{
15 read_column_block, write_column_block, ColumnBlockError, ColumnInput,
16};
17use crate::storage::unified::segment_codec::ColumnSemantics;
18
19pub const COLUMNAR_TS_COLUMN_ID: u32 = 0;
21pub const COLUMNAR_VALUE_COLUMN_ID: u32 = 1;
23pub const DEFAULT_GRANULE_SIZE: u32 = 8192;
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct TimeSeriesPoint {
31 pub timestamp_ns: u64,
32 pub value: f64,
33}
34
35pub struct TimeSeriesChunk {
40 pub metric: String,
42 pub tags: HashMap<String, String>,
44 timestamps: Vec<u64>,
46 values: Vec<f64>,
48 max_points: usize,
50 sealed: bool,
52 compressed_timestamps: Option<Vec<i64>>,
54 compressed_values: Option<Vec<u64>>,
56 bloom: BloomSegment,
60 timestamp_zone: ZoneMap,
65 value_zone: ZoneMap,
70}
71
72impl HasBloom for TimeSeriesChunk {
73 fn bloom_segment(&self) -> Option<&BloomSegment> {
74 Some(&self.bloom)
75 }
76}
77
78impl TimeSeriesChunk {
79 pub fn new(metric: impl Into<String>, tags: HashMap<String, String>) -> Self {
81 Self {
82 metric: metric.into(),
83 tags,
84 timestamps: Vec::new(),
85 values: Vec::new(),
86 max_points: 1024,
87 sealed: false,
88 compressed_timestamps: None,
89 compressed_values: None,
90 bloom: BloomSegment::with_capacity(1024),
91 timestamp_zone: ZoneMap::with_capacity(1024),
92 value_zone: ZoneMap::with_capacity(1024),
93 }
94 }
95
96 pub fn with_max_points(
98 metric: impl Into<String>,
99 tags: HashMap<String, String>,
100 max_points: usize,
101 ) -> Self {
102 let mut chunk = Self::new(metric, tags);
103 chunk.max_points = max_points;
104 chunk.timestamp_zone = ZoneMap::with_capacity(max_points);
105 chunk
106 }
107
108 pub fn append(&mut self, timestamp_ns: u64, value: f64) -> bool {
110 if self.sealed || self.timestamps.len() >= self.max_points {
111 return false;
112 }
113 self.bloom.insert(×tamp_ns.to_le_bytes());
114 self.timestamp_zone.observe(×tamp_ns.to_be_bytes()); self.value_zone.observe(&value.to_le_bytes());
116 self.timestamps.push(timestamp_ns);
117 self.values.push(value);
118 true
119 }
120
121 pub fn may_contain_timestamp(&self, timestamp_ns: u64) -> bool {
126 !self.bloom.definitely_absent(×tamp_ns.to_le_bytes())
127 }
128
129 pub fn timestamp_range_skip(&self, start_ns: u64, end_ns: u64) -> bool {
136 let start_b = start_ns.to_be_bytes();
137 let end_b = end_ns.to_be_bytes();
138 matches!(
139 self.timestamp_zone.block_skip(&ZonePredicate::Range {
140 start: Some(&start_b),
141 end: Some(&end_b),
142 }),
143 ZoneDecision::Skip
144 )
145 }
146
147 pub fn value_range_skip(&self, lo: f64, hi: f64) -> bool {
154 let lo_b = lo.to_le_bytes();
155 let hi_b = hi.to_le_bytes();
156 matches!(
157 self.value_zone.block_skip(&ZonePredicate::Range {
158 start: Some(&lo_b),
159 end: Some(&hi_b),
160 }),
161 ZoneDecision::Skip
162 )
163 }
164
165 pub fn distinct_value_estimate(&self) -> u64 {
167 self.value_zone.distinct_estimate()
168 }
169
170 pub fn len(&self) -> usize {
172 self.timestamps.len()
173 }
174
175 pub fn is_empty(&self) -> bool {
177 self.timestamps.is_empty()
178 }
179
180 pub fn is_full(&self) -> bool {
182 self.timestamps.len() >= self.max_points
183 }
184
185 pub fn is_sealed(&self) -> bool {
187 self.sealed
188 }
189
190 pub fn min_timestamp(&self) -> Option<u64> {
192 self.timestamps.first().copied()
193 }
194
195 pub fn max_timestamp(&self) -> Option<u64> {
197 self.timestamps.last().copied()
198 }
199
200 pub fn seal(&mut self) {
202 if self.sealed {
203 return;
204 }
205 if !self.timestamps.windows(2).all(|w| w[0] <= w[1]) {
207 let mut indices: Vec<usize> = (0..self.timestamps.len()).collect();
208 indices.sort_by_key(|&i| self.timestamps[i]);
209 let sorted_ts: Vec<u64> = indices.iter().map(|&i| self.timestamps[i]).collect();
210 let sorted_vals: Vec<f64> = indices.iter().map(|&i| self.values[i]).collect();
211 self.timestamps = sorted_ts;
212 self.values = sorted_vals;
213 }
214 self.compressed_timestamps = Some(delta_encode_timestamps(&self.timestamps));
215 self.compressed_values = Some(xor_encode_values(&self.values));
216 self.sealed = true;
217 }
218
219 pub fn seal_columnar(
233 &mut self,
234 chunk_id: u64,
235 schema_ref: u64,
236 ) -> Result<Vec<u8>, ColumnBlockError> {
237 self.seal_columnar_with_granule_size(chunk_id, schema_ref, DEFAULT_GRANULE_SIZE)
238 }
239
240 pub fn seal_columnar_with_granule_size(
246 &mut self,
247 chunk_id: u64,
248 schema_ref: u64,
249 granule_size: u32,
250 ) -> Result<Vec<u8>, ColumnBlockError> {
251 if !self.sealed {
252 self.seal();
253 }
254 let ts_bytes: Vec<u8> = self
255 .timestamps
256 .iter()
257 .flat_map(|t| t.to_le_bytes())
258 .collect();
259 let val_bytes: Vec<u8> = self.values.iter().flat_map(|v| v.to_le_bytes()).collect();
260 write_column_block(
261 chunk_id,
262 schema_ref,
263 self.timestamps.len() as u64,
264 self.min_timestamp().unwrap_or(0),
265 self.max_timestamp().unwrap_or(0),
266 granule_size,
267 &[
268 ColumnInput {
269 column_id: COLUMNAR_TS_COLUMN_ID,
270 logical_type: DataType::UnsignedInteger.to_byte(),
271 semantics: ColumnSemantics::Timestamp,
273 data: &ts_bytes,
274 },
275 ColumnInput {
276 column_id: COLUMNAR_VALUE_COLUMN_ID,
277 logical_type: DataType::Float.to_byte(),
278 semantics: ColumnSemantics::Gauge,
280 data: &val_bytes,
281 },
282 ],
283 )
284 }
285
286 pub fn query_range(&self, start_ns: u64, end_ns: u64) -> Vec<TimeSeriesPoint> {
298 if self.timestamp_range_skip(start_ns, end_ns) {
300 return Vec::new();
301 }
302 if self.sealed {
303 let start_idx = self.timestamps.partition_point(|&ts| ts < start_ns);
305 self.timestamps[start_idx..]
306 .iter()
307 .zip(self.values[start_idx..].iter())
308 .take_while(|(&ts, _)| ts <= end_ns)
309 .map(|(&ts, &val)| TimeSeriesPoint {
310 timestamp_ns: ts,
311 value: val,
312 })
313 .collect()
314 } else {
315 self.timestamps
317 .iter()
318 .zip(self.values.iter())
319 .filter(|(&ts, _)| ts >= start_ns && ts <= end_ns)
320 .map(|(&ts, &val)| TimeSeriesPoint {
321 timestamp_ns: ts,
322 value: val,
323 })
324 .collect()
325 }
326 }
327
328 pub fn points(&self) -> Vec<TimeSeriesPoint> {
330 self.timestamps
331 .iter()
332 .zip(self.values.iter())
333 .map(|(&ts, &val)| TimeSeriesPoint {
334 timestamp_ns: ts,
335 value: val,
336 })
337 .collect()
338 }
339
340 pub fn memory_bytes(&self) -> usize {
342 let mut size = std::mem::size_of::<Self>();
343 size += self.timestamps.len() * 8;
344 size += self.values.len() * 8;
345 if let Some(ref ct) = self.compressed_timestamps {
346 size += ct.len() * 8;
347 }
348 if let Some(ref cv) = self.compressed_values {
349 size += cv.len() * 8;
350 }
351 for (k, v) in &self.tags {
352 size += k.len() + v.len();
353 }
354 size += self.metric.len();
355 size
356 }
357
358 pub fn compression_ratio(&self) -> Option<f64> {
360 if !self.sealed {
361 return None;
362 }
363 let raw = (self.timestamps.len() * 8 + self.values.len() * 8) as f64;
364 let compressed = self
365 .compressed_timestamps
366 .as_ref()
367 .map_or(0, |v| v.len() * 8) as f64
368 + self.compressed_values.as_ref().map_or(0, |v| v.len() * 8) as f64;
369 if raw > 0.0 {
370 Some(compressed / raw)
371 } else {
372 None
373 }
374 }
375
376 pub fn verify(&self) -> bool {
378 if !self.sealed {
379 return true;
380 }
381 if let (Some(ct), Some(cv)) = (&self.compressed_timestamps, &self.compressed_values) {
382 let decoded_ts = delta_decode_timestamps(ct);
383 let decoded_vals = xor_decode_values(cv);
384 decoded_ts == self.timestamps && decoded_vals == self.values
385 } else {
386 false
387 }
388 }
389}
390
391#[derive(Debug)]
393pub enum SealedChunkStorage {
394 Columnar(Vec<u8>),
397 Row,
400}
401
402pub fn seal_chunk_with_config(
407 chunk: &mut TimeSeriesChunk,
408 config: Option<&AnalyticalStorageConfig>,
409 chunk_id: u64,
410 schema_ref: u64,
411) -> Result<SealedChunkStorage, ColumnBlockError> {
412 if config.map(|c| c.columnar).unwrap_or(false) {
413 Ok(SealedChunkStorage::Columnar(
414 chunk.seal_columnar(chunk_id, schema_ref)?,
415 ))
416 } else {
417 chunk.seal();
418 Ok(SealedChunkStorage::Row)
419 }
420}
421
422pub fn points_from_column_block(bytes: &[u8]) -> Result<Vec<TimeSeriesPoint>, ColumnBlockError> {
426 let block = read_column_block(bytes)?;
427 let ts_col = block
428 .columns
429 .iter()
430 .find(|c| c.column_id == COLUMNAR_TS_COLUMN_ID)
431 .ok_or(ColumnBlockError::BadDirectory)?;
432 let val_col = block
433 .columns
434 .iter()
435 .find(|c| c.column_id == COLUMNAR_VALUE_COLUMN_ID)
436 .ok_or(ColumnBlockError::BadDirectory)?;
437 if ts_col.data.len() % 8 != 0 || val_col.data.len() % 8 != 0 {
438 return Err(ColumnBlockError::BadDirectory);
439 }
440 let points = ts_col
441 .data
442 .chunks_exact(8)
443 .zip(val_col.data.chunks_exact(8))
444 .map(|(t, v)| TimeSeriesPoint {
445 timestamp_ns: u64::from_le_bytes(t.try_into().unwrap()),
446 value: f64::from_le_bytes(v.try_into().unwrap()),
447 })
448 .collect();
449 Ok(points)
450}
451
452#[derive(Debug, Clone, PartialEq)]
457pub struct PrunedColumnScan {
458 pub points: Vec<TimeSeriesPoint>,
461 pub granules_total: usize,
463 pub granules_scanned: usize,
465}
466
467pub fn query_column_block_range(
481 bytes: &[u8],
482 start_ns: u64,
483 end_ns: u64,
484) -> Result<PrunedColumnScan, ColumnBlockError> {
485 let block = read_column_block(bytes)?;
486 let ts_col = block
487 .columns
488 .iter()
489 .find(|c| c.column_id == COLUMNAR_TS_COLUMN_ID)
490 .ok_or(ColumnBlockError::BadDirectory)?;
491 let val_col = block
492 .columns
493 .iter()
494 .find(|c| c.column_id == COLUMNAR_VALUE_COLUMN_ID)
495 .ok_or(ColumnBlockError::BadDirectory)?;
496 if !ts_col.data.len().is_multiple_of(8) || !val_col.data.len().is_multiple_of(8) {
497 return Err(ColumnBlockError::BadDirectory);
498 }
499 let n = ts_col.data.len() / 8;
500 if val_col.data.len() / 8 != n {
501 return Err(ColumnBlockError::BadDirectory);
502 }
503
504 let ts_at =
505 |i: usize| -> u64 { u64::from_le_bytes(ts_col.data[i * 8..i * 8 + 8].try_into().unwrap()) };
506 let val_at = |i: usize| -> f64 {
507 f64::from_le_bytes(val_col.data[i * 8..i * 8 + 8].try_into().unwrap())
508 };
509 let take_row = |i: usize, out: &mut Vec<TimeSeriesPoint>| {
510 let ts = ts_at(i);
511 if ts >= start_ns && ts <= end_ns {
512 out.push(TimeSeriesPoint {
513 timestamp_ns: ts,
514 value: val_at(i),
515 });
516 }
517 };
518
519 let mut points = Vec::new();
520 let (granules_total, granules_scanned) = match &ts_col.granule_index {
521 Some(gi) if gi.granule_count() > 0 => {
522 let survivors = gi.surviving_granules(|min, max| {
525 if min.len() < 8 || max.len() < 8 {
526 return true; }
528 let gmin = u64::from_le_bytes(min[..8].try_into().unwrap());
529 let gmax = u64::from_le_bytes(max[..8].try_into().unwrap());
530 gmin <= end_ns && gmax >= start_ns
531 });
532 for &g in &survivors {
533 let (s, e) = gi.row_range(g, n);
534 for i in s..e {
535 take_row(i, &mut points);
536 }
537 }
538 (gi.granule_count(), survivors.len())
539 }
540 _ => {
542 for i in 0..n {
543 take_row(i, &mut points);
544 }
545 (1, 1)
546 }
547 };
548
549 Ok(PrunedColumnScan {
550 points,
551 granules_total,
552 granules_scanned,
553 })
554}
555
556pub fn query_column_block_value_eq(
570 bytes: &[u8],
571 target: f64,
572) -> Result<PrunedColumnScan, ColumnBlockError> {
573 let block = read_column_block(bytes)?;
574 let ts_col = block
575 .columns
576 .iter()
577 .find(|c| c.column_id == COLUMNAR_TS_COLUMN_ID)
578 .ok_or(ColumnBlockError::BadDirectory)?;
579 let val_col = block
580 .columns
581 .iter()
582 .find(|c| c.column_id == COLUMNAR_VALUE_COLUMN_ID)
583 .ok_or(ColumnBlockError::BadDirectory)?;
584 if !ts_col.data.len().is_multiple_of(8) || !val_col.data.len().is_multiple_of(8) {
585 return Err(ColumnBlockError::BadDirectory);
586 }
587 let n = val_col.data.len() / 8;
588 if ts_col.data.len() / 8 != n {
589 return Err(ColumnBlockError::BadDirectory);
590 }
591
592 let ts_at =
593 |i: usize| -> u64 { u64::from_le_bytes(ts_col.data[i * 8..i * 8 + 8].try_into().unwrap()) };
594 let val_bytes = |i: usize| -> [u8; 8] { val_col.data[i * 8..i * 8 + 8].try_into().unwrap() };
595 let target_bytes = target.to_le_bytes();
596 let take_row = |i: usize, out: &mut Vec<TimeSeriesPoint>| {
597 if val_bytes(i) == target_bytes {
598 out.push(TimeSeriesPoint {
599 timestamp_ns: ts_at(i),
600 value: target,
601 });
602 }
603 };
604
605 let mut points = Vec::new();
606 let (granules_total, granules_scanned) = match &val_col.granule_bloom {
607 Some(gb) if gb.granule_count() > 0 => {
608 let survivors = gb.surviving_granules(&target_bytes);
611 for &g in &survivors {
612 let (s, e) = gb.row_range(g, n);
613 for i in s..e {
614 take_row(i, &mut points);
615 }
616 }
617 (gb.granule_count(), survivors.len())
618 }
619 _ => {
621 for i in 0..n {
622 take_row(i, &mut points);
623 }
624 (1, 1)
625 }
626 };
627
628 Ok(PrunedColumnScan {
629 points,
630 granules_total,
631 granules_scanned,
632 })
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use proptest::prelude::*;
639
640 fn make_tags(host: &str) -> HashMap<String, String> {
641 let mut tags = HashMap::new();
642 tags.insert("host".to_string(), host.to_string());
643 tags
644 }
645
646 #[test]
647 fn test_chunk_basic() {
648 let mut chunk = TimeSeriesChunk::new("cpu.idle", make_tags("srv1"));
649 assert!(chunk.append(1000, 95.2));
650 assert!(chunk.append(2000, 94.8));
651 assert!(chunk.append(3000, 96.1));
652
653 assert_eq!(chunk.len(), 3);
654 assert_eq!(chunk.min_timestamp(), Some(1000));
655 assert_eq!(chunk.max_timestamp(), Some(3000));
656 }
657
658 #[test]
659 fn test_chunk_range_query() {
660 let mut chunk = TimeSeriesChunk::new("cpu.idle", make_tags("srv1"));
661 for i in 0..10 {
662 chunk.append(i * 1000, 90.0 + i as f64);
663 }
664
665 let results = chunk.query_range(3000, 6000);
666 assert_eq!(results.len(), 4); assert_eq!(results[0].timestamp_ns, 3000);
668 assert_eq!(results[3].timestamp_ns, 6000);
669 }
670
671 #[test]
672 fn test_chunk_seal_and_verify() {
673 let mut chunk = TimeSeriesChunk::new("mem.used", make_tags("srv1"));
674 for i in 0..100 {
675 chunk.append(1_000_000 + i * 60_000, 72.5 + (i as f64) * 0.1);
676 }
677
678 assert!(!chunk.is_sealed());
679 chunk.seal();
680 assert!(chunk.is_sealed());
681 assert!(chunk.verify());
682
683 assert!(!chunk.append(9_999_999, 99.0));
685 }
686
687 #[test]
688 fn test_chunk_max_points() {
689 let mut chunk = TimeSeriesChunk::with_max_points("test", HashMap::new(), 5);
690 for i in 0..5 {
691 assert!(chunk.append(i, i as f64));
692 }
693 assert!(chunk.is_full());
694 assert!(!chunk.append(5, 5.0));
695 }
696
697 #[test]
698 fn test_chunk_bloom_point_lookup() {
699 let mut chunk = TimeSeriesChunk::new("cpu.idle", make_tags("srv1"));
700 for ts in [1000u64, 2000, 3000, 4000] {
701 chunk.append(ts, 1.0);
702 }
703 assert!(chunk.may_contain_timestamp(1000));
705 assert!(chunk.may_contain_timestamp(4000));
706 let _ = chunk.may_contain_timestamp(9999);
710 assert!(chunk.query_range(9999, 9999).is_empty());
711 }
712
713 #[test]
718 fn seal_columnar_round_trips_points_value_for_value() {
719 let mut chunk = TimeSeriesChunk::new("cpu.idle", make_tags("srv1"));
720 let expected: Vec<TimeSeriesPoint> = (0..200)
721 .map(|i| TimeSeriesPoint {
722 timestamp_ns: 1_700_000_000_000 + i * 1_000_000,
723 value: 95.0 + (i % 11) as f64 * 0.125,
724 })
725 .collect();
726 for p in &expected {
727 assert!(chunk.append(p.timestamp_ns, p.value));
728 }
729
730 let block = chunk.seal_columnar(7, 42).expect("seal columnar");
731 assert!(chunk.is_sealed());
732
733 let decoded = points_from_column_block(&block).expect("decode block");
734 assert_eq!(decoded.len(), expected.len());
735 assert_eq!(decoded, expected, "lossless value-for-value round-trip");
736 }
737
738 #[test]
739 fn seal_chunk_with_config_routes_columnar_vs_row() {
740 let mut columnar = TimeSeriesChunk::new("m", HashMap::new());
742 for i in 0..50 {
743 columnar.append(i * 1000, i as f64);
744 }
745 let cfg = AnalyticalStorageConfig {
746 columnar: true,
747 time_key: "ts".to_string(),
748 order_by_key: None,
749 };
750 let routed = seal_chunk_with_config(&mut columnar, Some(&cfg), 1, 0).unwrap();
751 match routed {
752 SealedChunkStorage::Columnar(bytes) => {
753 assert_eq!(points_from_column_block(&bytes).unwrap().len(), 50);
754 }
755 SealedChunkStorage::Row => panic!("columnar flag must route to ColumnBlock writer"),
756 }
757
758 let mut row = TimeSeriesChunk::new("m", HashMap::new());
760 for i in 0..50 {
761 row.append(i * 1000, i as f64);
762 }
763 assert!(matches!(
764 seal_chunk_with_config(&mut row, None, 1, 0).unwrap(),
765 SealedChunkStorage::Row
766 ));
767 assert!(row.is_sealed());
768
769 let mut row_off = TimeSeriesChunk::new("m", HashMap::new());
770 row_off.append(1, 1.0);
771 let off = AnalyticalStorageConfig {
772 columnar: false,
773 time_key: "ts".to_string(),
774 order_by_key: None,
775 };
776 assert!(matches!(
777 seal_chunk_with_config(&mut row_off, Some(&off), 1, 0).unwrap(),
778 SealedChunkStorage::Row
779 ));
780 }
781
782 #[test]
783 fn columnar_chunk_round_trips_through_durable_column_block_page() {
784 use crate::storage::engine::{PageLocation, PageType, Pager};
785
786 let mut chunk = TimeSeriesChunk::new("mem.used", make_tags("srv1"));
788 for i in 0..200u64 {
789 chunk.append(1_000_000 + i * 60_000, 72.5 + (i as f64) * 0.1);
790 }
791 let block = chunk.seal_columnar(99, 3).expect("seal columnar");
792
793 let path = std::env::temp_dir().join(format!(
795 "reddb-columnblock-{}-{}.rdb",
796 std::process::id(),
797 crate::utils::now_unix_nanos()
798 ));
799 let pager = Pager::open_default(&path).expect("open pager");
800 let mut page = pager
801 .allocate_page(PageType::ColumnBlock)
802 .expect("allocate column block page");
803 assert_eq!(page.page_type().unwrap(), PageType::ColumnBlock);
804 let page_id = page.page_id();
805 page.content_mut()[..block.len()].copy_from_slice(&block);
806 pager.write_page(page_id, page).expect("write page");
807
808 let loc = PageLocation::new(page_id, 0, block.len() as u32);
810
811 let read = pager.read_page(loc.page_id).expect("read page");
813 assert_eq!(read.page_type().unwrap(), PageType::ColumnBlock);
814 let start = loc.offset as usize;
815 let stored = &read.content()[start..start + loc.length as usize];
816 let points = points_from_column_block(stored).expect("decode page block");
817
818 assert_eq!(points, chunk.points());
820 let reconstructed = {
822 let mut c = TimeSeriesChunk::new("mem.used", make_tags("srv1"));
823 for p in &points {
824 c.append(p.timestamp_ns, p.value);
825 }
826 c.seal();
827 c
828 };
829 assert_eq!(
830 reconstructed.query_range(1_000_000, 1_000_000 + 50 * 60_000),
831 chunk.query_range(1_000_000, 1_000_000 + 50 * 60_000)
832 );
833
834 drop(pager);
835 let _ = std::fs::remove_file(&path);
836 }
837
838 #[test]
845 fn sealed_columnar_chunk_carries_granule_index_in_footer() {
846 let mut chunk = TimeSeriesChunk::with_max_points("cpu.idle", make_tags("srv1"), 1000);
847 for i in 0..1000u64 {
848 chunk.append(1_000 + i, 50.0 + (i % 7) as f64);
849 }
850 let block = chunk
851 .seal_columnar_with_granule_size(1, 0, 100)
852 .expect("seal columnar");
853
854 let decoded = read_column_block(&block).expect("decode");
855 for col in &decoded.columns {
856 let gi = col
857 .granule_index
858 .as_ref()
859 .expect("both numeric columns carry a granule index");
860 assert_eq!(gi.granule_size, 100);
861 assert_eq!(gi.granule_count(), 10); assert_eq!(gi.granules.len(), 10);
863 assert!(gi
865 .granules
866 .iter()
867 .all(|g| g.min.len() == 8 && g.max.len() == 8));
868 }
869 }
870
871 #[test]
874 fn range_query_prunes_non_matching_granules() {
875 let mut chunk = TimeSeriesChunk::with_max_points("mem.used", make_tags("srv1"), 1000);
876 for i in 0..1000u64 {
878 chunk.append(i * 10, i as f64);
879 }
880 let block = chunk
881 .seal_columnar_with_granule_size(1, 0, 100)
882 .expect("seal columnar");
883
884 let scan = query_column_block_range(&block, 2_050, 2_150).expect("scan");
887 assert_eq!(scan.granules_total, 10);
888 assert!(
889 scan.granules_scanned < scan.granules_total,
890 "pruning must skip granules: scanned {} of {}",
891 scan.granules_scanned,
892 scan.granules_total
893 );
894 assert_eq!(scan.granules_scanned, 1);
895 assert_eq!(scan.points.len(), 11);
897 assert!(scan
898 .points
899 .iter()
900 .all(|p| p.timestamp_ns >= 2_050 && p.timestamp_ns <= 2_150));
901 assert!(scan
903 .points
904 .windows(2)
905 .all(|w| w[0].timestamp_ns <= w[1].timestamp_ns));
906
907 let empty = query_column_block_range(&block, 100_000, 200_000).expect("scan");
909 assert_eq!(empty.granules_scanned, 0);
910 assert!(empty.points.is_empty());
911 }
912
913 fn sort_points(mut v: Vec<TimeSeriesPoint>) -> Vec<TimeSeriesPoint> {
917 v.sort_by(|a, b| {
918 a.timestamp_ns
919 .cmp(&b.timestamp_ns)
920 .then_with(|| a.value.to_bits().cmp(&b.value.to_bits()))
921 });
922 v
923 }
924
925 proptest! {
926 #![proptest_config(ProptestConfig::with_cases(256))]
927
928 #[test]
929 fn granule_pruning_never_drops_a_matching_row(
930 rows in prop::collection::vec(
931 (0u64..5_000, prop::num::f64::NORMAL),
932 0..400,
933 ),
934 granule_size in 1u32..50,
935 a in 0u64..5_000,
936 b in 0u64..5_000,
937 ) {
938 let (start, end) = (a.min(b), a.max(b));
939
940 let mut chunk = TimeSeriesChunk::with_max_points("m", HashMap::new(), 512);
941 for (ts, v) in &rows {
942 chunk.append(*ts, *v);
943 }
944 let block = chunk
945 .seal_columnar_with_granule_size(1, 0, granule_size)
946 .expect("seal columnar");
947
948 let scan = query_column_block_range(&block, start, end).expect("pruned scan");
949
950 let expected: Vec<TimeSeriesPoint> = points_from_column_block(&block)
953 .expect("full decode")
954 .into_iter()
955 .filter(|p| p.timestamp_ns >= start && p.timestamp_ns <= end)
956 .collect();
957
958 prop_assert_eq!(
959 sort_points(scan.points.clone()),
960 sort_points(expected),
961 "granule pruning dropped or invented a row for [{}, {}] @ g={}",
962 start, end, granule_size
963 );
964 prop_assert!(scan.granules_scanned <= scan.granules_total);
965 }
966 }
967
968 #[test]
969 fn value_eq_pruning_skips_granules_via_bloom() {
970 let mut chunk = TimeSeriesChunk::with_max_points("m", HashMap::new(), 512);
974 for i in 0..300u64 {
975 chunk.append(1_000 + i * 10, (i % 4) as f64 * 100.0);
976 }
977 let block = chunk
978 .seal_columnar_with_granule_size(1, 0, 50)
979 .expect("seal columnar");
980
981 let hit = query_column_block_value_eq(&block, 300.0).expect("scan");
985 assert_eq!(hit.granules_total, 6);
986 assert!(hit.points.iter().all(|p| p.value == 300.0));
987 assert_eq!(hit.points.len(), 300 / 4);
988
989 let miss = query_column_block_value_eq(&block, 12_345.0).expect("scan");
992 assert!(miss.points.is_empty());
993 assert!(
994 miss.granules_scanned <= miss.granules_total,
995 "scanned more granules than exist"
996 );
997 }
998
999 proptest! {
1000 #![proptest_config(ProptestConfig::with_cases(256))]
1001
1002 #[test]
1008 fn value_eq_pruning_never_drops_a_matching_row(
1009 rows in prop::collection::vec(
1010 (0u64..5_000, prop::sample::select(vec![0.0f64, 1.0, 2.0, 3.0, 4.0, 5.0])),
1011 0..400,
1012 ),
1013 granule_size in 1u32..50,
1014 target in prop::sample::select(vec![0.0f64, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
1015 ) {
1016 let mut chunk = TimeSeriesChunk::with_max_points("m", HashMap::new(), 512);
1017 for (ts, v) in &rows {
1018 chunk.append(*ts, *v);
1019 }
1020 let block = chunk
1021 .seal_columnar_with_granule_size(1, 0, granule_size)
1022 .expect("seal columnar");
1023
1024 let scan = query_column_block_value_eq(&block, target).expect("pruned scan");
1025
1026 let expected: Vec<TimeSeriesPoint> = points_from_column_block(&block)
1028 .expect("full decode")
1029 .into_iter()
1030 .filter(|p| p.value == target)
1031 .collect();
1032
1033 prop_assert_eq!(
1034 sort_points(scan.points.clone()),
1035 sort_points(expected),
1036 "bloom equality pruning dropped or invented a row for value {} @ g={}",
1037 target, granule_size
1038 );
1039 prop_assert!(scan.granules_scanned <= scan.granules_total);
1040 }
1041 }
1042
1043 #[test]
1044 fn test_chunk_compression_ratio() {
1045 let mut chunk = TimeSeriesChunk::new("regular", HashMap::new());
1046 for i in 0..100 {
1048 chunk.append(
1049 1_000_000_000 + i * 1_000_000_000,
1050 95.0 + (i % 3) as f64 * 0.1,
1051 );
1052 }
1053 chunk.seal();
1054
1055 let ratio = chunk.compression_ratio().unwrap();
1056 assert!(ratio > 0.0);
1058 assert!(ratio <= 1.0);
1059 }
1060}