Fecha: 2026-06-11 Estado: Aceptado Bloque: R8 (reparación post-P5) Origen: docs/ANALISIS_POST_P5.md §3 R8 — asimetría P5b Reusa: ADR-0069 helpers find_matching_composite_index + composite_index_lookup_pks

Contexto

P5b (ADR-0069) entregó composite secondary index lookup solo para SELECT. El path exec_select_with_where gana el fast-path, pero resolve_target_pks (que usan exec_update y exec_delete) seguía con FullScan + 3VL como único path para WHERE compuesto.

Asimetría observable:

-- Rápido (P5b):
SELECT id FROM lines WHERE qty = 5 AND precio = 100;
-- 8.7 µs sobre 100k filas (bench M2)

-- Lento (pre-R8):
UPDATE lines SET val = 99 WHERE qty = 5 AND precio = 100;
-- ~150 ms sobre 100k filas (FullScan completo)

El motor “sabe” lookup-ear el composite index pero no usaba esa información en mutaciones. R8 cierra la asimetría.

Bonus: el mismo path añade composite-PK fast-path para UPDATE/DELETE — pre-R8 también caía a FullScan cuando el WHERE era AND-eq sobre todas las cols de una PK compuesta.

Decisión

Dos fast-paths nuevos en resolve_target_pks

Insertados entre el PK-single fast-path (existente) y el FullScan fallback (existente):

// 1. Composite PK fast-path
if meta.has_composite_pk() {
    if let Some(map) = extract_and_equality_map(where_clause) {
        if map.len() == meta.pk_columns().len() {
            // Compute fingerprint, verify row exists, return single-element Vec.
            ...
        }
    }
}

// 2. Composite secondary index fast-path (reusa helpers P5b)
if let Some(map) = extract_and_equality_map(where_clause) {
    if let Some((idx, fp)) = find_matching_composite_index(meta, &map) {
        let candidate_pks = composite_index_lookup_pks(self.pager, idx.root_page, fp)?;
        // Post-filter via eval_where_expr_single para descartar colisiones FNV
        // + aplicar predicados extra (igual que P5b).
        ...
    }
}

Por qué el post-filter es crítico

El bucket del índice compuesto solo guarda PKs (sin valores). Una colisión FNV-1a-64 — astronómicamente rara pero posible — devolvería una PK que no satisface el WHERE. Sin post-filter, UPDATE/DELETE operaría sobre la fila equivocada.

Caso aún más probable: predicates extra (WHERE qty=5 AND precio=100 AND sku='A' — composite cubre qty+precio pero no sku). El post-filter descarta filas que no matchean sku.

Mismo razonamiento que P5b (ADR-0069) — el post-filter es load-bearing para correctness.

Materializar antes del eval

eval_where_expr_single requiere &mut self, pero Catalog::open(self.pager) mantiene el borrow del pager. Resolución: recolectar todas las (pk, decoded_row) tuplas en un Vec<> mientras el catalog vive, después soltarlo y iterar para eval:

let candidate_rows: Vec<(i64, HashMap<String, Value>)> = {
    let mut catalog = Catalog::open(self.pager);
    let mut rows = Vec::with_capacity(candidate_pks.len());
    for pk in candidate_pks {
        if let Some(bytes) = catalog.get_row(meta.root_page, pk)? {
            rows.push((pk, decode_row(meta, &bytes)?));
        }
    }
    rows
};
// catalog dropped — eval_where_expr_single puede borrowear self

Costo: candidate_pks.len() clones de HashMap. Aceptable: el composite index ya redujo el conjunto candidato; típicamente 1-10 filas, no miles.

Composite PK con 0 matches

Si el fp computado no existe en el B+tree principal, devolvemos Ok((vec![], false)) — NO was_explicit_single_pk=true. Razón semántica: el WHERE original era compuesto (a=1 AND b=2), no pk=N. El caller no debe emitir ROW_NOT_FOUND_FOR_PK (esa semántica es para el pre-E3 caso de pk = literal).

UPDATE/DELETE con 0 matches devuelven rows_affected = 0 — consistente con SQL estándar.

Alternativas consideradas

  1. Refactorizar exec_select_with_where y resolve_target_pks a compartir un único compute_target_pks con todos los fast-paths.
    • Considerado. Descartado por scope creep — exec_select_with_where tiene 6+ fast-paths más (composite PK plan, composite index plan, P5c skip, Range, exists_postfilter, etc.) que resolve_target_pks no necesita. La duplicación parcial es controlada (4 fast-paths vs 6+); refactor mayor para 2 ramas no compensa.
  2. Aplicar P5c (skip-index si est.match alta) también aquí.
    • Diferido a R6. Este push trata únicamente la asimetría P5b → R8. Mezclar ambas cosas oscurece el commit y los tests.
  3. Solo composite secondary, no composite PK.
    • Descartado por simetría. Si exec_select_with_where tiene composite_pk_plan, exec_update / exec_delete deberían tenerlo también. El código adicional es pequeño y reusa el mismo helper encode_composite_key.
  4. Pasar el WhereExpr enviado al eval_where_expr_single ya resuelto (post-RLS) en lugar del original.
    • El where_clause que resolve_target_pks recibe YA tiene el RLS inyectado por exec_update / exec_delete arriba (ver comentarios a build_rls_where). No hay nada que pasar — el callsite ya hace lo correcto.

Tests

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

Suite total: 794 passing (789 → +5 R8). Verificado vía Docker rust:1.94-bookworm.

Hallazgo del proceso: tests inicialmente usaban ORDER BY a, b y fallaron con token inesperado: , — el motor no soporta multi-col ORDER BY (limitación previa, no documentada en este ADR). Tests ajustados para ordenar en Rust después del SELECT.

Consecuencias

Positivas

Negativas / Limitaciones honestas

Limitaciones / Trabajo futuro