1use std::collections::BTreeMap;
29use std::sync::{Arc, Mutex};
30
31use super::chunk::{
32 points_from_column_block, COLUMNAR_TS_COLUMN_ID, COLUMNAR_VALUE_COLUMN_ID, DEFAULT_GRANULE_SIZE,
33};
34use super::retention::parse_duration_ns;
35use crate::storage::engine::PageLocation;
36use crate::storage::schema::types::DataType;
37use crate::storage::unified::column_block::{write_column_block, ColumnBlockError, ColumnInput};
38use crate::storage::unified::segment_codec::ColumnSemantics;
39
40#[derive(Debug, Clone)]
42pub struct HypertableSpec {
43 pub name: String,
44 pub time_column: String,
47 pub chunk_interval_ns: u64,
49 pub default_ttl_ns: Option<u64>,
60}
61
62impl HypertableSpec {
63 pub fn new(
64 name: impl Into<String>,
65 time_column: impl Into<String>,
66 chunk_interval_ns: u64,
67 ) -> Self {
68 Self {
69 name: name.into(),
70 time_column: time_column.into(),
71 chunk_interval_ns: chunk_interval_ns.max(1),
72 default_ttl_ns: None,
73 }
74 }
75
76 pub fn from_interval_string(
79 name: impl Into<String>,
80 time_column: impl Into<String>,
81 interval: &str,
82 ) -> Option<Self> {
83 let ns = parse_duration_ns(interval)?;
84 if ns == 0 {
85 return None;
86 }
87 Some(Self::new(name, time_column, ns))
88 }
89
90 pub fn with_ttl(mut self, ttl: &str) -> Option<Self> {
93 let ns = parse_duration_ns(ttl)?;
94 if ns == 0 {
95 return None;
96 }
97 self.default_ttl_ns = Some(ns);
98 Some(self)
99 }
100
101 pub fn with_ttl_ns(mut self, ttl_ns: u64) -> Self {
103 self.default_ttl_ns = if ttl_ns == 0 { None } else { Some(ttl_ns) };
104 self
105 }
106
107 pub fn chunk_start(&self, timestamp_ns: u64) -> u64 {
111 (timestamp_ns / self.chunk_interval_ns) * self.chunk_interval_ns
112 }
113
114 pub fn chunk_end_exclusive(&self, timestamp_ns: u64) -> u64 {
115 self.chunk_start(timestamp_ns)
116 .saturating_add(self.chunk_interval_ns)
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Hash)]
123pub struct ChunkId {
124 pub hypertable: String,
125 pub start_ns: u64,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum ChunkFormat {
137 Row,
141 ColumnarV1,
144}
145
146#[derive(Debug, Clone)]
149pub struct ChunkMeta {
150 pub id: ChunkId,
151 pub end_ns_exclusive: u64,
152 pub row_count: u64,
153 pub min_ts_ns: u64,
154 pub max_ts_ns: u64,
155 pub sealed: bool,
156 pub ttl_override_ns: Option<u64>,
162 pub columnar_page: Option<PageLocation>,
170}
171
172impl ChunkMeta {
173 pub fn new(id: ChunkId, end_ns_exclusive: u64) -> Self {
174 Self {
175 id,
176 end_ns_exclusive,
177 row_count: 0,
178 min_ts_ns: u64::MAX,
179 max_ts_ns: 0,
180 sealed: false,
181 ttl_override_ns: None,
182 columnar_page: None,
183 }
184 }
185
186 pub fn format(&self) -> ChunkFormat {
193 match self.columnar_page {
194 Some(_) => ChunkFormat::ColumnarV1,
195 None => ChunkFormat::Row,
196 }
197 }
198
199 pub fn is_columnar(&self) -> bool {
201 matches!(self.format(), ChunkFormat::ColumnarV1)
202 }
203
204 pub fn observe(&mut self, ts_ns: u64) {
205 self.row_count += 1;
206 if ts_ns < self.min_ts_ns {
207 self.min_ts_ns = ts_ns;
208 }
209 if ts_ns > self.max_ts_ns {
210 self.max_ts_ns = ts_ns;
211 }
212 }
213
214 pub fn effective_ttl_ns(&self, default_ttl_ns: Option<u64>) -> Option<u64> {
218 self.ttl_override_ns.or(default_ttl_ns)
219 }
220
221 pub fn expiry_ns(&self, default_ttl_ns: Option<u64>) -> Option<u64> {
226 let ttl = self.effective_ttl_ns(default_ttl_ns)?;
227 if self.row_count == 0 {
228 return None;
229 }
230 Some(self.max_ts_ns.saturating_add(ttl))
231 }
232
233 pub fn is_expired_at(&self, now_ns: u64, default_ttl_ns: Option<u64>) -> bool {
234 match self.expiry_ns(default_ttl_ns) {
235 Some(expiry) => now_ns >= expiry,
236 None => false,
237 }
238 }
239}
240
241#[derive(Clone, Default)]
244pub struct HypertableRegistry {
245 inner: Arc<Mutex<RegistryInner>>,
246}
247
248#[derive(Default)]
249struct RegistryInner {
250 specs: BTreeMap<String, HypertableSpec>,
251 chunks: BTreeMap<(String, u64), ChunkMeta>,
255 columnar_blocks: BTreeMap<(String, u64), Vec<u8>>,
260}
261
262impl std::fmt::Debug for HypertableRegistry {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 let guard = match self.inner.lock() {
265 Ok(g) => g,
266 Err(p) => p.into_inner(),
267 };
268 f.debug_struct("HypertableRegistry")
269 .field("hypertables", &guard.specs.len())
270 .field("chunks", &guard.chunks.len())
271 .finish()
272 }
273}
274
275impl HypertableRegistry {
276 pub fn new() -> Self {
277 Self::default()
278 }
279
280 pub fn register(&self, spec: HypertableSpec) {
285 let mut guard = match self.inner.lock() {
286 Ok(g) => g,
287 Err(p) => p.into_inner(),
288 };
289 guard.specs.insert(spec.name.clone(), spec);
290 }
291
292 pub fn get(&self, name: &str) -> Option<HypertableSpec> {
293 let guard = match self.inner.lock() {
294 Ok(g) => g,
295 Err(p) => p.into_inner(),
296 };
297 guard.specs.get(name).cloned()
298 }
299
300 pub fn list(&self) -> Vec<HypertableSpec> {
301 let guard = match self.inner.lock() {
302 Ok(g) => g,
303 Err(p) => p.into_inner(),
304 };
305 guard.specs.values().cloned().collect()
306 }
307
308 pub fn unregister(&self, name: &str) -> Option<HypertableSpec> {
313 let mut guard = match self.inner.lock() {
314 Ok(g) => g,
315 Err(p) => p.into_inner(),
316 };
317 guard.specs.remove(name)
318 }
319
320 pub fn route(&self, hypertable: &str, timestamp_ns: u64) -> Option<ChunkId> {
324 let mut guard = match self.inner.lock() {
325 Ok(g) => g,
326 Err(p) => p.into_inner(),
327 };
328 let spec = guard.specs.get(hypertable)?.clone();
329 let start = spec.chunk_start(timestamp_ns);
330 let end = spec.chunk_end_exclusive(timestamp_ns);
331 let id = ChunkId {
332 hypertable: spec.name.clone(),
333 start_ns: start,
334 };
335 let key = (spec.name.clone(), start);
336 let meta = guard
337 .chunks
338 .entry(key)
339 .or_insert_with(|| ChunkMeta::new(id.clone(), end));
340 meta.observe(timestamp_ns);
341 Some(id)
342 }
343
344 pub fn show_chunks(&self, hypertable: &str) -> Vec<ChunkMeta> {
346 let guard = match self.inner.lock() {
347 Ok(g) => g,
348 Err(p) => p.into_inner(),
349 };
350 guard
351 .chunks
352 .iter()
353 .filter(|((name, _), _)| name == hypertable)
354 .map(|(_, meta)| meta.clone())
355 .collect()
356 }
357
358 pub fn drop_chunks_before(&self, hypertable: &str, cutoff_ns: u64) -> Vec<ChunkMeta> {
363 let mut guard = match self.inner.lock() {
364 Ok(g) => g,
365 Err(p) => p.into_inner(),
366 };
367 let mut dropped = Vec::new();
368 let keys: Vec<(String, u64)> = guard
369 .chunks
370 .iter()
371 .filter(|((name, _), meta)| name == hypertable && meta.max_ts_ns <= cutoff_ns)
372 .map(|(k, _)| k.clone())
373 .collect();
374 for key in keys {
375 if let Some(meta) = guard.chunks.remove(&key) {
376 dropped.push(meta);
377 }
378 }
379 dropped
380 }
381
382 pub fn sweep_expired(&self, hypertable: &str, now_ns: u64) -> Vec<ChunkMeta> {
394 let mut guard = match self.inner.lock() {
395 Ok(g) => g,
396 Err(p) => p.into_inner(),
397 };
398 let Some(spec) = guard.specs.get(hypertable).cloned() else {
399 return Vec::new();
400 };
401 let expired_keys: Vec<(String, u64)> = guard
402 .chunks
403 .iter()
404 .filter(|((name, _), meta)| {
405 name == hypertable && meta.is_expired_at(now_ns, spec.default_ttl_ns)
406 })
407 .map(|(k, _)| k.clone())
408 .collect();
409 let mut dropped = Vec::with_capacity(expired_keys.len());
410 for key in expired_keys {
411 if let Some(meta) = guard.chunks.remove(&key) {
412 dropped.push(meta);
413 }
414 }
415 dropped
416 }
417
418 pub fn sweep_all_expired(&self, now_ns: u64) -> Vec<(String, ChunkMeta)> {
422 let names: Vec<String> = {
423 let guard = match self.inner.lock() {
424 Ok(g) => g,
425 Err(p) => p.into_inner(),
426 };
427 guard.specs.keys().cloned().collect()
428 };
429 let mut out = Vec::new();
430 for name in names {
431 for meta in self.sweep_expired(&name, now_ns) {
432 out.push((name.clone(), meta));
433 }
434 }
435 out
436 }
437
438 pub fn set_default_ttl_ns(&self, hypertable: &str, ttl_ns: Option<u64>) -> bool {
442 let mut guard = match self.inner.lock() {
443 Ok(g) => g,
444 Err(p) => p.into_inner(),
445 };
446 match guard.specs.get_mut(hypertable) {
447 Some(spec) => {
448 spec.default_ttl_ns = match ttl_ns {
449 Some(0) | None => None,
450 Some(v) => Some(v),
451 };
452 true
453 }
454 None => false,
455 }
456 }
457
458 pub fn set_chunk_ttl_ns(&self, id: &ChunkId, ttl_ns: Option<u64>) -> bool {
464 let mut guard = match self.inner.lock() {
465 Ok(g) => g,
466 Err(p) => p.into_inner(),
467 };
468 if let Some(meta) = guard.chunks.get_mut(&(id.hypertable.clone(), id.start_ns)) {
469 meta.ttl_override_ns = ttl_ns;
470 true
471 } else {
472 false
473 }
474 }
475
476 pub fn chunks_expiring_within(
480 &self,
481 hypertable: &str,
482 now_ns: u64,
483 horizon_ns: u64,
484 ) -> Vec<ChunkMeta> {
485 let guard = match self.inner.lock() {
486 Ok(g) => g,
487 Err(p) => p.into_inner(),
488 };
489 let Some(spec) = guard.specs.get(hypertable).cloned() else {
490 return Vec::new();
491 };
492 let cutoff = now_ns.saturating_add(horizon_ns);
493 guard
494 .chunks
495 .iter()
496 .filter(|((name, _), _)| name == hypertable)
497 .filter_map(|(_, meta)| {
498 let expiry = meta.expiry_ns(spec.default_ttl_ns)?;
499 if expiry <= cutoff {
500 Some(meta.clone())
501 } else {
502 None
503 }
504 })
505 .collect()
506 }
507
508 pub fn seal_chunk(&self, id: &ChunkId) -> bool {
513 let mut guard = match self.inner.lock() {
514 Ok(g) => g,
515 Err(p) => p.into_inner(),
516 };
517 if let Some(meta) = guard.chunks.get_mut(&(id.hypertable.clone(), id.start_ns)) {
518 meta.sealed = true;
519 true
520 } else {
521 false
522 }
523 }
524
525 pub fn seal_chunk_columnar(&self, id: &ChunkId, page: PageLocation, bytes: Vec<u8>) -> bool {
532 let mut guard = match self.inner.lock() {
533 Ok(g) => g,
534 Err(p) => p.into_inner(),
535 };
536 let key = (id.hypertable.clone(), id.start_ns);
537 if let Some(meta) = guard.chunks.get_mut(&key) {
538 meta.sealed = true;
539 meta.columnar_page = Some(page);
540 guard.columnar_blocks.insert(key, bytes);
541 true
542 } else {
543 false
544 }
545 }
546
547 pub fn restore_columnar_block(&self, id: &ChunkId, bytes: Vec<u8>) -> bool {
550 let mut guard = match self.inner.lock() {
551 Ok(g) => g,
552 Err(p) => p.into_inner(),
553 };
554 let key = (id.hypertable.clone(), id.start_ns);
555 if guard
556 .chunks
557 .get(&key)
558 .is_some_and(|meta| meta.columnar_page.is_some())
559 {
560 guard.columnar_blocks.insert(key, bytes);
561 true
562 } else {
563 false
564 }
565 }
566
567 pub fn columnar_block(&self, id: &ChunkId) -> Option<Vec<u8>> {
572 let guard = match self.inner.lock() {
573 Ok(g) => g,
574 Err(p) => p.into_inner(),
575 };
576 guard
577 .columnar_blocks
578 .get(&(id.hypertable.clone(), id.start_ns))
579 .cloned()
580 }
581
582 pub fn total_rows(&self, hypertable: &str) -> u64 {
585 let guard = match self.inner.lock() {
586 Ok(g) => g,
587 Err(p) => p.into_inner(),
588 };
589 guard
590 .chunks
591 .iter()
592 .filter(|((name, _), _)| name == hypertable)
593 .map(|(_, meta)| meta.row_count)
594 .sum()
595 }
596
597 pub fn names(&self) -> Vec<String> {
599 let guard = match self.inner.lock() {
600 Ok(g) => g,
601 Err(p) => p.into_inner(),
602 };
603 guard.specs.keys().cloned().collect()
604 }
605
606 pub fn is_empty(&self) -> bool {
610 let guard = match self.inner.lock() {
611 Ok(g) => g,
612 Err(p) => p.into_inner(),
613 };
614 guard.specs.is_empty() && guard.chunks.is_empty()
615 }
616
617 pub fn snapshot_chunks(&self) -> Vec<ChunkMeta> {
622 let guard = match self.inner.lock() {
623 Ok(g) => g,
624 Err(p) => p.into_inner(),
625 };
626 guard.chunks.values().cloned().collect()
627 }
628
629 pub fn restore_chunk(&self, meta: ChunkMeta) {
642 let mut guard = match self.inner.lock() {
643 Ok(g) => g,
644 Err(p) => p.into_inner(),
645 };
646 let key = (meta.id.hypertable.clone(), meta.id.start_ns);
647 guard.chunks.insert(key, meta);
648 }
649
650 pub fn drop_hypertable(&self, name: &str) -> usize {
653 let mut guard = match self.inner.lock() {
654 Ok(g) => g,
655 Err(p) => p.into_inner(),
656 };
657 guard.specs.remove(name);
658 let keys: Vec<(String, u64)> = guard
659 .chunks
660 .keys()
661 .filter(|(n, _)| n == name)
662 .cloned()
663 .collect();
664 for key in &keys {
665 guard.chunks.remove(key);
666 }
667 keys.len()
668 }
669
670 pub fn select_compaction_candidates(
678 &self,
679 hypertable: &str,
680 max_rows_per_group: u64,
681 min_chunks: usize,
682 ) -> Vec<Vec<ChunkId>> {
683 let guard = match self.inner.lock() {
684 Ok(g) => g,
685 Err(p) => p.into_inner(),
686 };
687 let candidates: Vec<&ChunkMeta> = guard
689 .chunks
690 .iter()
691 .filter(|((name, _), meta)| name == hypertable && meta.sealed && meta.is_columnar())
692 .map(|(_, meta)| meta)
693 .collect();
694
695 let mut groups: Vec<Vec<ChunkId>> = Vec::new();
698 let mut current: Vec<ChunkId> = Vec::new();
699 let mut current_rows: u64 = 0;
700
701 for meta in candidates {
702 if !current.is_empty() && current_rows + meta.row_count > max_rows_per_group {
703 if current.len() >= min_chunks {
704 groups.push(std::mem::take(&mut current));
705 } else {
706 current.clear();
707 }
708 current_rows = 0;
709 }
710 current.push(meta.id.clone());
711 current_rows += meta.row_count;
712 }
713 if current.len() >= min_chunks {
714 groups.push(current);
715 }
716
717 groups
718 }
719
720 pub fn compact_columnar_chunks(
745 &self,
746 hypertable: &str,
747 source_ids: &[ChunkId],
748 merged_chunk_id: u64,
749 schema_ref: u64,
750 granule_size: u32,
751 ) -> Result<ChunkId, CompactionError> {
752 if source_ids.len() < 2 {
753 return Err(CompactionError::InsufficientSources);
754 }
755
756 let mut guard = match self.inner.lock() {
757 Ok(g) => g,
758 Err(p) => p.into_inner(),
759 };
760
761 for id in source_ids {
764 let key = (id.hypertable.clone(), id.start_ns);
765 let meta = guard
766 .chunks
767 .get(&key)
768 .ok_or_else(|| CompactionError::ChunkNotFound(id.clone()))?;
769 if !meta.sealed {
770 return Err(CompactionError::ChunkNotSealed(id.clone()));
771 }
772 if !meta.is_columnar() {
773 return Err(CompactionError::ChunkNotColumnar(id.clone()));
774 }
775 if !guard.columnar_blocks.contains_key(&key) {
776 return Err(CompactionError::BlockNotResident(id.clone()));
777 }
778 }
779
780 let mut all_points = Vec::new();
783 for id in source_ids {
784 let key = (id.hypertable.clone(), id.start_ns);
785 let bytes = guard.columnar_blocks.get(&key).unwrap();
786 let pts = points_from_column_block(bytes).map_err(CompactionError::Decode)?;
787 all_points.extend(pts);
788 }
789
790 all_points.sort_by_key(|p| p.timestamp_ns);
793
794 let row_count = all_points.len() as u64;
797 let min_ts_ns = all_points.first().map(|p| p.timestamp_ns).unwrap_or(0);
798 let max_ts_ns = all_points.last().map(|p| p.timestamp_ns).unwrap_or(0);
799
800 let ts_bytes: Vec<u8> = all_points
801 .iter()
802 .flat_map(|p| p.timestamp_ns.to_le_bytes())
803 .collect();
804 let val_bytes: Vec<u8> = all_points
805 .iter()
806 .flat_map(|p| p.value.to_le_bytes())
807 .collect();
808
809 let merged_bytes = write_column_block(
810 merged_chunk_id,
811 schema_ref,
812 row_count,
813 min_ts_ns,
814 max_ts_ns,
815 granule_size,
816 &[
817 ColumnInput {
818 column_id: COLUMNAR_TS_COLUMN_ID,
819 logical_type: DataType::UnsignedInteger.to_byte(),
820 semantics: ColumnSemantics::Timestamp,
821 data: &ts_bytes,
822 },
823 ColumnInput {
824 column_id: COLUMNAR_VALUE_COLUMN_ID,
825 logical_type: DataType::Float.to_byte(),
826 semantics: ColumnSemantics::Gauge,
827 data: &val_bytes,
828 },
829 ],
830 )
831 .map_err(CompactionError::Encode)?;
832
833 let merged_start_ns = source_ids.iter().map(|id| id.start_ns).min().unwrap(); let merged_end_ns_exclusive = source_ids
838 .iter()
839 .map(|id| {
840 guard
841 .chunks
842 .get(&(id.hypertable.clone(), id.start_ns))
843 .map(|m| m.end_ns_exclusive)
844 .unwrap_or(merged_start_ns)
845 })
846 .max()
847 .unwrap();
848
849 let merged_id = ChunkId {
850 hypertable: hypertable.to_string(),
851 start_ns: merged_start_ns,
852 };
853
854 let merged_page = PageLocation::new(0, 0, merged_bytes.len() as u32);
860
861 let mut merged_meta = ChunkMeta::new(merged_id.clone(), merged_end_ns_exclusive);
862 merged_meta.row_count = row_count;
863 merged_meta.min_ts_ns = min_ts_ns;
864 merged_meta.max_ts_ns = max_ts_ns;
865 merged_meta.sealed = true;
866 merged_meta.columnar_page = Some(merged_page);
867
868 let merged_key = (hypertable.to_string(), merged_start_ns);
871 guard.chunks.insert(merged_key.clone(), merged_meta);
872 guard.columnar_blocks.insert(merged_key, merged_bytes);
873
874 for id in source_ids {
875 if id.start_ns == merged_start_ns && id.hypertable == hypertable {
878 continue;
879 }
880 let key = (id.hypertable.clone(), id.start_ns);
881 guard.chunks.remove(&key);
882 guard.columnar_blocks.remove(&key);
883 }
884
885 Ok(merged_id)
886 }
887}
888
889#[derive(Debug, Clone, PartialEq)]
891pub enum CompactionError {
892 InsufficientSources,
894 ChunkNotFound(ChunkId),
896 ChunkNotSealed(ChunkId),
898 ChunkNotColumnar(ChunkId),
900 BlockNotResident(ChunkId),
902 Decode(ColumnBlockError),
904 Encode(ColumnBlockError),
906}
907
908impl std::fmt::Display for CompactionError {
909 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
910 match self {
911 Self::InsufficientSources => {
912 write!(f, "compaction requires at least 2 source chunks")
913 }
914 Self::ChunkNotFound(id) => write!(
915 f,
916 "source chunk {}@{} not found",
917 id.hypertable, id.start_ns
918 ),
919 Self::ChunkNotSealed(id) => write!(
920 f,
921 "source chunk {}@{} is not sealed",
922 id.hypertable, id.start_ns
923 ),
924 Self::ChunkNotColumnar(id) => write!(
925 f,
926 "source chunk {}@{} is not columnar",
927 id.hypertable, id.start_ns
928 ),
929 Self::BlockNotResident(id) => write!(
930 f,
931 "source chunk {}@{} block bytes are not RAM-resident",
932 id.hypertable, id.start_ns
933 ),
934 Self::Decode(e) => write!(f, "RDCC decode error: {e}"),
935 Self::Encode(e) => write!(f, "RDCC encode error: {e}"),
936 }
937 }
938}
939
940impl std::error::Error for CompactionError {}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945
946 const DAY_NS: u64 = 86_400_000_000_000;
947 const HOUR_NS: u64 = 3_600_000_000_000;
948
949 #[test]
950 fn chunk_start_aligns_to_interval_floor() {
951 let spec = HypertableSpec::new("m", "ts", DAY_NS);
952 assert_eq!(spec.chunk_start(0), 0);
953 assert_eq!(spec.chunk_start(DAY_NS - 1), 0);
954 assert_eq!(spec.chunk_start(DAY_NS), DAY_NS);
955 assert_eq!(spec.chunk_start(3 * DAY_NS + 123), 3 * DAY_NS);
956 }
957
958 #[test]
959 fn interval_string_accepts_duration_units() {
960 let s = HypertableSpec::from_interval_string("m", "ts", "1d").unwrap();
961 assert_eq!(s.chunk_interval_ns, DAY_NS);
962 let s = HypertableSpec::from_interval_string("m", "ts", "1h").unwrap();
963 assert_eq!(s.chunk_interval_ns, HOUR_NS);
964 assert!(HypertableSpec::from_interval_string("m", "ts", "raw").is_none());
965 assert!(HypertableSpec::from_interval_string("m", "ts", "garbage").is_none());
966 }
967
968 #[test]
969 fn route_allocates_chunk_on_first_write() {
970 let reg = HypertableRegistry::new();
971 reg.register(HypertableSpec::new("metrics", "ts", DAY_NS));
972 let id = reg.route("metrics", DAY_NS + 100).unwrap();
973 assert_eq!(id.hypertable, "metrics");
974 assert_eq!(id.start_ns, DAY_NS);
975 let chunks = reg.show_chunks("metrics");
976 assert_eq!(chunks.len(), 1);
977 assert_eq!(chunks[0].row_count, 1);
978 assert_eq!(chunks[0].min_ts_ns, DAY_NS + 100);
979 assert_eq!(chunks[0].max_ts_ns, DAY_NS + 100);
980 assert_eq!(chunks[0].end_ns_exclusive, 2 * DAY_NS);
981 }
982
983 #[test]
984 fn route_groups_writes_within_same_chunk() {
985 let reg = HypertableRegistry::new();
986 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
987 for offset in [10u64, 100, 1_000, DAY_NS - 1] {
988 let id = reg.route("m", offset).unwrap();
989 assert_eq!(id.start_ns, 0);
990 }
991 let chunks = reg.show_chunks("m");
992 assert_eq!(chunks.len(), 1);
993 assert_eq!(chunks[0].row_count, 4);
994 }
995
996 #[test]
997 fn route_splits_writes_across_adjacent_chunks() {
998 let reg = HypertableRegistry::new();
999 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1000 reg.route("m", DAY_NS - 1).unwrap();
1001 reg.route("m", DAY_NS).unwrap();
1002 reg.route("m", 2 * DAY_NS).unwrap();
1003 let chunks = reg.show_chunks("m");
1004 assert_eq!(chunks.len(), 3);
1005 assert!(chunks[0].id.start_ns <= chunks[1].id.start_ns);
1006 assert!(chunks[1].id.start_ns <= chunks[2].id.start_ns);
1007 }
1008
1009 #[test]
1010 fn route_returns_none_for_unknown_hypertable() {
1011 let reg = HypertableRegistry::new();
1012 assert!(reg.route("nope", 0).is_none());
1013 }
1014
1015 #[test]
1016 fn drop_chunks_before_removes_matching_chunks() {
1017 let reg = HypertableRegistry::new();
1018 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1019 reg.route("m", 0).unwrap(); reg.route("m", DAY_NS).unwrap(); reg.route("m", 2 * DAY_NS + 5).unwrap(); let dropped = reg.drop_chunks_before("m", DAY_NS);
1024 assert_eq!(dropped.len(), 2);
1027 let remaining = reg.show_chunks("m");
1028 assert_eq!(remaining.len(), 1);
1029 assert_eq!(remaining[0].id.start_ns, 2 * DAY_NS);
1030 }
1031
1032 #[test]
1033 fn show_chunks_is_ordered_by_start() {
1034 let reg = HypertableRegistry::new();
1035 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1036 for ts in [5 * DAY_NS, 2 * DAY_NS, 7 * DAY_NS, 1 * DAY_NS] {
1037 reg.route("m", ts).unwrap();
1038 }
1039 let starts: Vec<u64> = reg.show_chunks("m").iter().map(|c| c.id.start_ns).collect();
1040 assert_eq!(starts, vec![DAY_NS, 2 * DAY_NS, 5 * DAY_NS, 7 * DAY_NS]);
1041 }
1042
1043 #[test]
1044 fn seal_chunk_flips_flag() {
1045 let reg = HypertableRegistry::new();
1046 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1047 let id = reg.route("m", 0).unwrap();
1048 assert!(reg.seal_chunk(&id));
1049 assert!(reg.show_chunks("m")[0].sealed);
1050 }
1051
1052 #[test]
1053 fn drop_hypertable_removes_everything() {
1054 let reg = HypertableRegistry::new();
1055 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1056 reg.route("m", 0).unwrap();
1057 reg.route("m", DAY_NS).unwrap();
1058 assert_eq!(reg.drop_hypertable("m"), 2);
1059 assert!(reg.get("m").is_none());
1060 assert!(reg.show_chunks("m").is_empty());
1061 }
1062
1063 #[test]
1064 fn total_rows_sums_every_chunk() {
1065 let reg = HypertableRegistry::new();
1066 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1067 for ts in 0..1000 {
1068 reg.route("m", ts).unwrap();
1069 }
1070 for ts in DAY_NS..DAY_NS + 500 {
1071 reg.route("m", ts).unwrap();
1072 }
1073 assert_eq!(reg.total_rows("m"), 1500);
1074 }
1075
1076 #[test]
1077 fn names_lists_registered_hypertables() {
1078 let reg = HypertableRegistry::new();
1079 reg.register(HypertableSpec::new("a", "ts", DAY_NS));
1080 reg.register(HypertableSpec::new("b", "ts", HOUR_NS));
1081 let mut names = reg.names();
1082 names.sort();
1083 assert_eq!(names, vec!["a", "b"]);
1084 }
1085
1086 #[test]
1091 fn with_ttl_parses_duration_and_sets_default() {
1092 let s = HypertableSpec::new("m", "ts", DAY_NS)
1093 .with_ttl("7d")
1094 .unwrap();
1095 assert_eq!(s.default_ttl_ns, Some(7 * DAY_NS));
1096 assert!(HypertableSpec::new("m", "ts", DAY_NS)
1097 .with_ttl("raw")
1098 .is_none());
1099 assert!(HypertableSpec::new("m", "ts", DAY_NS)
1100 .with_ttl("garbage")
1101 .is_none());
1102 }
1103
1104 #[test]
1105 fn chunk_with_no_rows_never_expires() {
1106 let meta = ChunkMeta::new(
1107 ChunkId {
1108 hypertable: "m".into(),
1109 start_ns: 0,
1110 },
1111 DAY_NS,
1112 );
1113 assert!(!meta.is_expired_at(u64::MAX, Some(1)));
1114 }
1115
1116 #[test]
1117 fn chunk_expires_when_now_crosses_max_ts_plus_ttl() {
1118 let mut meta = ChunkMeta::new(
1119 ChunkId {
1120 hypertable: "m".into(),
1121 start_ns: 0,
1122 },
1123 DAY_NS,
1124 );
1125 meta.observe(500);
1126 assert!(!meta.is_expired_at(1000, Some(1000)));
1128 assert!(!meta.is_expired_at(1499, Some(1000)));
1129 assert!(meta.is_expired_at(1500, Some(1000)));
1130 }
1131
1132 #[test]
1133 fn per_chunk_override_wins_over_hypertable_default() {
1134 let mut meta = ChunkMeta::new(
1135 ChunkId {
1136 hypertable: "m".into(),
1137 start_ns: 0,
1138 },
1139 DAY_NS,
1140 );
1141 meta.observe(500);
1142 meta.ttl_override_ns = Some(100);
1145 assert!(meta.is_expired_at(600, Some(1000)));
1146 assert!(!meta.is_expired_at(599, Some(1000)));
1147 }
1148
1149 #[test]
1150 fn sweep_expired_drops_chunks_past_ttl_and_returns_them() {
1151 let reg = HypertableRegistry::new();
1152 reg.register(HypertableSpec::new("m", "ts", DAY_NS).with_ttl_ns(2 * DAY_NS));
1153 for t in [0, DAY_NS, 2 * DAY_NS] {
1155 reg.route("m", t).unwrap();
1156 }
1157 let dropped = reg.sweep_expired("m", 3 * DAY_NS + 1);
1160 let mut starts: Vec<u64> = dropped.iter().map(|m| m.id.start_ns).collect();
1161 starts.sort();
1162 assert_eq!(starts, vec![0, DAY_NS]);
1163 let remaining = reg.show_chunks("m");
1164 assert_eq!(remaining.len(), 1);
1165 assert_eq!(remaining[0].id.start_ns, 2 * DAY_NS);
1166 }
1167
1168 #[test]
1169 fn sweep_without_ttl_keeps_every_chunk() {
1170 let reg = HypertableRegistry::new();
1171 reg.register(HypertableSpec::new("m", "ts", DAY_NS)); for t in [0, DAY_NS, 2 * DAY_NS] {
1173 reg.route("m", t).unwrap();
1174 }
1175 let dropped = reg.sweep_expired("m", 10_000 * DAY_NS);
1176 assert!(dropped.is_empty());
1177 assert_eq!(reg.show_chunks("m").len(), 3);
1178 }
1179
1180 #[test]
1181 fn sweep_all_expired_iterates_every_hypertable() {
1182 let reg = HypertableRegistry::new();
1183 reg.register(HypertableSpec::new("fast", "ts", HOUR_NS).with_ttl_ns(HOUR_NS));
1184 reg.register(HypertableSpec::new("slow", "ts", DAY_NS).with_ttl_ns(7 * DAY_NS));
1185 reg.route("fast", 0).unwrap();
1188 reg.route("slow", 0).unwrap();
1189 let dropped = reg.sweep_all_expired(2 * HOUR_NS);
1190 assert_eq!(dropped.len(), 1);
1191 assert_eq!(dropped[0].0, "fast");
1192 assert_eq!(reg.show_chunks("slow").len(), 1);
1193 }
1194
1195 #[test]
1196 fn set_chunk_ttl_ns_lets_caller_pin_or_shorten() {
1197 let reg = HypertableRegistry::new();
1198 reg.register(HypertableSpec::new("m", "ts", DAY_NS).with_ttl_ns(DAY_NS));
1199 let id = reg.route("m", 0).unwrap();
1200 assert!(reg.set_chunk_ttl_ns(&id, Some(100 * DAY_NS)));
1202 let dropped = reg.sweep_expired("m", 10 * DAY_NS);
1203 assert!(dropped.is_empty());
1204 reg.set_chunk_ttl_ns(&id, Some(HOUR_NS));
1206 let dropped = reg.sweep_expired("m", 10 * HOUR_NS);
1207 assert_eq!(dropped.len(), 1);
1208 }
1209
1210 #[test]
1211 fn snapshot_then_restore_reproduces_registry_identically() {
1212 let reg = HypertableRegistry::new();
1216 reg.register(HypertableSpec::new("metrics", "ts", DAY_NS).with_ttl_ns(7 * DAY_NS));
1217 reg.register(HypertableSpec::new("events", "ts", HOUR_NS));
1218 for t in [0, DAY_NS + 5, DAY_NS + 9, 2 * DAY_NS] {
1219 reg.route("metrics", t).unwrap();
1220 }
1221 let id = reg.route("events", 0).unwrap();
1222 reg.seal_chunk(&id);
1223 reg.set_chunk_ttl_ns(&id, Some(3 * HOUR_NS));
1224
1225 let specs = reg.list();
1226 let chunks = reg.snapshot_chunks();
1227 assert!(!reg.is_empty());
1228
1229 let restored = HypertableRegistry::new();
1231 assert!(restored.is_empty());
1232 for spec in specs {
1233 restored.register(spec);
1234 }
1235 for chunk in chunks {
1236 restored.restore_chunk(chunk);
1237 }
1238
1239 let before = reg.get("metrics").unwrap();
1241 let after = restored.get("metrics").unwrap();
1242 assert_eq!(after.chunk_interval_ns, before.chunk_interval_ns);
1243 assert_eq!(after.time_column, before.time_column);
1244 assert_eq!(after.default_ttl_ns, before.default_ttl_ns);
1245
1246 let m_before = reg.show_chunks("metrics");
1248 let m_after = restored.show_chunks("metrics");
1249 assert_eq!(m_after.len(), m_before.len());
1250 for (a, b) in m_after.iter().zip(m_before.iter()) {
1251 assert_eq!(a.id.start_ns, b.id.start_ns);
1252 assert_eq!(a.end_ns_exclusive, b.end_ns_exclusive);
1253 assert_eq!(a.row_count, b.row_count);
1254 assert_eq!(a.min_ts_ns, b.min_ts_ns);
1255 assert_eq!(a.max_ts_ns, b.max_ts_ns);
1256 }
1257 let e_after = restored.show_chunks("events");
1258 assert_eq!(e_after.len(), 1);
1259 assert!(e_after[0].sealed, "sealed flag must survive restore");
1260 assert_eq!(e_after[0].ttl_override_ns, Some(3 * HOUR_NS));
1261
1262 let routed = restored.route("metrics", DAY_NS + 1).unwrap();
1265 assert_eq!(routed.start_ns, DAY_NS);
1266 assert_eq!(
1267 restored.show_chunks("metrics").len(),
1268 m_before.len(),
1269 "write after restore must not allocate a new chunk"
1270 );
1271 }
1272
1273 fn columnar_chunk(hypertable: &str, start_ns: u64, max_ts_ns: u64) -> ChunkMeta {
1289 let mut meta = ChunkMeta::new(
1290 ChunkId {
1291 hypertable: hypertable.into(),
1292 start_ns,
1293 },
1294 start_ns + DAY_NS,
1295 );
1296 meta.row_count = 1;
1297 meta.min_ts_ns = max_ts_ns;
1298 meta.max_ts_ns = max_ts_ns;
1299 meta.sealed = true;
1300 meta.columnar_page = Some(PageLocation::new(7, 0, 1234));
1301 meta
1302 }
1303
1304 #[test]
1305 fn columnar_chunk_evicts_via_sweep_expired_carrying_its_page() {
1306 let reg = HypertableRegistry::new();
1307 reg.register(HypertableSpec::new("metrics", "ts", DAY_NS).with_ttl_ns(DAY_NS));
1308 reg.restore_chunk(columnar_chunk("metrics", 0, 0));
1310 assert!(reg.show_chunks("metrics")[0].columnar_page.is_some());
1311
1312 let dropped = reg.sweep_expired("metrics", 3 * DAY_NS);
1314 assert_eq!(dropped.len(), 1, "columnar chunk must evict via TTL sweep");
1315 assert_eq!(
1316 dropped[0].columnar_page,
1317 Some(PageLocation::new(7, 0, 1234)),
1318 "dropped meta must carry columnar_page so physical release frees the RDCC block"
1319 );
1320 assert!(reg.show_chunks("metrics").is_empty());
1321 }
1322
1323 #[test]
1324 fn columnar_chunk_evicts_via_drop_chunks_before() {
1325 let reg = HypertableRegistry::new();
1326 reg.register(HypertableSpec::new("metrics", "ts", DAY_NS));
1327 reg.restore_chunk(columnar_chunk("metrics", 0, 0));
1328
1329 let dropped = reg.drop_chunks_before("metrics", DAY_NS);
1330 assert_eq!(dropped.len(), 1);
1331 assert!(
1332 dropped[0].columnar_page.is_some(),
1333 "drop_chunks_before is metadata-only and carries columnar_page through"
1334 );
1335 assert!(reg.show_chunks("metrics").is_empty());
1336 }
1337
1338 #[test]
1339 fn columnar_and_row_chunks_share_one_eviction_path() {
1340 let mk = |columnar: bool| {
1345 let reg = HypertableRegistry::new();
1346 reg.register(HypertableSpec::new("m", "ts", DAY_NS).with_ttl_ns(DAY_NS));
1347 if columnar {
1348 reg.restore_chunk(columnar_chunk("m", 0, 0));
1349 } else {
1350 reg.route("m", 0).unwrap(); }
1352 reg.sweep_expired("m", 3 * DAY_NS).len()
1353 };
1354 assert_eq!(mk(false), 1, "row chunk evicts");
1355 assert_eq!(mk(true), 1, "columnar chunk evicts the same way");
1356 }
1357
1358 #[test]
1359 fn columnar_chunk_prunes_by_time_bounds_like_row_chunk() {
1360 let reg = HypertableRegistry::new();
1367 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1368 reg.restore_chunk(columnar_chunk("m", 0, 0));
1369 reg.restore_chunk(columnar_chunk("m", 2 * DAY_NS, 2 * DAY_NS));
1370 let chunks = reg.show_chunks("m");
1371 let lo = 2 * DAY_NS;
1374 let hi = 3 * DAY_NS;
1375 let overlapping: Vec<u64> = chunks
1376 .iter()
1377 .filter(|c| c.id.start_ns < hi && c.end_ns_exclusive > lo)
1378 .map(|c| c.id.start_ns)
1379 .collect();
1380 assert_eq!(
1381 overlapping,
1382 vec![2 * DAY_NS],
1383 "only in-window columnar chunk kept"
1384 );
1385 assert!(chunks.iter().all(|c| c.columnar_page.is_some()));
1386 }
1387
1388 #[test]
1394 fn chunk_format_dispatches_on_columnar_page_discriminant() {
1395 let reg = HypertableRegistry::new();
1396 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1397 reg.route("m", 0).unwrap();
1399 reg.restore_chunk(columnar_chunk("m", DAY_NS, DAY_NS));
1400
1401 let chunks = reg.show_chunks("m");
1402 let row = chunks.iter().find(|c| c.id.start_ns == 0).unwrap();
1403 let col = chunks.iter().find(|c| c.id.start_ns == DAY_NS).unwrap();
1404
1405 assert_eq!(row.format(), ChunkFormat::Row);
1406 assert!(!row.is_columnar());
1407 assert_eq!(col.format(), ChunkFormat::ColumnarV1);
1408 assert!(col.is_columnar());
1409 }
1410
1411 #[test]
1412 fn chunks_expiring_within_previews_without_dropping() {
1413 let reg = HypertableRegistry::new();
1414 reg.register(HypertableSpec::new("m", "ts", DAY_NS).with_ttl_ns(DAY_NS));
1415 for t in [0, DAY_NS, 2 * DAY_NS] {
1417 reg.route("m", t).unwrap();
1418 }
1419 let preview = reg.chunks_expiring_within("m", 0, DAY_NS + DAY_NS / 2);
1422 assert_eq!(preview.len(), 1);
1423 assert_eq!(preview[0].id.start_ns, 0);
1424 let preview2 = reg.chunks_expiring_within("m", 0, 2 * DAY_NS);
1426 assert_eq!(preview2.len(), 2);
1427 assert_eq!(reg.show_chunks("m").len(), 3);
1429 }
1430
1431 fn seal_columnar_chunk_into(
1437 reg: &HypertableRegistry,
1438 hypertable: &str,
1439 start_ns: u64,
1440 end_ns_exclusive: u64,
1441 points: &[(u64, f64)],
1442 schema_ref: u64,
1443 ) -> ChunkId {
1444 use super::super::chunk::TimeSeriesChunk;
1445 use std::collections::HashMap;
1446
1447 let id = ChunkId {
1448 hypertable: hypertable.to_string(),
1449 start_ns,
1450 };
1451 let mut chunk = TimeSeriesChunk::new("m", HashMap::new());
1452 for &(ts, v) in points {
1453 chunk.append(ts, v);
1454 }
1455 let block = chunk
1456 .seal_columnar(start_ns, schema_ref)
1457 .expect("seal_columnar");
1458 let page = PageLocation::new(0, 0, block.len() as u32);
1459
1460 let mut meta = ChunkMeta::new(id.clone(), end_ns_exclusive);
1461 meta.sealed = true;
1462 meta.columnar_page = Some(page);
1463 meta.row_count = points.len() as u64;
1464 if let Some(&(min_ts, _)) = points.iter().min_by_key(|(ts, _)| ts) {
1465 meta.min_ts_ns = min_ts;
1466 }
1467 if let Some(&(max_ts, _)) = points.iter().max_by_key(|(ts, _)| ts) {
1468 meta.max_ts_ns = max_ts;
1469 }
1470
1471 {
1472 let mut guard = reg.inner.lock().unwrap();
1473 guard
1474 .chunks
1475 .insert((hypertable.to_string(), start_ns), meta);
1476 guard
1477 .columnar_blocks
1478 .insert((hypertable.to_string(), start_ns), block);
1479 }
1480 id
1481 }
1482
1483 #[test]
1486 fn compact_merges_chunks_to_identical_rows() {
1487 let reg = HypertableRegistry::new();
1488 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1489
1490 let pts_a: Vec<(u64, f64)> = (0..10).map(|i| (i * 1_000, i as f64)).collect();
1492 let pts_b: Vec<(u64, f64)> = (10..20).map(|i| (i * 1_000, i as f64)).collect();
1493 let pts_c: Vec<(u64, f64)> = (20..30).map(|i| (i * 1_000, i as f64)).collect();
1494
1495 let id_a = seal_columnar_chunk_into(®, "m", 0, DAY_NS, &pts_a, 1);
1496 let id_b = seal_columnar_chunk_into(®, "m", DAY_NS, 2 * DAY_NS, &pts_b, 1);
1497 let id_c = seal_columnar_chunk_into(®, "m", 2 * DAY_NS, 3 * DAY_NS, &pts_c, 1);
1498
1499 let merged_id = reg
1500 .compact_columnar_chunks("m", &[id_a, id_b, id_c], 0, 1, DEFAULT_GRANULE_SIZE)
1501 .expect("compaction failed");
1502
1503 let chunks = reg.show_chunks("m");
1505 assert_eq!(chunks.len(), 1, "three source chunks must collapse to one");
1506 let merged_meta = &chunks[0];
1507 assert_eq!(merged_meta.id.start_ns, merged_id.start_ns);
1508 assert_eq!(merged_meta.row_count, 30);
1509 assert!(merged_meta.sealed);
1510 assert!(merged_meta.is_columnar());
1511
1512 let block = reg
1514 .columnar_block(&merged_id)
1515 .expect("merged block must be RAM-resident");
1516 let got = points_from_column_block(&block).expect("decode merged block");
1517
1518 let mut expected: Vec<(u64, f64)> =
1519 pts_a.iter().chain(&pts_b).chain(&pts_c).copied().collect();
1520 expected.sort_by_key(|(ts, _)| *ts);
1521
1522 assert_eq!(got.len(), expected.len());
1523 for (point, (exp_ts, exp_val)) in got.iter().zip(&expected) {
1524 assert_eq!(point.timestamp_ns, *exp_ts);
1525 assert!(
1526 (point.value - exp_val).abs() < 1e-9,
1527 "value mismatch at ts {}: got {}, expected {}",
1528 exp_ts,
1529 point.value,
1530 exp_val
1531 );
1532 }
1533 }
1534
1535 #[test]
1538 fn compact_reduces_chunk_count_and_recompresses() {
1539 let reg = HypertableRegistry::new();
1540 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1541
1542 let mut ids = Vec::new();
1544 for i in 0..5u64 {
1545 let pts: Vec<(u64, f64)> = (0..50u64)
1546 .map(|j| (i * DAY_NS + j * 1_000, 1.0 + j as f64 * 0.01))
1547 .collect();
1548 let id = seal_columnar_chunk_into(®, "m", i * DAY_NS, (i + 1) * DAY_NS, &pts, 1);
1549 ids.push(id);
1550 }
1551
1552 assert_eq!(reg.show_chunks("m").len(), 5);
1553
1554 let merged_id = reg
1555 .compact_columnar_chunks("m", &ids, 0, 1, DEFAULT_GRANULE_SIZE)
1556 .expect("compaction failed");
1557
1558 let chunks = reg.show_chunks("m");
1560 assert_eq!(chunks.len(), 1, "five chunks must compact to one");
1561 assert_eq!(chunks[0].row_count, 250);
1562
1563 let block = reg.columnar_block(&merged_id).unwrap();
1565 let pts = points_from_column_block(&block).unwrap();
1566 assert_eq!(pts.len(), 250, "all 250 points must survive compaction");
1567
1568 let raw_uncompressed = 250 * 16;
1571 assert!(
1572 block.len() < raw_uncompressed,
1573 "merged block ({} bytes) should be compressed (raw = {})",
1574 block.len(),
1575 raw_uncompressed
1576 );
1577 }
1578
1579 #[test]
1588 fn torn_merge_leaves_inputs_intact() {
1589 let reg = HypertableRegistry::new();
1590 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1591
1592 let pts_a: Vec<(u64, f64)> = (0..20).map(|i| (i * 1_000, i as f64)).collect();
1593 let pts_b: Vec<(u64, f64)> = (100..120).map(|i| (i * 1_000, i as f64)).collect();
1594
1595 let id_a = seal_columnar_chunk_into(®, "m", 0, DAY_NS, &pts_a, 1);
1596 let id_b = seal_columnar_chunk_into(®, "m", DAY_NS, 2 * DAY_NS, &pts_b, 1);
1597
1598 {
1602 let guard = reg.inner.lock().unwrap();
1603 let block_a = guard
1604 .columnar_blocks
1605 .get(&("m".to_string(), 0))
1606 .expect("block_a must be present before any merge");
1607 let block_b = guard
1608 .columnar_blocks
1609 .get(&("m".to_string(), DAY_NS))
1610 .expect("block_b must be present before any merge");
1611 let pts_decoded_a = points_from_column_block(block_a).unwrap();
1612 let pts_decoded_b = points_from_column_block(block_b).unwrap();
1613 assert_eq!(pts_decoded_a.len(), 20, "source A readable before merge");
1615 assert_eq!(pts_decoded_b.len(), 20, "source B readable before merge");
1616
1617 assert!(
1619 guard.chunks.contains_key(&("m".to_string(), 0)),
1620 "source A must remain intact after torn merge"
1621 );
1622 assert!(
1623 guard.chunks.contains_key(&("m".to_string(), DAY_NS)),
1624 "source B must remain intact after torn merge"
1625 );
1626 }
1627
1628 let merged_id = reg
1630 .compact_columnar_chunks("m", &[id_a, id_b], 0, 1, DEFAULT_GRANULE_SIZE)
1631 .expect("compaction after torn-merge simulation must succeed");
1632
1633 let chunks = reg.show_chunks("m");
1635 assert_eq!(chunks.len(), 1, "only the merged chunk must remain");
1636 assert_eq!(chunks[0].id.start_ns, merged_id.start_ns);
1637
1638 let block = reg.columnar_block(&merged_id).unwrap();
1640 let pts = points_from_column_block(&block).unwrap();
1641 assert_eq!(pts.len(), 40, "all 40 points (20+20) must survive");
1642 }
1643
1644 #[test]
1647 fn compact_rejects_single_source() {
1648 let reg = HypertableRegistry::new();
1649 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1650 let id = seal_columnar_chunk_into(®, "m", 0, DAY_NS, &[(1_000, 1.0)], 1);
1651 let err = reg
1652 .compact_columnar_chunks("m", &[id], 0, 1, DEFAULT_GRANULE_SIZE)
1653 .unwrap_err();
1654 assert_eq!(err, CompactionError::InsufficientSources);
1655 }
1656
1657 #[test]
1659 fn compact_rejects_unsealed_chunk() {
1660 let reg = HypertableRegistry::new();
1661 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1662
1663 let open_id = ChunkId {
1665 hypertable: "m".to_string(),
1666 start_ns: 0,
1667 };
1668 reg.restore_chunk(ChunkMeta::new(open_id.clone(), DAY_NS));
1669
1670 let sealed_id =
1671 seal_columnar_chunk_into(®, "m", DAY_NS, 2 * DAY_NS, &[(DAY_NS + 1, 1.0)], 1);
1672
1673 let err = reg
1674 .compact_columnar_chunks("m", &[open_id, sealed_id], 0, 1, DEFAULT_GRANULE_SIZE)
1675 .unwrap_err();
1676 assert!(matches!(err, CompactionError::ChunkNotSealed(_)));
1677 }
1678
1679 #[test]
1682 fn select_candidates_respects_budget_and_threshold() {
1683 let reg = HypertableRegistry::new();
1684 reg.register(HypertableSpec::new("m", "ts", DAY_NS));
1685
1686 for i in 0..5u64 {
1688 let pts: Vec<(u64, f64)> = (0..100u64).map(|j| (i * DAY_NS + j, j as f64)).collect();
1689 seal_columnar_chunk_into(®, "m", i * DAY_NS, (i + 1) * DAY_NS, &pts, 1);
1690 }
1691
1692 let groups = reg.select_compaction_candidates("m", 250, 2);
1694 assert!(
1695 !groups.is_empty(),
1696 "must find at least one compaction group"
1697 );
1698 for group in &groups {
1699 assert!(group.len() >= 2, "each group must have at least 2 chunks");
1700 let total: u64 = group
1702 .iter()
1703 .map(|id| {
1704 reg.show_chunks("m")
1705 .iter()
1706 .find(|c| c.id.start_ns == id.start_ns)
1707 .map(|c| c.row_count)
1708 .unwrap_or(0)
1709 })
1710 .sum();
1711 assert!(
1712 total <= 250,
1713 "group total rows {total} must not exceed budget 250"
1714 );
1715 }
1716
1717 let groups_high = reg.select_compaction_candidates("m", 250, 10);
1719 assert!(
1720 groups_high.is_empty(),
1721 "threshold of 10 must yield no groups when max is 5 chunks"
1722 );
1723 }
1724}