Fecha: 2026-06-11 Estado: Aceptado Bloque: R1 (reparación post-P5) Origen: docs/ANALISIS_POST_P5.md §3 R1 — tensión #2.1 Consume: ADR-0067 (P3b — analyzed_at_nanos ya persiste)

Contexto

P5c (ADR-0071) introdujo la primera decisión de plan que depende de stats. Si las stats están obsoletas, el plan se equivoca silenciosamente. Esto fue identificado como la tensión #1 en el análisis post-P5 (docs/ANALISIS_POST_P5.md).

El dato analyzed_at_nanos ya estaba persistido en cada StatsMeta (P3b/2026-06-09) pero nadie lo consumía. Esta brecha — “tener el dato y no usarlo” — es exactamente lo que el análisis identificó como prioridad alta.

R1 conecta el cable.

Decisión

Threshold: 7 días

const STATS_STALE_THRESHOLD_SECS: u64 = 7 * 24 * 60 * 60;

Por qué 7 días:

Futuro: ajustable per-tabla con ALTER TABLE ... SET STATS_TTL=Xd (no en este push).

Helpers nuevos

fn stats_age_secs(analyzed_at_nanos: u128) -> u64;
fn stats_are_stale(analyzed_at_nanos: u128) -> bool;
fn format_stats_age(age_secs: u64) -> String;

stats_age_secs devuelve 0 si el reloj está atrasado (sesgo conservador, no marca falsos positivos de stale por skew de reloj).

format_stats_age renderiza con resolución útil:

EXPLAIN annotation

stats_annotation extendida — cuando hay stats y age ≥ 60s, agrega stats.age=Xd Yh y opcionalmente ` STALE`:

[est.rows=10 cols=3 est.match=6 stats.age=3d 5h]      ← fresca aún
[est.rows=10 cols=3 est.match=6 stats.age=10d 0h STALE] ← stale
[est.rows=3 est.match=1]                              ← <60s, no se muestra

Bypass de P5c

let p5c_skip_index = ...
    .and_then(|expr| {
        let stats = ...;
        if stats_are_stale(stats.analyzed_at_nanos) {
            return Some(false);  // bow out
        }
        let sel = estimate_selectivity(stats, expr);
        Some(sel >= INDEX_BREAKEVEN_SELECTIVITY)
    });

Cuando las stats son stale, P5c no se aplica. El plan vuelve al comportamiento conservador pre-P5c (siempre preferir índice si existe). Argumento: mejor “lento por usar índice cuando no convenía” que “lento por usar FullScan basándose en datos viejos que sub-estiman selectividad”. El primer caso es contenido; el segundo puede escalar.

Aplica tanto en exec_select_with_where como en classify_scan (EXPLAIN) — ambos consultan el mismo helper, decisión consistente.

Alternativas consideradas

  1. Threshold configurable per-instancia vía variable de entorno GABYSQL_STATS_TTL_DAYS.
    • Diferido. La constante en código es buena para ahora; cuando haya señales de que 7 días no encaja para algún workload, lo hacemos config.
  2. Bypass parcial: degradar progresivamente la confianza con el tiempo (mezcla lineal entre sel real y DEFAULT_EQ_SELECTIVITY según age / threshold).
    • Descartado por complejidad. La regla binaria “stale o no” es legible. Si se ve que es muy abrupto, refinamos.
  3. Re-ANALYZE automático cuando se detecta stale (auto-ANALYZE).
    • Diferido a M1 (auto-ANALYZE, declarado en docs/ANALISIS_POST_P5.md). Requiere scheduler que aquí no tenemos.
  4. Warning explícito en el message de la query (“⚠ stats stale sobre tabla t; considerá ANALYZE TABLE t”).
    • Considerado y descartado para este push: agregar warnings en paths normales complica el ResultSet shape. EXPLAIN ya lo dice — el usuario que mira EXPLAIN ve STALE.
  5. Stats stale → no anotar est.match (no estimar).
    • Considerado: si decimos stale, también el est.match está equivocado. Pero seguir mostrándolo da una pista útil (“esta era la última estimación conocida”). Lo dejamos.

Tests

4 tests nuevos en tests/integration_test.rs (suite r1_*):

Helper de test r1_overwrite_stats_timestamp(db, table, age_secs) abre el catálogo directamente y reescribe StatsMeta.analyzed_at_nanos para simular el paso del tiempo determinísticamente. Sin esto, el test necesitaría sleep(7 días).

Suite total: 785 passing (781 → +4 R1). Verificado vía Docker rust:1.94-bookworm.

Consecuencias

Positivas

Negativas / Limitaciones honestas

Limitaciones / Trabajo futuro