Skip to main content

mito2/
sst.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Sorted strings tables.
16
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use api::v1::SemanticType;
21use arrow_schema::DataType;
22use arrow_schema::extension::{EXTENSION_TYPE_NAME_KEY, ExtensionType};
23use common_base::readable_size::ReadableSize;
24use common_query::native_histogram::{
25    is_native_histogram_value_type, native_histogram_list_element_id, native_histogram_subfield_id,
26};
27use datatypes::arrow::datatypes::{
28    DataType as ArrowDataType, Field, FieldRef, Fields, Schema, SchemaRef,
29};
30use datatypes::arrow::record_batch::RecordBatch;
31use datatypes::extension::histogram::HistogramExtensionType;
32use datatypes::prelude::ConcreteDataType;
33use datatypes::timestamp::timestamp_array_to_primitive;
34use serde::{Deserialize, Serialize};
35use store_api::codec::PrimaryKeyEncoding;
36use store_api::metadata::RegionMetadata;
37use store_api::storage::consts::{
38    OP_TYPE_COLUMN_NAME, PRIMARY_KEY_COLUMN_NAME, SEQUENCE_COLUMN_NAME,
39};
40
41use crate::error::{InvalidNativeHistogramFieldIdSnafu, InvalidNativeHistogramSubfieldSnafu};
42use crate::sst::parquet::flat_format::time_index_column_index;
43
44pub mod file;
45pub mod file_purger;
46pub mod file_ref;
47pub mod index;
48pub mod location;
49pub mod parquet;
50pub(crate) mod version;
51
52/// Default write buffer size, it should be greater than the default minimum upload part of S3 (5mb).
53pub const DEFAULT_WRITE_BUFFER_SIZE: ReadableSize = ReadableSize::mb(8);
54
55/// Default number of concurrent write, it only works on object store backend(e.g., S3).
56pub const DEFAULT_WRITE_CONCURRENCY: usize = 8;
57
58/// Format type of the SST file.
59#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::EnumString)]
60#[serde(rename_all = "snake_case")]
61#[strum(serialize_all = "snake_case")]
62pub enum FormatType {
63    /// Parquet with primary key encoded.
64    #[default]
65    PrimaryKey,
66    /// Flat Parquet format.
67    Flat,
68}
69
70/// Iceberg-compatible column field ID key stored in Parquet column metadata.
71pub const PARQUET_FIELD_ID_KEY: &str = "PARQUET:field_id";
72
73/// Adds `PARQUET:field_id` metadata to a top-level Arrow field.
74///
75/// Native-histogram sub-field ids are stamped separately at parquet-write
76/// time by [`stamp_native_histogram_subfield_ids`], not here.
77pub fn with_field_id(mut field: Field, column_id: u32) -> Field {
78    field
79        .metadata_mut()
80        .insert(PARQUET_FIELD_ID_KEY.to_string(), column_id.to_string());
81    field
82}
83
84/// Stamps the `greptime.histogram` extension and reserved `PARQUET:field_id`s
85/// onto a native-histogram struct field (and its sub-fields / list element
86/// fields), so external readers can identify it by extension and resolve
87/// nested fields by id.
88///
89/// Detection is by the exact native-histogram struct type
90/// (`is_native_histogram_value_type`); other struct columns are left untouched.
91/// mito2 reads SST columns by schema position, never by field metadata, so this
92/// only affects external readers.
93///
94/// Returns an error if the parent column's `PARQUET:field_id` is missing,
95/// malformed, or exceeds `i32::MAX`, or if a sub-field id cannot be derived
96/// — because the sub-field name is not a known native-histogram field, or
97/// the derived id overflows a positive `i32` (an absurdly large parent
98/// `column_id`); see [`native_histogram_subfield_id`].
99fn stamp_native_histogram_subfield_ids(field: &mut Field) -> crate::error::Result<()> {
100    if !is_native_histogram_value_type(&ConcreteDataType::from_arrow_type(field.data_type())) {
101        return Ok(());
102    }
103    // Namespace sub-field ids by the parent column's field id (its
104    // `PARQUET:field_id`, stamped earlier by `with_field_id`) so several
105    // histogram columns in one table get disjoint ids. Fail loudly if the id
106    // is absent, malformed, or too large to fit a positive `i32`.
107    let column_id = field
108        .metadata()
109        .get(PARQUET_FIELD_ID_KEY)
110        .and_then(|s| s.parse::<i32>().ok())
111        .ok_or_else(|| {
112            InvalidNativeHistogramFieldIdSnafu {
113                field_name: field.name().clone(),
114            }
115            .build()
116        })?;
117    // Tag the field with the greptime.histogram extension.
118    field.metadata_mut().insert(
119        EXTENSION_TYPE_NAME_KEY.to_string(),
120        HistogramExtensionType::NAME.to_string(),
121    );
122    let ArrowDataType::Struct(children) = field.data_type() else {
123        return Ok(());
124    };
125    let new_children: crate::error::Result<Fields> = children
126        .iter()
127        .map(|child| {
128            let mut c = (**child).clone();
129            // `None` here means either the sub-field name is not a known
130            // native-histogram field, or the derived id overflowed i32.
131            // Surface it as an error rather than silently leaving the field
132            // without an id.
133            let id = native_histogram_subfield_id(column_id, c.name()).ok_or_else(|| {
134                InvalidNativeHistogramSubfieldSnafu {
135                    column_id,
136                    field_name: c.name().clone(),
137                }
138                .build()
139            })?;
140            // Stamp the sub-field's own id.
141            c.metadata_mut()
142                .insert(PARQUET_FIELD_ID_KEY.to_string(), id.to_string());
143            // If the sub-field is a list, stamp its element field's id.
144            if let ArrowDataType::List(elem) = c.data_type() {
145                let elem_id =
146                    native_histogram_list_element_id(column_id, c.name()).ok_or_else(|| {
147                        InvalidNativeHistogramSubfieldSnafu {
148                            column_id,
149                            field_name: c.name().clone(),
150                        }
151                        .build()
152                    })?;
153                let mut new_elem = (**elem).clone();
154                new_elem
155                    .metadata_mut()
156                    .insert(PARQUET_FIELD_ID_KEY.to_string(), elem_id.to_string());
157                c.set_data_type(ArrowDataType::List(Arc::new(new_elem)));
158            }
159            Ok(Arc::new(c))
160        })
161        .collect();
162    field.set_data_type(ArrowDataType::Struct(new_children?));
163    Ok(())
164}
165
166/// Returns a copy of `schema` with native-histogram sub-field ids stamped,
167/// for the parquet writer.
168///
169/// This is called on the schema handed to `AsyncArrowWriter`, not in
170/// [`with_field_id`], because the SST arrow schema is also the memtable's
171/// in-memory schema, whose `Struct` equality (`PartialEq`) is
172/// metadata-sensitive — stamping there would break writes. The parquet writer
173/// compares types with `DataType::equals_datatype`, which ignores field
174/// metadata, so a stamped schema accepts an unstamped batch.
175pub fn maybe_wrap_schema(schema: &SchemaRef) -> crate::error::Result<SchemaRef> {
176    // Fast path: only a struct column can be a native histogram; if there are
177    // none, skip the rebuild.
178    if !schema
179        .fields()
180        .iter()
181        .any(|f| matches!(f.data_type(), ArrowDataType::Struct(_)))
182    {
183        return Ok(schema.clone());
184    }
185    let new_fields: crate::error::Result<Vec<FieldRef>> = schema
186        .fields()
187        .iter()
188        .map(|f| {
189            let mut field = (**f).clone();
190            stamp_native_histogram_subfield_ids(&mut field)?;
191            Ok(Arc::new(field))
192        })
193        .collect();
194    Ok(Arc::new(Schema::new_with_metadata(
195        Fields::from(new_fields?),
196        schema.metadata().clone(),
197    )))
198}
199
200/// Parquet field ID base for internal columns (__primary_key, __sequence, __op_type).
201/// Uses bit 30 to distinguish from user column IDs and fit in positive i32 range.
202pub(crate) const INTERNAL_PARQUET_FIELD_ID_BASE: u32 = 1 << 30;
203
204/// Parquet field ID for the __primary_key column.
205pub(crate) const PRIMARY_KEY_PARQUET_FIELD_ID: u32 = INTERNAL_PARQUET_FIELD_ID_BASE;
206/// Parquet field ID for the __sequence column.
207pub(crate) const SEQUENCE_PARQUET_FIELD_ID: u32 = INTERNAL_PARQUET_FIELD_ID_BASE + 1;
208/// Parquet field ID for the __op_type column.
209pub(crate) const OP_TYPE_PARQUET_FIELD_ID: u32 = INTERNAL_PARQUET_FIELD_ID_BASE + 2;
210
211/// Gets the arrow schema to store in parquet.
212pub fn to_sst_arrow_schema(metadata: &RegionMetadata) -> SchemaRef {
213    let fields = Fields::from_iter(
214        metadata
215            .schema
216            .arrow_schema()
217            .fields()
218            .iter()
219            .zip(&metadata.column_metadatas)
220            .filter_map(|(field, column_meta)| {
221                if column_meta.semantic_type == SemanticType::Field {
222                    Some(Arc::new(with_field_id(
223                        (**field).clone(),
224                        column_meta.column_id,
225                    )))
226                } else {
227                    // We have fixed positions for tags (primary key) and time index.
228                    None
229                }
230            })
231            .chain([Arc::new(with_field_id(
232                (*metadata.time_index_field()).clone(),
233                metadata.time_index_column().column_id,
234            ))])
235            .chain(internal_fields()),
236    );
237
238    Arc::new(Schema::new(fields))
239}
240
241/// Options of flat schema.
242pub struct FlatSchemaOptions {
243    /// Whether to store primary key columns additionally instead of an encoded column.
244    pub raw_pk_columns: bool,
245    /// Whether to use dictionary encoding for string primary key columns
246    /// when storing primary key columns.
247    /// Only takes effect when `raw_pk_columns` is true.
248    pub string_pk_use_dict: bool,
249    /// The column's concretized JSON types, to be set into Arrow schema.
250    /// Otherwise it's empty struct in the Arrow schema.
251    pub concretized_json_types: HashMap<String, DataType>,
252}
253
254impl Default for FlatSchemaOptions {
255    fn default() -> Self {
256        Self {
257            raw_pk_columns: true,
258            string_pk_use_dict: true,
259            concretized_json_types: HashMap::new(),
260        }
261    }
262}
263
264impl FlatSchemaOptions {
265    /// Creates a options according to the primary key encoding.
266    pub fn from_encoding(encoding: PrimaryKeyEncoding) -> Self {
267        if encoding == PrimaryKeyEncoding::Dense {
268            Self::default()
269        } else {
270            Self {
271                raw_pk_columns: false,
272                string_pk_use_dict: false,
273                concretized_json_types: HashMap::new(),
274            }
275        }
276    }
277}
278
279/// Gets the arrow schema to store in parquet.
280///
281/// The schema is:
282/// ```text
283/// primary key columns, field columns, time index, __primary_key, __sequence, __op_type
284/// ```
285///
286/// # Panics
287/// Panics if the metadata is invalid.
288pub fn to_flat_sst_arrow_schema(
289    metadata: &RegionMetadata,
290    options: &FlatSchemaOptions,
291) -> SchemaRef {
292    let num_fields = flat_sst_arrow_schema_column_num(metadata, options);
293    let mut fields = Vec::with_capacity(num_fields);
294    let schema = metadata.schema.arrow_schema();
295    if options.raw_pk_columns {
296        for pk_id in &metadata.primary_key {
297            let pk_index = metadata.column_index_by_id(*pk_id).unwrap();
298            let column_id = metadata.column_metadatas[pk_index].column_id;
299            if options.string_pk_use_dict {
300                let old_field = &schema.fields[pk_index];
301                let new_field = tag_maybe_to_dictionary_field(
302                    &metadata.column_metadatas[pk_index].column_schema.data_type,
303                    old_field,
304                );
305                let new_field = concretize_json_type(new_field, options);
306                fields.push(Arc::new(with_field_id((*new_field).clone(), column_id)));
307            }
308        }
309    }
310    let remaining_fields = schema
311        .fields()
312        .iter()
313        .zip(&metadata.column_metadatas)
314        .filter_map(|(field, column_meta)| {
315            if column_meta.semantic_type == SemanticType::Field {
316                let field = concretize_json_type(field.clone(), options);
317                Some(Arc::new(with_field_id(
318                    Arc::unwrap_or_clone(field),
319                    column_meta.column_id,
320                )))
321            } else {
322                None
323            }
324        })
325        .chain([Arc::new(with_field_id(
326            (*metadata.time_index_field()).clone(),
327            metadata.time_index_column().column_id,
328        ))])
329        .chain(internal_fields());
330    for field in remaining_fields {
331        fields.push(field);
332    }
333
334    Arc::new(Schema::new(fields))
335}
336
337fn concretize_json_type(field: Arc<Field>, options: &FlatSchemaOptions) -> Arc<Field> {
338    if let Some(data_type) = options.concretized_json_types.get(field.name()) {
339        let mut field = Arc::unwrap_or_clone(field);
340        field.set_data_type(data_type.clone());
341        Arc::new(field)
342    } else {
343        field
344    }
345}
346
347/// Returns the number of columns in the flat format.
348pub fn flat_sst_arrow_schema_column_num(
349    metadata: &RegionMetadata,
350    options: &FlatSchemaOptions,
351) -> usize {
352    if options.raw_pk_columns {
353        metadata.column_metadatas.len() + 3
354    } else {
355        metadata.column_metadatas.len() + 3 - metadata.primary_key.len()
356    }
357}
358
359/// Helper function to create a dictionary field from a field.
360fn to_dictionary_field(field: &Field) -> Field {
361    let mut new_field = Field::new_dictionary(
362        field.name(),
363        datatypes::arrow::datatypes::DataType::UInt32,
364        field.data_type().clone(),
365        field.is_nullable(),
366    );
367
368    // retain field_id metadata
369    if let Some(field_id) = field.metadata().get(PARQUET_FIELD_ID_KEY) {
370        new_field
371            .metadata_mut()
372            .insert(PARQUET_FIELD_ID_KEY.to_string(), field_id.clone());
373    }
374
375    new_field
376}
377
378/// Helper function to create a dictionary field from a field if it is a string column.
379pub(crate) fn tag_maybe_to_dictionary_field(
380    data_type: &ConcreteDataType,
381    field: &Arc<Field>,
382) -> Arc<Field> {
383    if data_type.is_string() {
384        Arc::new(to_dictionary_field(field))
385    } else {
386        field.clone()
387    }
388}
389
390/// Fields for internal columns.
391pub(crate) fn internal_fields() -> [FieldRef; 3] {
392    // Internal columns are always not null.
393    [
394        Arc::new(with_field_id(
395            Field::new_dictionary(
396                PRIMARY_KEY_COLUMN_NAME,
397                ArrowDataType::UInt32,
398                ArrowDataType::Binary,
399                false,
400            ),
401            PRIMARY_KEY_PARQUET_FIELD_ID,
402        )),
403        Arc::new(with_field_id(
404            Field::new(SEQUENCE_COLUMN_NAME, ArrowDataType::UInt64, false),
405            SEQUENCE_PARQUET_FIELD_ID,
406        )),
407        Arc::new(with_field_id(
408            Field::new(OP_TYPE_COLUMN_NAME, ArrowDataType::UInt8, false),
409            OP_TYPE_PARQUET_FIELD_ID,
410        )),
411    ]
412}
413
414/// Returns a copy of `schema` with the `__primary_key` field replaced by a plain `Binary` field.
415pub(crate) fn override_pk_field_to_binary(schema: &SchemaRef) -> SchemaRef {
416    let new_fields = schema
417        .fields()
418        .iter()
419        .map(|field| {
420            if field.name() == PRIMARY_KEY_COLUMN_NAME {
421                let mut new_field = Field::new(
422                    PRIMARY_KEY_COLUMN_NAME,
423                    ArrowDataType::Binary,
424                    field.is_nullable(),
425                );
426                // Preserve the field_id metadata so parquet readers that require
427                // all columns to carry a field_id don't fail.
428                if let Some(field_id) = field.metadata().get(PARQUET_FIELD_ID_KEY) {
429                    new_field
430                        .metadata_mut()
431                        .insert(PARQUET_FIELD_ID_KEY.to_string(), field_id.clone());
432                }
433                Arc::new(new_field)
434            } else {
435                field.clone()
436            }
437        })
438        .collect::<Vec<_>>();
439    Arc::new(Schema::new(new_fields))
440}
441
442/// Gets the estimated number of series from record batches.
443///
444/// This struct tracks the last timestamp value to detect series boundaries
445/// by observing when timestamps decrease (indicating a new series).
446#[derive(Default)]
447pub(crate) struct SeriesEstimator {
448    /// The last timestamp value seen
449    last_timestamp: Option<i64>,
450    /// The estimated number of series
451    series_count: u64,
452}
453
454impl SeriesEstimator {
455    /// Updates the estimator with a new record batch in flat format.
456    ///
457    /// This method examines the time index column to detect series boundaries.
458    pub(crate) fn update_flat(&mut self, record_batch: &RecordBatch) {
459        let batch_rows = record_batch.num_rows();
460        if batch_rows == 0 {
461            return;
462        }
463
464        let time_index_pos = time_index_column_index(record_batch.num_columns());
465        let timestamps = record_batch.column(time_index_pos);
466        let Some((ts_values, _unit)) = timestamp_array_to_primitive(timestamps) else {
467            return;
468        };
469        let values = ts_values.values();
470
471        // Checks if there's a boundary between the last batch and this batch
472        if let Some(last_ts) = self.last_timestamp {
473            if values[0] <= last_ts {
474                self.series_count += 1;
475            }
476        } else {
477            // First batch, counts as first series
478            self.series_count = 1;
479        }
480
481        // Counts series boundaries within this batch.
482        for i in 0..batch_rows - 1 {
483            // We assumes the same timestamp as a new series, which is different from
484            // how we split batches.
485            if values[i] >= values[i + 1] {
486                self.series_count += 1;
487            }
488        }
489
490        // Updates the last timestamp
491        self.last_timestamp = Some(values[batch_rows - 1]);
492    }
493
494    /// Returns the estimated number of series.
495    pub(crate) fn finish(&mut self) -> u64 {
496        self.last_timestamp = None;
497        let count = self.series_count;
498        self.series_count = 0;
499
500        count
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use std::sync::Arc;
507
508    use common_query::prelude::greptime_native_histogram;
509    use datatypes::arrow::array::{
510        BinaryArray, DictionaryArray, TimestampMillisecondArray, UInt8Array, UInt32Array,
511        UInt64Array,
512    };
513    use datatypes::arrow::datatypes::{DataType as ArrowDataType, Field, Schema, TimeUnit};
514    use datatypes::arrow::record_batch::RecordBatch;
515
516    use super::*;
517
518    fn new_flat_record_batch(timestamps: &[i64]) -> RecordBatch {
519        // Flat format has: [fields..., time_index, __primary_key, __sequence, __op_type]
520        let num_cols = 4; // time_index + 3 internal columns
521        let time_index_pos = time_index_column_index(num_cols);
522        assert_eq!(time_index_pos, 0); // For 4 columns, time index should be at position 0
523
524        let time_array = Arc::new(TimestampMillisecondArray::from(timestamps.to_vec()));
525        let pk_array = Arc::new(DictionaryArray::new(
526            UInt32Array::from(vec![0; timestamps.len()]),
527            Arc::new(BinaryArray::from(vec![b"test".as_slice()])),
528        ));
529        let seq_array = Arc::new(UInt64Array::from(vec![1; timestamps.len()]));
530        let op_array = Arc::new(UInt8Array::from(vec![1; timestamps.len()]));
531
532        let schema = Arc::new(Schema::new(vec![
533            Field::new(
534                "time",
535                ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
536                false,
537            ),
538            Field::new_dictionary(
539                "__primary_key",
540                ArrowDataType::UInt32,
541                ArrowDataType::Binary,
542                false,
543            ),
544            Field::new("__sequence", ArrowDataType::UInt64, false),
545            Field::new("__op_type", ArrowDataType::UInt8, false),
546        ]));
547
548        RecordBatch::try_new(schema, vec![time_array, pk_array, seq_array, op_array]).unwrap()
549    }
550
551    #[test]
552    fn test_series_estimator_flat_empty_batch() {
553        let mut estimator = SeriesEstimator::default();
554        let record_batch = new_flat_record_batch(&[]);
555        estimator.update_flat(&record_batch);
556        assert_eq!(0, estimator.finish());
557    }
558
559    #[test]
560    fn test_series_estimator_flat_single_batch() {
561        let mut estimator = SeriesEstimator::default();
562        let record_batch = new_flat_record_batch(&[1, 2, 3]);
563        estimator.update_flat(&record_batch);
564        assert_eq!(1, estimator.finish());
565    }
566
567    #[test]
568    fn test_series_estimator_flat_series_boundary_within_batch() {
569        let mut estimator = SeriesEstimator::default();
570        // Timestamps decrease from 3 to 2, indicating a series boundary
571        let record_batch = new_flat_record_batch(&[1, 2, 3, 2, 4, 5]);
572        estimator.update_flat(&record_batch);
573        // Should detect boundary at position 3 (3 >= 2)
574        assert_eq!(2, estimator.finish());
575    }
576
577    #[test]
578    fn test_series_estimator_flat_multiple_boundaries_within_batch() {
579        let mut estimator = SeriesEstimator::default();
580        // Multiple series boundaries: 5>=4, 6>=3
581        let record_batch = new_flat_record_batch(&[1, 2, 5, 4, 6, 3, 7]);
582        estimator.update_flat(&record_batch);
583        assert_eq!(3, estimator.finish());
584    }
585
586    #[test]
587    fn test_series_estimator_flat_equal_timestamps() {
588        let mut estimator = SeriesEstimator::default();
589        // Equal timestamps are considered as new series
590        let record_batch = new_flat_record_batch(&[1, 2, 2, 3, 3, 3, 4]);
591        estimator.update_flat(&record_batch);
592        // Boundaries at: 2>=2, 3>=3, 3>=3
593        assert_eq!(4, estimator.finish());
594    }
595
596    #[test]
597    fn test_series_estimator_flat_multiple_batches_continuation() {
598        let mut estimator = SeriesEstimator::default();
599
600        // First batch: timestamps 1, 2, 3
601        let batch1 = new_flat_record_batch(&[1, 2, 3]);
602        estimator.update_flat(&batch1);
603
604        // Second batch: timestamps 4, 5, 6 (continuation)
605        let batch2 = new_flat_record_batch(&[4, 5, 6]);
606        estimator.update_flat(&batch2);
607
608        assert_eq!(1, estimator.finish());
609    }
610
611    #[test]
612    fn test_series_estimator_flat_multiple_batches_new_series() {
613        let mut estimator = SeriesEstimator::default();
614
615        // First batch: timestamps 1, 2, 3
616        let batch1 = new_flat_record_batch(&[1, 2, 3]);
617        estimator.update_flat(&batch1);
618
619        // Second batch: timestamps 2, 3, 4 (goes back to 2, new series)
620        let batch2 = new_flat_record_batch(&[2, 3, 4]);
621        estimator.update_flat(&batch2);
622
623        assert_eq!(2, estimator.finish());
624    }
625
626    #[test]
627    fn test_series_estimator_flat_boundary_at_batch_edge_equal() {
628        let mut estimator = SeriesEstimator::default();
629
630        // First batch ending at 5
631        let batch1 = new_flat_record_batch(&[1, 2, 5]);
632        estimator.update_flat(&batch1);
633
634        // Second batch starting at 5 (equal timestamp, new series)
635        let batch2 = new_flat_record_batch(&[5, 6, 7]);
636        estimator.update_flat(&batch2);
637
638        assert_eq!(2, estimator.finish());
639    }
640
641    #[test]
642    fn test_series_estimator_flat_mixed_batches() {
643        let mut estimator = SeriesEstimator::default();
644
645        // Batch 1: single series [10, 20, 30]
646        let batch1 = new_flat_record_batch(&[10, 20, 30]);
647        estimator.update_flat(&batch1);
648
649        // Batch 2: starts new series [5, 15], boundary within batch [15, 10, 25]
650        let batch2 = new_flat_record_batch(&[5, 15, 10, 25]);
651        estimator.update_flat(&batch2);
652
653        // Batch 3: continues from 25 to [30, 35]
654        let batch3 = new_flat_record_batch(&[30, 35]);
655        estimator.update_flat(&batch3);
656
657        // Expected: 1 (batch1) + 1 (batch2 start) + 1 (within batch2) = 3
658        assert_eq!(3, estimator.finish());
659    }
660
661    #[test]
662    fn test_series_estimator_flat_descending_timestamps() {
663        let mut estimator = SeriesEstimator::default();
664        // Strictly descending timestamps - each pair creates a boundary
665        let record_batch = new_flat_record_batch(&[10, 9, 8, 7, 6]);
666        estimator.update_flat(&record_batch);
667        // Boundaries: 10>=9, 9>=8, 8>=7, 7>=6 = 4 boundaries + 1 initial = 5 series
668        assert_eq!(5, estimator.finish());
669    }
670
671    #[test]
672    fn test_series_estimator_flat_finish_resets_state() {
673        let mut estimator = SeriesEstimator::default();
674
675        let batch1 = new_flat_record_batch(&[1, 2, 3]);
676        estimator.update_flat(&batch1);
677
678        assert_eq!(1, estimator.finish());
679
680        // After finish, state should be reset
681        let batch2 = new_flat_record_batch(&[4, 5, 6]);
682        estimator.update_flat(&batch2);
683
684        assert_eq!(1, estimator.finish());
685    }
686
687    /// Build a native-histogram struct field whose top-level `PARQUET:field_id`
688    /// is `column_id` (as `with_field_id` does on the real write path).
689    fn histogram_field(name: &str, column_id: u32) -> Field {
690        use common_query::native_histogram::native_histogram_value_type;
691        use datatypes::data_type::DataType;
692        with_field_id(
693            Field::new(name, native_histogram_value_type().as_arrow_type(), true),
694            column_id,
695        )
696    }
697
698    /// Asserts `field` is a stamped native-histogram struct: it carries the
699    /// `greptime.histogram` extension and every sub-field (and list element)
700    /// carries its reserved `PARQUET:field_id` namespaced by `column_id`.
701    fn assert_histogram_stamped(field: &Field, column_id: i32) {
702        use arrow_schema::extension::ExtensionType;
703        use common_query::native_histogram::{
704            native_histogram_list_element_id, native_histogram_subfield_id,
705        };
706        use datatypes::extension::histogram::HistogramExtensionType;
707
708        assert_eq!(
709            field
710                .metadata()
711                .get(arrow_schema::extension::EXTENSION_TYPE_NAME_KEY)
712                .map(|s| s.as_str()),
713            Some(HistogramExtensionType::NAME),
714            "histogram field must carry the greptime.histogram extension"
715        );
716        let ArrowDataType::Struct(children) = field.data_type() else {
717            panic!("expected a struct, got {:?}", field.data_type());
718        };
719        for child in children {
720            let expected = native_histogram_subfield_id(column_id, child.name())
721                .unwrap_or_else(|| panic!("no id for sub-field {}", child.name()));
722            let got: i32 = child
723                .metadata()
724                .get(PARQUET_FIELD_ID_KEY)
725                .unwrap_or_else(|| panic!("sub-field {} missing field id", child.name()))
726                .parse()
727                .unwrap();
728            assert_eq!(got, expected, "sub-field {} id", child.name());
729            if let ArrowDataType::List(elem) = child.data_type() {
730                let elem_expected =
731                    native_histogram_list_element_id(column_id, child.name()).unwrap();
732                let elem_got: i32 = elem
733                    .metadata()
734                    .get(PARQUET_FIELD_ID_KEY)
735                    .unwrap_or_else(|| panic!("list element of {} missing id", child.name()))
736                    .parse()
737                    .unwrap();
738                assert_eq!(
739                    elem_got,
740                    elem_expected,
741                    "list element id of {}",
742                    child.name()
743                );
744            }
745        }
746    }
747
748    #[test]
749    fn test_maybe_wrap_schema_native_histogram() {
750        let schema = Arc::new(Schema::new(vec![
751            Field::new(
752                "greptime_timestamp",
753                ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
754                false,
755            ),
756            histogram_field(greptime_native_histogram(), 1),
757        ]));
758
759        let wrapped = maybe_wrap_schema(&schema).unwrap();
760        let hist = wrapped
761            .field_with_name(greptime_native_histogram())
762            .expect("histogram field present");
763        // The struct has 18 sub-fields.
764        let ArrowDataType::Struct(children) = hist.data_type() else {
765            unreachable!()
766        };
767        assert_eq!(children.len(), 18);
768        assert_histogram_stamped(hist, 1);
769    }
770
771    #[test]
772    fn test_maybe_wrap_schema_multiple_histograms_disjoint_ids() {
773        // Two histogram columns with distinct parent column ids get disjoint
774        // sub-field ids (defensive: the metric engine yields at most one
775        // histogram column, but the scheme must stay correct if more appear).
776        use common_query::native_histogram::native_histogram_subfield_id;
777
778        let schema = Arc::new(Schema::new(vec![
779            histogram_field(greptime_native_histogram(), 1),
780            histogram_field(greptime_native_histogram(), 7),
781        ]));
782        let wrapped = maybe_wrap_schema(&schema).unwrap();
783        let h1 = &wrapped.fields()[0];
784        let h2 = &wrapped.fields()[1];
785        assert_histogram_stamped(h1, 1);
786        assert_histogram_stamped(h2, 7);
787        // The same sub-field name resolves to different ids across columns.
788        assert_ne!(
789            native_histogram_subfield_id(1, "sum"),
790            native_histogram_subfield_id(7, "sum")
791        );
792    }
793
794    #[test]
795    fn test_maybe_wrap_schema_recognizes_histogram_by_type() {
796        let schema = Arc::new(Schema::new(vec![histogram_field("custom_histogram", 5)]));
797
798        let wrapped = maybe_wrap_schema(&schema).unwrap();
799        let hist = wrapped.field_with_name("custom_histogram").unwrap();
800        assert_histogram_stamped(hist, 5);
801    }
802
803    #[test]
804    fn test_maybe_wrap_schema_plain_struct_not_stamped() {
805        use arrow_schema::extension::EXTENSION_TYPE_NAME_KEY;
806
807        let plain = ArrowDataType::Struct(
808            vec![
809                Arc::new(Field::new("a", ArrowDataType::Int32, true)),
810                Arc::new(Field::new("b", ArrowDataType::Utf8, true)),
811            ]
812            .into(),
813        );
814        let schema = Arc::new(Schema::new(vec![
815            Field::new(
816                "ts",
817                ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
818                false,
819            ),
820            Field::new("data", plain, true),
821        ]));
822
823        let wrapped = maybe_wrap_schema(&schema).unwrap();
824        let data = wrapped.field_with_name("data").unwrap();
825        assert!(
826            data.metadata().get(EXTENSION_TYPE_NAME_KEY).is_none(),
827            "non-histogram struct must not get the extension"
828        );
829        if let ArrowDataType::Struct(children) = data.data_type() {
830            for child in children {
831                assert!(
832                    child.metadata().get(PARQUET_FIELD_ID_KEY).is_none(),
833                    "non-histogram sub-field {} must not get a field id",
834                    child.name()
835                );
836            }
837        }
838    }
839
840    #[test]
841    fn test_maybe_wrap_schema_no_struct_unchanged() {
842        let schema: Arc<Schema> = Arc::new(Schema::new(vec![
843            Field::new(
844                "ts",
845                ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
846                false,
847            ),
848            Field::new("v", ArrowDataType::Float64, true),
849        ]));
850        let wrapped = maybe_wrap_schema(&schema).unwrap();
851        assert!(
852            Arc::ptr_eq(&wrapped, &schema),
853            "a schema without any struct column must be returned unchanged"
854        );
855    }
856
857    /// Writes `schema` through `maybe_wrap_schema` and a real parquet
858    /// [`ArrowWriter`], then returns the arrow schema read back from the file
859    /// footer. This proves the `greptime.histogram` extension and the nested
860    /// `PARQUET:field_id`s actually land on disk, not just in memory.
861    ///
862    /// `maybe_wrap_schema` is exactly what the SST parquet writer hands to
863    /// `AsyncArrowWriter` (see `writer.rs`); the sync [`ArrowWriter`] shares
864    /// the same arrow-to-parquet schema conversion, so the footer it emits is
865    /// the on-disk contract this change introduces. An empty batch suffices
866    /// because the parquet footer always carries the schema.
867    fn parquet_footer_arrow_schema(schema: &SchemaRef) -> SchemaRef {
868        use ::parquet::arrow::ArrowWriter;
869        use ::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
870        use ::parquet::file::properties::WriterProperties;
871        use bytes::Bytes;
872
873        let wrapped = maybe_wrap_schema(schema).unwrap();
874        let mut bytes = Vec::new();
875        let props = WriterProperties::builder().build();
876        let mut writer = ArrowWriter::try_new(&mut bytes, wrapped.clone(), Some(props)).unwrap();
877        writer
878            .write(&RecordBatch::new_empty(wrapped.clone()))
879            .unwrap();
880        writer.close().unwrap();
881
882        ParquetRecordBatchReaderBuilder::try_new(Bytes::from(bytes))
883            .unwrap()
884            .schema()
885            .clone()
886    }
887
888    #[test]
889    fn test_maybe_wrap_schema_survives_parquet_roundtrip() {
890        // On-disk contract: after writing through the parquet writer path, the
891        // footer still carries the greptime.histogram extension and every
892        // nested (sub-field + list-element) PARQUET:field_id.
893        let schema = Arc::new(Schema::new(vec![
894            Field::new(
895                "greptime_timestamp",
896                ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
897                false,
898            ),
899            histogram_field(greptime_native_histogram(), 3),
900        ]));
901
902        let on_disk = parquet_footer_arrow_schema(&schema);
903        let hist = on_disk
904            .field_with_name(greptime_native_histogram())
905            .expect("histogram field present");
906        assert_histogram_stamped(hist, 3);
907    }
908
909    #[test]
910    fn test_parquet_roundtrip_recognizes_histogram_by_type() {
911        // The persisted type, rather than a process-local configured name,
912        // identifies native histograms across upgrades and prefix changes.
913        let schema = Arc::new(Schema::new(vec![
914            Field::new(
915                "ts",
916                ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
917                false,
918            ),
919            histogram_field("custom_histogram", 9),
920        ]));
921
922        let on_disk = parquet_footer_arrow_schema(&schema);
923        let hist = on_disk.field_with_name("custom_histogram").unwrap();
924        assert_histogram_stamped(hist, 9);
925    }
926
927    #[test]
928    fn test_maybe_wrap_schema_overflows_return_error() {
929        // A column id of 12_582_912 makes the derived sub-field id overflow
930        // i32 (BASE + column_id*64 == i32::MAX + 1). The write path must
931        // surface this as an error rather than silently dropping the field
932        // id, wrapping, or panicking.
933        let schema = Arc::new(Schema::new(vec![
934            Field::new(
935                "greptime_timestamp",
936                ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
937                false,
938            ),
939            histogram_field(greptime_native_histogram(), 12_582_912),
940        ]));
941        let err = maybe_wrap_schema(&schema).unwrap_err();
942        assert!(
943            matches!(
944                err,
945                crate::error::Error::InvalidNativeHistogramSubfield { .. }
946            ),
947            "expected InvalidNativeHistogramSubfield, got {:?}",
948            err
949        );
950    }
951
952    #[test]
953    fn test_maybe_wrap_schema_missing_field_id_returns_error() {
954        // The parent column's PARQUET:field_id namespaces every sub-field id.
955        // If it is absent (e.g. a histogram struct handed to the writer
956        // without the write path's stamping), the writer must fail loudly
957        // rather than silently namespace under column 0, which would collide
958        // with that column's nested ids.
959        use common_query::native_histogram::native_histogram_value_type;
960        use datatypes::data_type::DataType;
961
962        let field = Field::new(
963            greptime_native_histogram(),
964            native_histogram_value_type().as_arrow_type(),
965            true,
966        );
967        assert!(
968            field.metadata().get(PARQUET_FIELD_ID_KEY).is_none(),
969            "fixture must not carry a field id"
970        );
971        let schema = Arc::new(Schema::new(vec![field]));
972        let err = maybe_wrap_schema(&schema).unwrap_err();
973        assert!(
974            matches!(
975                err,
976                crate::error::Error::InvalidNativeHistogramFieldId { .. }
977            ),
978            "expected InvalidNativeHistogramFieldId, got {:?}",
979            err
980        );
981    }
982
983    #[test]
984    fn test_maybe_wrap_schema_field_id_above_i32_max_returns_error() {
985        // `with_field_id` serializes the column id from a u32, so a valid id
986        // above i32::MAX (e.g. u32::MAX) must not be silently parsed as a
987        // failed i32 and collapsed onto column 0's nested ids. It must
988        // surface a checked-conversion error instead.
989        let schema = Arc::new(Schema::new(vec![
990            Field::new(
991                "greptime_timestamp",
992                ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
993                false,
994            ),
995            histogram_field(greptime_native_histogram(), u32::MAX),
996        ]));
997        let err = maybe_wrap_schema(&schema).unwrap_err();
998        assert!(
999            matches!(
1000                err,
1001                crate::error::Error::InvalidNativeHistogramFieldId { .. }
1002            ),
1003            "expected InvalidNativeHistogramFieldId, got {:?}",
1004            err
1005        );
1006    }
1007}