Skip to main content

linkrs_storage/edge/csr_with_properties/
encoding.rs

1use super::{CsrWithProperties, FSST_MAX_SYMBOLS};
2use linkrs_core::{StorageError, StorageResult, Value};
3use std::sync::Arc;
4
5impl CsrWithProperties {
6    /// Merge the per-chunk zone bounds of one property column.
7    ///
8    /// Bounds only ever widen (rebuilds never shrink), so the result contains
9    /// every non-null value any snapshot can still observe through a version
10    /// chain. `None` when the column is absent or holds no recorded bounds
11    /// (all-null columns included).
12    fn zone_bounds(&self, column: &str) -> Option<(Value, Value)> {
13        let col = self
14            .column_index
15            .get(column)
16            .and_then(|&idx| self.property_columns.get(idx))?;
17        let mut min: Option<Value> = None;
18        let mut max: Option<Value> = None;
19        for zone in col.zone_maps() {
20            if let Some(v) = &zone.min {
21                match &min {
22                    Some(cur)
23                        if crate::vertex::column::compare_values(cur, v)
24                            != std::cmp::Ordering::Greater => {}
25                    _ => min = Some(v.clone()),
26                }
27            }
28            if let Some(v) = &zone.max {
29                match &max {
30                    Some(cur)
31                        if crate::vertex::column::compare_values(cur, v)
32                            != std::cmp::Ordering::Less => {}
33                    _ => max = Some(v.clone()),
34                }
35            }
36        }
37        match (min, max) {
38            (Some(lower), Some(upper)) => Some((lower, upper)),
39            _ => None,
40        }
41    }
42
43    pub fn column_stats_snapshot(
44        &self,
45        column: &str,
46    ) -> Option<crate::stats_reader::ColumnStatsSnapshot> {
47        let col = self
48            .column_index
49            .get(column)
50            .and_then(|&idx| self.property_columns.get(idx))?;
51        let bounds = self.zone_bounds(column);
52        let persisted = col.stats();
53        let null_count = persisted.as_ref().map(|s| s.null_count);
54        let (distinct_count, hll) = match persisted.as_ref().and_then(|s| s.hll.clone()) {
55            Some(h) => {
56                let est = h.estimate();
57                (Some(est), persisted.as_ref().and_then(|s| s.hll.clone()))
58            }
59            None => (None, None),
60        };
61        Some(crate::stats_reader::ColumnStatsSnapshot {
62            row_count: self.row_count as u64,
63            null_count,
64            distinct_count,
65            hll,
66            min_value: bounds.as_ref().map(|(lower, _)| lower.clone()),
67            max_value: bounds.as_ref().map(|(_, upper)| upper.clone()),
68        })
69    }
70
71    /// Global min/max bounds of one column for predicate pushdown pruning.
72    ///
73    /// Callers must scan instead of pruning when this is `None`.
74    pub fn prune_bounds(&self, column: &str) -> Option<(Value, Value)> {
75        self.zone_bounds(column)
76    }
77
78    /// Encoding applied to one property column, if the column exists.
79    pub fn column_encoding_type(&self, column: &str) -> Option<crate::encoding::EncodingType> {
80        self.column_index
81            .get(column)
82            .and_then(|&idx| self.property_columns.get(idx))
83            .map(|c| c.encoding_type())
84    }
85
86    /// Apply one encoding to a single property column.
87    ///
88    /// Property columns are chunk-backed, so the encoding is applied per
89    /// chunk and point updates keep decoding only the affected chunk.
90    /// Empty columns are a no-op. Unknown columns are an explicit error.
91    pub fn apply_encoding_to_column(
92        &mut self,
93        column: &str,
94        encoding_type: crate::encoding::EncodingType,
95        fsst_max_symbols: usize,
96    ) -> StorageResult<()> {
97        let idx = self
98            .column_index
99            .get(column)
100            .copied()
101            .ok_or_else(|| StorageError::column_not_found(column.to_string()))?;
102        let col = &mut self.property_columns[idx];
103        if col.is_empty() {
104            return Ok(());
105        }
106        col.apply_encoding_to_chunks(encoding_type, fsst_max_symbols)?;
107        if let Some(schema) = self.property_schema.get_mut(idx) {
108            schema.encoding_type = col.encoding_type();
109        }
110        Ok(())
111    }
112
113    /// Select and apply one encoding per property column.
114    ///
115    /// Explicit maintenance operation: hot columns stay unencoded between runs
116    /// by design so everyday writes never pay re-encoding. Each column is
117    /// profiled chunk by chunk, so no column-wide value vector is built.
118    /// Returns the number of columns that received an encoding.
119    pub fn auto_encode_properties(&mut self) -> usize {
120        let targets = self.all_column_targets();
121        self.encode_targets(&targets, 0)
122    }
123
124    /// Infer encodings for checkpoint dirty columns.
125    ///
126    /// Checkpoint-only entry: the write path never calls this, so hot columns
127    /// pay no re-encoding on everyday writes. `dirty_only` selects the
128    /// inference set; `None` infers every column for first-flush
129    /// completeness. Columns below `min_rows` are skipped. A column keeps
130    /// its current encoding when inference yields none or matches. Returns
131    /// the number of columns that changed encoding.
132    pub fn adapt_encodings_for_checkpoint(
133        &mut self,
134        dirty_only: Option<&[Arc<str>]>,
135        min_rows: usize,
136    ) -> usize {
137        let targets = match dirty_only {
138            Some(names) if !names.is_empty() => names
139                .iter()
140                .filter_map(|name| {
141                    self.column_index
142                        .get(&**name)
143                        .map(|idx| (*idx, name.clone()))
144                })
145                .collect(),
146            _ => self.all_column_targets(),
147        };
148        self.encode_targets(&targets, min_rows)
149    }
150
151    /// Every schema column as an encode target, in schema order.
152    fn all_column_targets(&self) -> Vec<(usize, Arc<str>)> {
153        self.property_schema
154            .iter()
155            .enumerate()
156            .map(|(idx, schema)| (idx, schema.name.clone()))
157            .collect()
158    }
159
160    /// Profile each target column chunk by chunk and apply the winner.
161    ///
162    /// The single place where a column's encoding is inferred, so explicit
163    /// maintenance and checkpoint adaptation can never drift apart. Columns
164    /// under `min_rows`, empty columns and columns whose winner matches the
165    /// encoding they already carry are left alone.
166    fn encode_targets(&mut self, targets: &[(usize, Arc<str>)], min_rows: usize) -> usize {
167        let selector = crate::encoding::EncodingSelector::default();
168        let mut encoded = 0usize;
169        for (idx, name) in targets {
170            let Some(col) = self.property_columns.get(*idx) else {
171                continue;
172            };
173            if col.len() < min_rows || col.is_empty() {
174                continue;
175            }
176            let selected = col.select_encoding(&selector);
177            if selected == crate::encoding::EncodingType::None || selected == col.encoding_type() {
178                continue;
179            }
180            if self
181                .apply_encoding_to_column(name, selected, FSST_MAX_SYMBOLS)
182                .is_ok()
183            {
184                encoded += 1;
185            }
186        }
187        encoded
188    }
189}