Estado: ✅ Aceptada Fecha: 2026-05-18 Contexto que la motiva: ítem residual “mejoras de full scan para tablas medianas” de Fase 1 → primer paso hacia un cursor que coopere con la jerarquía de caches (kernel + PageCache).

🧭 Contexto

LeafCursor (ADR-0008) ya hace el trabajo correcto desde el punto de vista algorítmico: SELECT … LIMIT N deja de materializar la tabla completa y consume O(N + offset) páginas en disco en vez de O(filas_totales).

Pero el patrón de acceso del cursor le presenta al kernel — y a nuestro propio PageCache (ADR-0009) — un perfil de I/O stop-and-go:

read leaf 1 (disk syscall) → decode 100 rows → yield 100 rows
... pausa larga mientras el caller procesa ...
read leaf 2 (disk syscall) → decode → yield ...

Dos consecuencias:

  1. El readahead del kernel no se “engancha”: las heurísticas sequential-readahead de Linux/Windows necesitan ver lecturas back-to-back para detectar un patrón streaming y prefetchear. Con una pausa larga entre leaf N y leaf N+1, la heurística puede no dispararse y cada page_data se paga como I/O frío.
  2. Nuestro PageCache arranca vacío para cada leaf transition: la primera lectura post-transición es siempre miss → syscall → CRC verify → cache populate.

Para un scan grande la latencia acumulada de “miss en cada leaf transition” se nota. La mejora barata es adelantar la lectura de la próxima hoja al final de la carga de la actual: el syscall ocurre cuando el cursor “no lo necesita todavía”, y para cuando el caller termina de procesar la hoja actual, la siguiente ya está en PageCache.

💡 Decisión

Modificación de 4 líneas en src/bptree.rs:

fn load_current(&mut self) -> DbResult<()> {
    if self.next_leaf == 0 { self.done = true; return Ok(()); }
    let page = self.pager.page_data(self.next_leaf)?;
    let leaf = decode_leaf(&page)?;
    self.buf = leaf.kvs;
    self.pos = 0;
    self.next_leaf = leaf.next;

    // Prefetch one leaf ahead — synchronous read into PageCache.
    if self.next_leaf != 0 {
        let _ = self.pager.page_data(self.next_leaf);
    }
    Ok(())
}

Properties:

🤔 Alternativas evaluadas

  1. Lookahead de N>1 hojas (N=4): más agresivo, mejor para scans largos. Pero (a) si el LIMIT es pequeño, desperdicia más; (b) no hay benchmarks que justifiquen N>1 todavía. Decisión: arrancar con N=1, sintonizar con gabybench.

  2. Bulk read syscall (pread de 16 páginas contiguas en un único read): el win real para scans largos, porque elimina N-1 syscalls y deja el filesystem firmware pipelinear. Pero requiere extender Pager con una API de bulk-read y manejar el caso de páginas no contiguas (los leaves no están garantizadamente adyacentes en el archivo). Diferida hasta que gabybench muestre que es el cuello de botella.

  3. Async I/O (io_uring en Linux, IOCP en Windows): saltar la simulación sincrónica del prefetch y hacer overlap real con compute. Imposible sin un runtime async (tokio, etc.) o sin wrappers manuales no-portables. Viola ADR-0001.

  4. No hacer nada y esperar gabybench: argumentable. El contraargumento: la mejora es de 4 líneas, no toca formato, no rompe ningún test, y al menos calienta la cache antes del próximo acceso — incluso si el efecto medible es pequeño, la dirección es correcta y no cuesta nada.

✅ Consecuencias

Positivas:

Negativas / a vigilar:

🔗 Referencias