Skip to main content

radixdb_executor/
statistics.rs

1// Copyright 2026 RadixDB Contributors
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//! ANALYZE command executor for statistics collection
16//!
17//! This module implements the ANALYZE command which collects statistics
18//! about tables and stores them in system tables for query optimization.
19
20use radixdb_core::time_compat::{system_time_now, UNIX_EPOCH};
21use std::hash::{Hash, Hasher};
22
23use rustc_hash::FxHasher;
24
25use radixdb_catalog::ObjectId;
26use radixdb_core::{DataType, Error, Result, Row, RowVec, Schema, SchemaBuilder, Value, ValueSet};
27use radixdb_sql::ast::AnalyzeStatement;
28use radixdb_storage::statistics::{
29    encode_statistics_value, is_stats_table, Histogram, CREATE_COLUMN_STATS_SQL,
30    CREATE_TABLE_STATS_SQL, DEFAULT_HISTOGRAM_BUCKETS, DEFAULT_SAMPLE_SIZE, SYS_COLUMN_STATS,
31    SYS_TABLE_STATS,
32};
33use radixdb_storage::traits::{Engine, QueryResult, Table, Transaction};
34use radixdb_storage::volume::zonemap::{ZoneMapBuilder, DEFAULT_SEGMENT_SIZE};
35
36use super::context::ExecutionContext;
37use super::result::ExecutorResult;
38use super::Executor;
39
40type CollectedColumnStats = (
41    i64,
42    i64,
43    Option<Value>,
44    Option<Value>,
45    i64,
46    Option<Histogram>,
47);
48
49struct PendingStatisticsPublication {
50    table: Box<dyn Table>,
51    zone_maps: radixdb_storage::volume::zonemap::TableZoneMap,
52}
53
54const EXACT_DISTINCT_HASH_LIMIT: usize = DEFAULT_SAMPLE_SIZE;
55const HLL_PRECISION: usize = 10;
56const HLL_REGISTERS: usize = 1 << HLL_PRECISION;
57
58enum BoundedDistinct {
59    Exact(ValueSet),
60    Approx(Box<[u8; HLL_REGISTERS]>),
61}
62
63impl Default for BoundedDistinct {
64    fn default() -> Self {
65        Self::Exact(ValueSet::default())
66    }
67}
68
69impl BoundedDistinct {
70    fn insert(&mut self, value: &Value) {
71        let mut hasher = FxHasher::default();
72        value.hash(&mut hasher);
73        let hash = hasher.finish();
74        match self {
75            Self::Exact(values) => {
76                if values.contains(value) {
77                    return;
78                }
79                if values.len() < EXACT_DISTINCT_HASH_LIMIT {
80                    values.insert(value.clone());
81                    return;
82                }
83                let mut registers = Box::new([0u8; HLL_REGISTERS]);
84                for existing in values.drain() {
85                    let mut hasher = FxHasher::default();
86                    existing.hash(&mut hasher);
87                    Self::hll_insert(&mut registers, hasher.finish());
88                }
89                Self::hll_insert(&mut registers, hash);
90                *self = Self::Approx(registers);
91            }
92            Self::Approx(registers) => Self::hll_insert(registers, hash),
93        }
94    }
95
96    fn hll_insert(registers: &mut [u8; HLL_REGISTERS], hash: u64) {
97        let index = (hash >> (64 - HLL_PRECISION)) as usize;
98        let remaining = hash << HLL_PRECISION;
99        let rank = remaining.leading_zeros().saturating_add(1) as u8;
100        registers[index] = registers[index].max(rank);
101    }
102
103    fn estimate(&self) -> u64 {
104        match self {
105            Self::Exact(values) => values.len() as u64,
106            Self::Approx(registers) => {
107                let m = HLL_REGISTERS as f64;
108                let harmonic: f64 = registers
109                    .iter()
110                    .map(|rank| 2f64.powi(-(*rank as i32)))
111                    .sum();
112                let alpha = 0.7213 / (1.0 + 1.079 / m);
113                let raw = alpha * m * m / harmonic.max(f64::MIN_POSITIVE);
114                let zeroes = registers.iter().filter(|&&rank| rank == 0).count();
115                let estimate = if zeroes > 0 {
116                    m * (m / zeroes as f64).ln()
117                } else {
118                    raw
119                };
120                estimate.round().max(1.0) as u64
121            }
122        }
123    }
124}
125
126fn canonical_table_stats_schema() -> Schema {
127    let mut schema = SchemaBuilder::new(SYS_TABLE_STATS)
128        .add_with_constraints("id", DataType::Integer, false, true, true, None, None)
129        .add("table_name", DataType::Text)
130        .add_with_constraints(
131            "row_count",
132            DataType::Integer,
133            false,
134            false,
135            false,
136            Some("0".to_string()),
137            None,
138        )
139        .set_last_default_value(Some(Value::Integer(0)))
140        .add_with_constraints(
141            "page_count",
142            DataType::Integer,
143            false,
144            false,
145            false,
146            Some("0".to_string()),
147            None,
148        )
149        .set_last_default_value(Some(Value::Integer(0)))
150        .add_with_constraints(
151            "avg_row_size",
152            DataType::Integer,
153            false,
154            false,
155            false,
156            Some("0".to_string()),
157            None,
158        )
159        .set_last_default_value(Some(Value::Integer(0)))
160        .add_nullable("last_analyzed", DataType::Timestamp)
161        .build();
162    schema
163        .register_primary_key_constraint(vec!["id".to_owned()])
164        .expect("canonical statistics primary key must be valid");
165    schema
166        .register_unique_constraint(vec!["table_name".to_owned()])
167        .expect("canonical statistics unique key must be valid");
168    schema
169}
170
171fn canonical_column_stats_schema() -> Schema {
172    let mut schema = SchemaBuilder::new(SYS_COLUMN_STATS)
173        .add_with_constraints("id", DataType::Integer, false, true, true, None, None)
174        .add("table_name", DataType::Text)
175        .add("column_name", DataType::Text)
176        .add_with_constraints(
177            "null_count",
178            DataType::Integer,
179            false,
180            false,
181            false,
182            Some("0".to_string()),
183            None,
184        )
185        .set_last_default_value(Some(Value::Integer(0)))
186        .add_with_constraints(
187            "distinct_count",
188            DataType::Integer,
189            false,
190            false,
191            false,
192            Some("0".to_string()),
193            None,
194        )
195        .set_last_default_value(Some(Value::Integer(0)))
196        .add_nullable("min_value", DataType::Text)
197        .add_nullable("max_value", DataType::Text)
198        .add_with_constraints(
199            "avg_width",
200            DataType::Integer,
201            false,
202            false,
203            false,
204            Some("0".to_string()),
205            None,
206        )
207        .set_last_default_value(Some(Value::Integer(0)))
208        .add_nullable("histogram", DataType::Text)
209        .build();
210    schema
211        .register_primary_key_constraint(vec!["id".to_owned()])
212        .expect("canonical column-statistics primary key must be valid");
213    schema
214}
215
216fn validate_statistics_schema(actual: &Schema, expected: &Schema) -> Result<()> {
217    if actual.columns != expected.columns
218        || actual.foreign_keys != expected.foreign_keys
219        || actual.table_checks != expected.table_checks
220    {
221        return Err(Error::invalid_argument(format!(
222            "system statistics table '{}' has an incompatible schema",
223            actual.table_name
224        )));
225    }
226    Ok(())
227}
228
229fn stage_statistics_table_catalog(
230    catalog: &mut crate::catalog::DdlTransaction,
231    schema: &mut Schema,
232    create_sql: &str,
233) -> Result<()> {
234    schema.ensure_catalog_identity();
235    let table_id = ObjectId::from_user_bytes(schema.catalog_id())
236        .map_err(|error| Error::internal(format!("statistics catalog ID rejected: {error}")))?;
237    let mut statements =
238        radixdb_sql::parse_sql(create_sql).map_err(|error| Error::Parse(error.to_string()))?;
239    if statements.len() != 1 {
240        return Err(Error::internal(
241            "statistics bootstrap must contain one CREATE TABLE statement",
242        ));
243    }
244    catalog.stage_statement_with_object_ids(
245        statements
246            .pop()
247            .expect("single statistics CREATE TABLE statement exists"),
248        [table_id],
249    )
250}
251
252#[derive(Default)]
253struct ColumnStatsAccumulator {
254    null_count: i64,
255    distinct: BoundedDistinct,
256    min_value: Option<Value>,
257    max_value: Option<Value>,
258    total_width: usize,
259    numeric_seen: u64,
260    histogram_sample: Vec<Value>,
261}
262
263impl ColumnStatsAccumulator {
264    fn update(&mut self, value: Option<&Value>, width: usize, column_index: usize) {
265        self.total_width = self.total_width.saturating_add(width);
266        let Some(value) = value else {
267            self.null_count += 1;
268            return;
269        };
270        if value.is_null() {
271            self.null_count += 1;
272            return;
273        }
274
275        self.distinct.insert(value);
276        if self
277            .min_value
278            .as_ref()
279            .is_none_or(|minimum| value < minimum)
280        {
281            self.min_value = Some(value.clone());
282        }
283        if self
284            .max_value
285            .as_ref()
286            .is_none_or(|maximum| value > maximum)
287        {
288            self.max_value = Some(value.clone());
289        }
290
291        if matches!(value, Value::Integer(_) | Value::Float(_)) {
292            self.numeric_seen += 1;
293            if self.histogram_sample.len() < DEFAULT_SAMPLE_SIZE {
294                self.histogram_sample.push(value.clone());
295            } else {
296                let slot = deterministic_reservoir_slot(self.numeric_seen, column_index);
297                if slot < DEFAULT_SAMPLE_SIZE as u64 {
298                    self.histogram_sample[slot as usize] = value.clone();
299                }
300            }
301        }
302    }
303
304    fn finish(mut self, row_count: usize) -> CollectedColumnStats {
305        self.histogram_sample.sort();
306        let histogram = (self.histogram_sample.len() >= DEFAULT_HISTOGRAM_BUCKETS * 2)
307            .then(|| {
308                Histogram::from_sorted_sample(
309                    &self.histogram_sample,
310                    DEFAULT_HISTOGRAM_BUCKETS,
311                    self.numeric_seen,
312                )
313            })
314            .flatten();
315        (
316            self.null_count,
317            self.distinct.estimate().min(row_count as u64) as i64,
318            self.min_value,
319            self.max_value,
320            self.total_width.checked_div(row_count).unwrap_or(0) as i64,
321            histogram,
322        )
323    }
324}
325
326fn deterministic_reservoir_slot(seen: u64, column_index: usize) -> u64 {
327    let mut value = seen ^ (column_index as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
328    value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
329    value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
330    (value ^ (value >> 31)) % seen.max(1)
331}
332
333impl Executor {
334    /// Execute ANALYZE statement
335    ///
336    /// Collects statistics for the specified table (or all tables if none specified)
337    /// and stores them in the system statistics tables.
338    pub(crate) fn execute_analyze(
339        &self,
340        stmt: &AnalyzeStatement,
341        _ctx: &ExecutionContext,
342    ) -> Result<Box<dyn QueryResult>> {
343        if self.has_active_transaction() {
344            return Err(Error::NotSupported(
345                "ANALYZE owns an atomic statistics transaction and cannot run inside an explicit transaction"
346                    .to_string(),
347            ));
348        }
349
350        // Ensure system tables exist
351        self.ensure_stats_tables_exist()?;
352
353        // Get list of tables to analyze
354        let tables_to_analyze: Vec<String> = if let Some(ref table_name) = stmt.table_name {
355            // Analyze specific table
356            vec![table_name.to_string()]
357        } else {
358            // Analyze all tables - need a transaction to list tables
359            let tx = self.engine.begin_transaction()?;
360            let all_tables = tx.list_tables()?;
361            all_tables
362                .into_iter()
363                .filter(|name| !is_stats_table(name))
364                .collect()
365        };
366
367        let targeted = stmt.table_name.is_some();
368        let mut analyzed_count = 0;
369        let mut failures = Vec::new();
370
371        for table_name in &tables_to_analyze {
372            // Skip system tables
373            if is_stats_table(table_name) {
374                continue;
375            }
376
377            // Begin a transaction for this table's analysis
378            let mut tx = self.engine.begin_transaction()?;
379
380            let success = match self.analyze_table(&mut *tx, table_name) {
381                Ok(publication) => {
382                    tx.commit()?;
383                    publication.table.set_zone_maps(publication.zone_maps);
384                    analyzed_count += 1;
385                    true
386                }
387                Err(e) => {
388                    let _ = tx.rollback();
389                    if targeted {
390                        return Err(e);
391                    }
392                    failures.push(format!("{table_name}: {e}"));
393                    false
394                }
395            };
396
397            // Invalidate cached statistics AFTER transaction is dropped
398            // This avoids potential lock ordering issues between transaction locks
399            // and stats_cache write lock
400            if success {
401                self.get_query_planner().invalidate_stats_cache(table_name);
402            }
403        }
404
405        if !failures.is_empty() {
406            return Err(Error::internal(format!(
407                "ANALYZE completed with {} failed table(s): {}",
408                failures.len(),
409                failures.join("; ")
410            )));
411        }
412
413        // Return result showing how many tables were analyzed
414        let columns = vec!["tables_analyzed".to_string()];
415        let mut rows = RowVec::with_capacity(1);
416        rows.push((0, Row::from_values(vec![Value::Integer(analyzed_count)])));
417
418        Ok(Box::new(ExecutorResult::new(columns, rows)))
419    }
420
421    /// Ensure the system statistics tables exist
422    fn ensure_stats_tables_exist(&self) -> Result<()> {
423        let mut tx = self.engine.begin_transaction()?;
424        let tables = tx.list_tables()?;
425        let has_table_stats = tables
426            .iter()
427            .any(|t| t.eq_ignore_ascii_case(SYS_TABLE_STATS));
428        let has_column_stats = tables
429            .iter()
430            .any(|t| t.eq_ignore_ascii_case(SYS_COLUMN_STATS));
431        let mut expected_table = canonical_table_stats_schema();
432        let mut expected_column = canonical_column_stats_schema();
433        if has_table_stats {
434            let table = tx.get_table(SYS_TABLE_STATS)?;
435            validate_statistics_schema(table.schema(), &expected_table)?;
436            let index = table
437                .get_indexes()
438                .into_iter()
439                .find(|index| {
440                    index.is_unique()
441                        && index.column_names().len() == 1
442                        && index.column_names()[0].eq_ignore_ascii_case("table_name")
443                })
444                .ok_or_else(|| {
445                    Error::invalid_argument(
446                        "system table '_sys_table_stats' is missing its table_name UNIQUE index",
447                    )
448                })?;
449            debug_assert!(index.is_unique());
450        }
451        if has_column_stats {
452            let table = tx.get_table(SYS_COLUMN_STATS)?;
453            validate_statistics_schema(table.schema(), &expected_column)?;
454        }
455        if has_table_stats && has_column_stats {
456            return tx.commit();
457        }
458
459        let generation = self.engine.pin_catalog()?;
460        let mut catalog = crate::catalog::DdlTransaction::begin_shared(generation);
461        if !has_table_stats {
462            stage_statistics_table_catalog(
463                &mut catalog,
464                &mut expected_table,
465                CREATE_TABLE_STATS_SQL,
466            )?;
467            tx.create_table(SYS_TABLE_STATS, expected_table)?;
468            tx.create_table_index(
469                SYS_TABLE_STATS,
470                "uq__sys_table_stats_table_name",
471                &["table_name".to_string()],
472                true,
473            )?;
474        }
475        if !has_column_stats {
476            stage_statistics_table_catalog(
477                &mut catalog,
478                &mut expected_column,
479                CREATE_COLUMN_STATS_SQL,
480            )?;
481            tx.create_table(SYS_COLUMN_STATS, expected_column)?;
482        }
483        if let Some(mutation) = catalog.pending_mutation()? {
484            tx.stage_catalog_mutation(mutation)?;
485        }
486        tx.commit()
487    }
488
489    /// Analyze a single table and update statistics
490    fn analyze_table(
491        &self,
492        tx: &mut dyn Transaction,
493        table_name: &str,
494    ) -> Result<PendingStatisticsPublication> {
495        let table = tx.get_table(table_name)?;
496        let schema = table.schema().clone();
497        let zone_map_generation = table.zone_map_generation();
498
499        let mut zone_map_builder =
500            ZoneMapBuilder::new_for_generation(DEFAULT_SEGMENT_SIZE, zone_map_generation);
501        let mut column_stats: Vec<ColumnStatsAccumulator> = (0..schema.columns.len())
502            .map(|_| ColumnStatsAccumulator::default())
503            .collect();
504        let mut row_count = 0usize;
505        let mut total_size = 0usize;
506
507        // One snapshot walk owns every persisted denominator. Native tables
508        // stream this callback directly from MVCC/artifact-backed owners; only bounded
509        // sketches/reservoirs and the zone-map generation remain resident.
510        table.visit_visible_rows(&mut |row_id, row| {
511            row_count = row_count.saturating_add(1);
512            let row_size = self.estimate_row_size(&row);
513            total_size = total_size.saturating_add(row_size);
514            zone_map_builder.add_row_from_schema_with_id(row_id, &schema, &row);
515            for (column_index, accumulator) in column_stats.iter_mut().enumerate() {
516                let value = row.get(column_index);
517                let width = value.map_or(1, |value| self.estimate_value_size(value));
518                accumulator.update(value, width, column_index);
519            }
520            Ok(())
521        })?;
522
523        let zone_maps = zone_map_builder.build();
524        let avg_row_size = total_size.checked_div(row_count).unwrap_or(0);
525
526        // Estimate page count (assuming 8KB pages)
527        let page_count = total_size.div_ceil(8192).max(1);
528        // Collect column statistics before dropping table
529        let column_stats_list: Vec<_> = schema
530            .columns
531            .iter()
532            .zip(column_stats)
533            .map(|(column, stats)| (column.name.clone(), stats.finish(row_count)))
534            .collect();
535
536        let row_count = i64::try_from(row_count)
537            .map_err(|_| Error::invalid_argument("ANALYZE row_count exceeds INTEGER domain"))?;
538        let page_count = i64::try_from(page_count)
539            .map_err(|_| Error::invalid_argument("ANALYZE page_count exceeds INTEGER domain"))?;
540        let avg_row_size = i64::try_from(avg_row_size)
541            .map_err(|_| Error::invalid_argument("ANALYZE avg_row_size exceeds INTEGER domain"))?;
542
543        self.replace_statistics(
544            tx,
545            table_name,
546            row_count,
547            page_count,
548            avg_row_size,
549            &column_stats_list,
550        )?;
551
552        Ok(PendingStatisticsPublication { table, zone_maps })
553    }
554
555    /// Estimate size of a row in bytes
556    fn estimate_row_size(&self, row: &Row) -> usize {
557        row.iter().map(|v| self.estimate_value_size(v)).sum()
558    }
559
560    /// Estimate size of a value in bytes
561    fn estimate_value_size(&self, value: &Value) -> usize {
562        match value {
563            Value::Null(_) => 1,
564            Value::Boolean(_) => 1,
565            Value::Integer(_) => 8,
566            Value::Float(_) => 8,
567            Value::Text(s) => s.len() + 4, // string + length prefix
568            Value::Timestamp(_) => 8,
569            Value::Extension(data) => data.len() + 4,
570        }
571    }
572
573    fn statistics_row_ids(
574        table: &dyn Table,
575        table_name_column: usize,
576        table_name: &str,
577    ) -> Result<Vec<i64>> {
578        let rows = table.collect_all_rows(None)?;
579        Ok(rows
580            .iter()
581            .filter_map(|(row_id, row)| match row.get(table_name_column) {
582                Some(Value::Text(name)) if name.eq_ignore_ascii_case(table_name) => Some(*row_id),
583                _ => None,
584            })
585            .collect())
586    }
587
588    fn delete_statistics_rows(
589        table: &mut dyn Table,
590        table_name_column: usize,
591        table_name: &str,
592    ) -> Result<()> {
593        let row_ids = Self::statistics_row_ids(table, table_name_column, table_name)?;
594        if !row_ids.is_empty() {
595            table.delete_by_row_ids(&row_ids)?;
596        }
597        Ok(())
598    }
599
600    /// Replace one table's complete statistics generation inside the owning
601    /// storage transaction. Any late table/column write failure therefore
602    /// rolls back the entire durable generation.
603    fn replace_statistics(
604        &self,
605        tx: &mut dyn Transaction,
606        table_name: &str,
607        row_count: i64,
608        page_count: i64,
609        avg_row_size: i64,
610        columns: &[(String, CollectedColumnStats)],
611    ) -> Result<()> {
612        let now = system_time_now()
613            .duration_since(UNIX_EPOCH)
614            .map(|d| d.as_secs() as i64)
615            .unwrap_or(0);
616
617        let analyzed_at = chrono::DateTime::from_timestamp(now, 0)
618            .map(Value::timestamp)
619            .ok_or_else(|| Error::internal("ANALYZE timestamp is outside chrono range"))?;
620
621        let mut table_stats = tx.get_table(SYS_TABLE_STATS)?;
622        let replacement = Row::from_values(vec![
623            Value::Null(DataType::Integer),
624            Value::text(table_name),
625            Value::Integer(row_count),
626            Value::Integer(page_count),
627            Value::Integer(avg_row_size),
628            analyzed_at,
629        ]);
630        let existing = Self::statistics_row_ids(table_stats.as_ref(), 1, table_name)?;
631        if let Some(&row_id) = existing.first() {
632            let mut replacement = Some(replacement);
633            table_stats.update_by_row_ids(&[row_id], &mut |row| {
634                let mut values = replacement
635                    .take()
636                    .ok_or_else(|| Error::internal("statistics replacement row was reused"))?
637                    .into_values();
638                values[0] = row
639                    .get(0)
640                    .cloned()
641                    .ok_or_else(|| Error::internal("statistics row is missing its primary key"))?;
642                Ok((Row::from_values(values), true))
643            })?;
644            if existing.len() > 1 {
645                table_stats.delete_by_row_ids(&existing[1..])?;
646            }
647        } else {
648            table_stats.insert_discard(replacement)?;
649        }
650        drop(table_stats);
651
652        let mut column_stats = tx.get_table(SYS_COLUMN_STATS)?;
653        Self::delete_statistics_rows(column_stats.as_mut(), 1, table_name)?;
654        for (column_name, stats) in columns {
655            let min_value = stats
656                .2
657                .as_ref()
658                .map(|value| Value::text(encode_statistics_value(value)))
659                .unwrap_or(Value::Null(DataType::Text));
660            let max_value = stats
661                .3
662                .as_ref()
663                .map(|value| Value::text(encode_statistics_value(value)))
664                .unwrap_or(Value::Null(DataType::Text));
665            let histogram = stats
666                .5
667                .as_ref()
668                .map(|histogram| Value::text(histogram.to_json()))
669                .unwrap_or(Value::Null(DataType::Text));
670            column_stats.insert_discard(Row::from_values(vec![
671                Value::Null(DataType::Integer),
672                Value::text(table_name),
673                Value::text(column_name),
674                Value::Integer(stats.0),
675                Value::Integer(stats.1),
676                min_value,
677                max_value,
678                Value::Integer(stats.4),
679                histogram,
680            ]))?;
681        }
682
683        Ok(())
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use std::sync::Arc;
690
691    use super::*;
692    use radixdb_core::DataType;
693    use radixdb_storage::mvcc::engine::MVCCEngine;
694    use radixdb_storage::statistics::{CREATE_TABLE_STATS_SQL, DEFAULT_SAMPLE_SIZE};
695
696    fn executor() -> Executor {
697        let engine = MVCCEngine::in_memory();
698        engine.open_engine().unwrap();
699        Executor::new(Arc::new(engine))
700    }
701
702    fn scalar_i64(executor: &Executor, sql: &str) -> i64 {
703        let mut result = executor.execute(sql).unwrap();
704        assert!(result.next(), "query returned no row: {sql}");
705        result
706            .row()
707            .get(0)
708            .and_then(Value::as_int64)
709            .expect("query did not return an INTEGER scalar")
710    }
711
712    #[test]
713    fn r3_l04_batch_c_sampled_statistics_use_full_table_domain() {
714        let executor = executor();
715        executor
716            .execute("CREATE TABLE sampled_stats (id INTEGER PRIMARY KEY, payload TEXT)")
717            .unwrap();
718
719        let mut tx = executor.begin_transaction().unwrap();
720        let mut table = tx.get_table("sampled_stats").unwrap();
721        let rows = (0..=DEFAULT_SAMPLE_SIZE)
722            .map(|id| {
723                Row::from_values(vec![Value::Integer(id as i64), Value::Null(DataType::Text)])
724            })
725            .collect();
726        table.insert_batch(rows).unwrap();
727        drop(table);
728        tx.commit().unwrap();
729
730        let mut analyzed = executor.execute("ANALYZE sampled_stats").unwrap();
731        assert!(analyzed.next());
732        assert_eq!(analyzed.row().get(0).and_then(Value::as_int64), Some(1));
733
734        assert_eq!(
735            scalar_i64(
736                &executor,
737                "SELECT null_count FROM _sys_column_stats \
738                 WHERE table_name = 'sampled_stats' AND column_name = 'payload'",
739            ),
740            (DEFAULT_SAMPLE_SIZE + 1) as i64,
741        );
742
743        let expected_pages = ((DEFAULT_SAMPLE_SIZE + 1) * 9).div_ceil(8192).max(1) as i64;
744        assert_eq!(
745            scalar_i64(
746                &executor,
747                "SELECT page_count FROM _sys_table_stats WHERE table_name = 'sampled_stats'",
748            ),
749            expected_pages,
750        );
751    }
752
753    #[test]
754    fn r8_l01_batch_g_analyze_accumulators_stay_bounded_above_sample_limit() {
755        let row_count = DEFAULT_SAMPLE_SIZE * 3;
756        let mut stats = ColumnStatsAccumulator::default();
757        for value in 0..row_count {
758            let value = Value::Integer(value as i64);
759            stats.update(Some(&value), 8, 0);
760        }
761
762        assert_eq!(stats.histogram_sample.len(), DEFAULT_SAMPLE_SIZE);
763        assert!(matches!(stats.distinct, BoundedDistinct::Approx(_)));
764        let collected = stats.finish(row_count);
765        assert_eq!(collected.0, 0);
766        assert_eq!(collected.4, 8);
767        assert!(collected.1 > (row_count as i64 * 9 / 10));
768        assert!(collected.1 < (row_count as i64 * 11 / 10));
769        assert_eq!(collected.5.unwrap().total_rows(), row_count as u64);
770    }
771
772    #[test]
773    fn r6_exact_distinct_retains_values_until_a_new_identity_crosses_the_limit() {
774        let mut distinct = BoundedDistinct::default();
775        for value in 0..EXACT_DISTINCT_HASH_LIMIT {
776            distinct.insert(&Value::Integer(value as i64));
777        }
778        distinct.insert(&Value::Float(42.0));
779        assert!(matches!(distinct, BoundedDistinct::Exact(_)));
780        assert_eq!(distinct.estimate(), EXACT_DISTINCT_HASH_LIMIT as u64);
781
782        distinct.insert(&Value::Integer(EXACT_DISTINCT_HASH_LIMIT as i64));
783        assert!(matches!(distinct, BoundedDistinct::Approx(_)));
784    }
785
786    #[test]
787    fn r6_statistics_bootstrap_is_atomic_and_targeted_failure_is_visible() {
788        let subject = executor();
789        subject
790            .execute("CREATE TABLE _sys_column_stats (id INTEGER PRIMARY KEY, broken TEXT)")
791            .unwrap();
792
793        let error = match subject.execute("ANALYZE missing_target") {
794            Err(error) => error,
795            Ok(_) => panic!("targeted ANALYZE must surface catalog/bootstrap failure"),
796        };
797        assert!(error.to_string().contains("incompatible schema"), "{error}");
798        assert!(
799            subject.execute("SELECT * FROM _sys_table_stats").is_err(),
800            "the sibling statistics table was published despite rollback"
801        );
802        assert!(subject
803            .execute("SELECT COUNT(*) FROM _sys_column_stats")
804            .is_ok());
805
806        let clean = executor();
807        let error = match clean.execute("ANALYZE definitely_missing") {
808            Err(error) => error,
809            Ok(_) => panic!("a missing explicit target must never look successful"),
810        };
811        assert!(error.to_string().contains("definitely_missing"), "{error}");
812    }
813
814    #[test]
815    fn r3_l04_batch_c_failed_analyze_publishes_nothing() {
816        let executor = executor();
817        executor
818            .execute("CREATE TABLE analyze_target (id INTEGER PRIMARY KEY, payload INTEGER)")
819            .unwrap();
820        executor
821            .execute("INSERT INTO analyze_target VALUES (1, 10), (2, 20)")
822            .unwrap();
823        executor.execute(CREATE_TABLE_STATS_SQL).unwrap();
824        executor
825            .execute(
826                "CREATE TABLE _sys_column_stats (\
827                    id INTEGER PRIMARY KEY AUTO_INCREMENT, broken TEXT)",
828            )
829            .unwrap();
830
831        {
832            let tx = executor.begin_transaction().unwrap();
833            let table = tx.get_table("analyze_target").unwrap();
834            assert!(table.get_zone_maps().is_none());
835        }
836
837        let error = match executor.execute("ANALYZE analyze_target") {
838            Err(error) => error,
839            Ok(_) => panic!("incompatible statistics catalog must fail closed"),
840        };
841        assert!(error.to_string().contains("incompatible schema"), "{error}");
842
843        assert_eq!(
844            scalar_i64(
845                &executor,
846                "SELECT COUNT(*) FROM _sys_table_stats WHERE table_name = 'analyze_target'",
847            ),
848            0,
849        );
850        let tx = executor.begin_transaction().unwrap();
851        let table = tx.get_table("analyze_target").unwrap();
852        assert!(
853            table.get_zone_maps().is_none(),
854            "failed ANALYZE published a new zone-map generation"
855        );
856    }
857}