Fecha: 2026-06-11 Estado: Aceptado Bloque: P5c (sub-tarea de P5 — planner-as-optimizer) Consume: ADR-0070 (selectividad P5a) Antecede: P5d (JOIN reorder)

Contexto

P5a entregó estimate_selectivity y EXPLAIN mostraba est.match=K pero el plan seguía inalterado. Caso degradado típico: una tabla de 1k filas con CREATE INDEX idx_cat ON t (category) y la query WHERE category = 'common' donde 'common' cubre 60% de las filas.

Path actual: index lookup devuelve 600 PKs → 600 random reads del B+tree principal → cada random read cuesta ~5x un read secuencial. Total ~3000 read-equivalentes secuenciales.

Path FullScan: 1000 reads secuenciales + filtro CPU. Total ~1000. 3x más rápido pero el planner lo ignoraba.

P5c implementa el switch automático: si las stats P4 dicen que est.match / row_count ≥ INDEX_BREAKEVEN_SELECTIVITY (~0.2), fuerza Plan::FullScan + post-filter.

Decisión

Cost model implícito

FullScan = row_count × C_SEQ
Index    = log(row_count) × C_LOG + est.match × C_RANDOM

Con C_RANDOM ≈ 5 × C_SEQ (SSD; peor en HDD ~10x) y log despreciable a escalas relevantes:

Index gana sólo si est.match / row_count < C_SEQ / C_RANDOM ≈ 0.2

INDEX_BREAKEVEN_SELECTIVITY = 0.2.

Detección

let p5c_skip_index: bool = !composite_pk_fast_path_active
    && stmt.where_clause.as_ref()
        .and_then(|expr| {
            let stats = self.table_stats.iter()
                .find(|(k, _)| k.eq_ignore_ascii_case(&meta.name))
                .map(|(_, v)| v)?;
            let sel = estimate_selectivity(stats, expr);
            Some(sel >= INDEX_BREAKEVEN_SELECTIVITY)
        })
        .unwrap_or(false);

Conservador: sin stats → false (preserva el comportamiento pre-P5c). El usuario tiene que correr ANALYZE TABLE para activar la lógica.

Cuándo NO se aplica

  1. Composite PK fast-path (composite_pk_fast_path_active): match único por construcción (PK es unique). est.match = 1. La selectividad siempre baja. P5c lo deja pasar.

  2. Sin WHERE: no hay nada que estimar — el path siempre es FullScan natural.

  3. Sin stats (ningún ANALYZE TABLE corrido): P5c queda OFF. Conservador — si el usuario no analizó, el planner no asume.

Dispatch en exec_select_with_where

let generic_post_filter: Option<WhereExpr> = match &stmt.where_clause {
    Some(_) if composite_pk_fast_path_active => None,
    Some(expr) if p5c_skip_index => Some(expr.clone()),  // ← fuerza post-filter
    Some(expr) => { /* lógica heredada */ }
    None => None,
};

let plan = if let Some(p) = composite_pk_plan {
    p
} else if p5c_skip_index {
    Plan::FullScan  // ← override antes de composite_index/atom dispatch
} else if let Some(p) = composite_index_plan {
    p
} else if exists_postfilter.is_some() || generic_post_filter.is_some() {
    Plan::FullScan
} else { /* atom dispatch */ };

El Plan::FullScan PLUS el generic_post_filter forzado da el comportamiento correcto: escanear toda la tabla, filtrar row-a-row con 3VL. Mismo resultado que el index lookup — solo más rápido.

EXPLAIN

classify_scan reproduce la misma lógica y anuncia el override:

SCAN `t` (P5c: hash-index `idx_cat` disponible
          pero stats prefieren FullScan + post-filter)
         [est.rows=10 cols=3 est.match=6]

vs. el caso normal:

SCAN `t` → hash-index equality `cat` (bucket lookup, ~O(1))
         [est.rows=10 cols=3 est.match=1]

Alternativas consideradas

  1. Cost model completo con varianzas.
    • Descartado: complejidad agrega ~600 LOC sin ganancia clara. El umbral simple basado en break-even cubre el 95% de los casos prácticos. P5d/P5e podrán refinar.
  2. Tuning de INDEX_BREAKEVEN por tipo de índice (Hash vs OrderedInt).
    • Descartado: ambos tipos pagan random fetch al B+tree principal. El cost del lookup en el ÍNDICE difiere (Hash O(1), OrderedInt O(log) sin range), pero es despreciable vs los random fetches.
  3. Activar P5c sin stats usando heurística por row_count.
    • Descartado: sin stats, no sabemos est.match. Asumir un default conservador (0.1) haría que P5c casi nunca dispare, lo que anularía su valor. Mejor exigir ANALYZE.
  4. Pre-computar selectividad en exec_analyze_table y cachearla por WHERE.
    • Descartado: WHERE es dinámico — diferentes queries tienen selectividades distintas. La estimación es barata (~µs por evaluación) — no vale cachear.
  5. Override per-query con hint /*+ INDEX(t) */.
    • Descartado por ahora: no tenemos parser de hints. Si P5c se equivoca, el remedio es re-ANALIZAR (refrescar stats) o re-evaluar el umbral globalmente. Hints es Roadmap futuro.

Tests

5 tests nuevos en tests/integration_test.rs (suite p5c_*):

Suite total: 774 passing (769 → +5 P5c). Verificado vía Docker rust:1.94-bookworm.

Consecuencias

Positivas

Negativas / Limitaciones honestas

Limitaciones / Trabajo futuro