Fecha: 2026-06-10 Estado: Aceptado Bloque: P4 (Fase 3 — Performance / Planeación) Antecede: ADR-0067 (P3b — stats por tabla persistidas) Bumpea: VERSION 32 → 33

Contexto

P3b (ADR-0067) persistió stats agregadas por tabla — row_count y analyzed_at_nanos — pero el planner real (P5) no puede decidir entre planes con solo eso. Necesita:

P4 entrega esos cuatro componentes persistidos en el mismo record ObjectKind::TableStats de P3b, con un format_version interno para permitir extensiones aditivas sin bump global del VERSION.

Importante: P4 solo recolecta y persiste las stats. El planner no las consume todavía — eso es P5 / P5b. EXPLAIN sí muestra que están presentes (anota cols=N) para verificación operativa.

Decisión

ColumnStats (catalog.rs)

pub struct ColumnStats {
    pub name: String,
    pub null_count: u64,
    pub ndv: u64,                          // estimado HLL
    pub mcv: Vec<(StatsValue, u64)>,       // top-K=10 por frecuencia desc
    pub histogram: Vec<HistogramBucket>,   // equi-depth ~16 buckets
}

pub struct HistogramBucket {
    pub lower: StatsValue,
    pub upper: StatsValue,
    pub count: u64,
}

pub enum StatsValue {
    Null, Integer(i64), Float(f64), Bool(bool),
    String(String), Decimal { value: i128, scale: u8 },
}

StatsValue excluye Value::Bytes (BLOB sin semántica de orden ni equality útil para el planner) y vive en catalog para mantener la capa independiente del frontend SQL — mismo patrón que DefaultLiteral.

StatsMeta extendido

pub struct StatsMeta {
    pub name: String,
    pub row_count: u64,
    pub analyzed_at_nanos: u128,
    pub columns: Vec<ColumnStats>,  // ← nuevo en P4
}

Layout serializado (apéndice al payload P3b):

[ P3b: name (string) | row_count (u64) | analyzed_at_nanos (u128) ]
[ P4: format_version (u8 = 1) | column_count (u16) | per-col: ColumnStats ]

HyperLogLog (m = 256, b = 8 bits del hash para el índice)

MCV top-K = 10

Histograma equi-depth ~16 buckets

exec_analyze_table

Un solo full-scan del B+tree:

for kv in scan_rows(root_page):
    let decoded = decode_row(&meta, &kv.value)?;
    for (i, col) in meta.columns.iter().enumerate():
        collectors[i].observe(decoded.get(&normalize_ident(&col.name)));

Post-scan: cada ColumnCollector::finalize() produce un ColumnStats.

Costo: O(rows × cols). Para 100k filas × 5 cols → ~500k observaciones, en el orden de cientos de milisegundos. Aceptable porque ANALYZE es explícito y manual.

Bump VERSION 32 → 33

Política conservadora (storage.rs): no hay auto-upgrade entre versiones. Aunque el cambio es aditivo (no toca records existentes), DBs V32 son rechazadas con [GBY-1003] UNSUPPORTED_FORMAT_VERSION y el mensaje estándar de “dump + re-create”.

Razón: un binario V32 viendo el format_version byte después de analyzed_at_nanos lo trataría como basura (intentaría seguir parseando o crashearía al re-compactar el catálogo).

Alternativas consideradas

  1. Particionar en P4a/b/c (NDV, luego MCV, luego histograma).
    • Descartado: tres bumps de VERSION (32→33→34→35) cuestan tres reset de DBs cada uno. Un solo bump con format_version interno futuro-proof cubre las tres features de un saque.
  2. No bumpear VERSION (zero-bump como P1/P2/P3).
    • Descartado: el format_version byte aparece en una posición fija del payload existente; binarios viejos lo confundirían.
  3. Reservoir sampling aleatorio (Vitter Algorithm R).
    • Descartado: RNG en exec_analyze_table rompería tests determinísticos. La toma de los primeros 10k es sesgada al comienzo del scan pero el scan ya itera en orden de PK — para un histograma equi-depth post-sort el sesgo es despreciable.
  4. Sketch HLL++ con bias correction de Google (2007).
    • Descartado: complejidad agrega ~200 LOC y el error de HLL básico ya es suficiente para guiar el planner. Reevaluar si P5 mide regresiones por bias.
  5. Top-K vía Misra-Gries / Space-Saving (streaming exact).
    • Descartado: el HashMap con cap simple es más barato a la cardinalidad de columnas reales (< 50k distinct típicos). Si una columna excede el cap, perdemos accuracy en el long tail, pero el top-K queda correcto porque las frecuentes se ven temprano.

Tests

7 tests nuevos en tests/integration_test.rs (suite p4_*):

Consecuencias

Positivas

Negativas / Limitaciones honestas

Limitaciones / Trabajo futuro