1use 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 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 self.ensure_stats_tables_exist()?;
352
353 let tables_to_analyze: Vec<String> = if let Some(ref table_name) = stmt.table_name {
355 vec![table_name.to_string()]
357 } else {
358 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 if is_stats_table(table_name) {
374 continue;
375 }
376
377 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 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 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 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 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 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 let page_count = total_size.div_ceil(8192).max(1);
528 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 fn estimate_row_size(&self, row: &Row) -> usize {
557 row.iter().map(|v| self.estimate_value_size(v)).sum()
558 }
559
560 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, 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 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}