Fecha: 2026-06-15 Estado: Aceptado Bloque: M12 (TCL — recuperación parcial dentro de transacción) Origen: docs/TAREAS_PENDIENTES.md §6.5 — declarado como pre-requisito de M13 (cross-request tx en server HTTP). Refina: Bloque T (BEGIN/COMMIT/ROLLBACK).

Contexto

Antes de M12, gabysql tenía solo el track básico: BEGIN/COMMIT/ROLLBACK. Si hacías 50 INSERTs y el #30 fallaba, ROLLBACK descartaba los 50.

SAVEPOINT es estándar SQL (ANSI SQL:2003, soportado por PostgreSQL, SQLite, MySQL, Oracle). Permite marcar checkpoints dentro de una transacción y revertir parcialmente. Cualquier cliente serio (ORMs, herramientas de migración, frameworks de testing) lo asume disponible.

Hasta hoy, intentar SAVEPOINT fallaba con [GBY-4029] TX_BEGIN_DOUBLE (el mensaje incluso decía “savepoints aún no soportados”). Esta entrega lo soporta.

Decisión

3 statements SQL nuevos + soporte en el Pager + dispatch en el Engine.

SQL aceptado

SAVEPOINT my_point                              -- marca checkpoint
ROLLBACK TO [SAVEPOINT] my_point                -- revierte hasta el checkpoint
RELEASE [SAVEPOINT] my_point                    -- libera el checkpoint

SAVEPOINT es token obligatorio en SAVEPOINT name. En ROLLBACK TO y RELEASE la palabra SAVEPOINT es opcional (compatibilidad con PostgreSQL).

Semántica

Implementación

Pager (src/storage.rs):

struct Savepoint {
    name: String,
    header: Header,
    cache_snapshot: HashMap<u32, CachedPage>,
}

pub struct Pager {
    // ... campos existentes ...
    savepoints: Vec<Savepoint>,
}

impl Pager {
    pub fn savepoint(&mut self, name: String) -> DbResult<()> { ... }
    pub fn rollback_to_savepoint(&mut self, name: &str) -> DbResult<()> { ... }
    pub fn release_savepoint(&mut self, name: &str) -> DbResult<()> { ... }
}

PageCache (src/storage.rs): nuevos full_snapshot() (clone de todas las páginas, dirty y clean, con su flag) y restore_snapshot() (reemplaza contenido del cache).

Engine (src/sql.rs): 3 nuevas variantes en enum Statement (Savepoint(String), RollbackToSavepoint(String), ReleaseSavepoint(String)) + dispatch + 3 exec_* methods que verifican explicit_tx y forwardean al Pager.

Parser: 5 lookaheads adicionales:

Error codes nuevos (src/errors.rs):

Costo de memoria

full_snapshot() clona TODO el cache. Con cache default de 1024 páginas × 4 KB = ~4 MB por savepoint. Para un workload típico (2–5 savepoints simultáneos), ~10–20 MB extra. Aceptable.

Optimización futura: undo log por-página en vez de snapshot completo. Cuesta sustancialmente más código; diferible hasta que la memoria se vuelva un cuello.

Consecuencias

Positivas

Negativas / deuda

Alternativas consideradas

  1. Undo log por-página. Más eficiente en memoria pero substancialmente más complejo de implementar correctamente (apply/un-apply ordering, manejo de allocations). Diferible.
  2. Savepoints solo por nombre stack (FIFO/LIFO) sin permitir nombres custom. SQLite por defecto los permite por nombre y los clientes esperan eso. Rechazado.
  3. Hacer RELEASE también revertir (semántica no-ANSI). Rechazado: PostgreSQL/SQLite ya tienen la semántica correcta y los clientes asumen eso.

Tests añadidos

Cinco tests m12_* en tests/integration_test.rs:

Suite total: 819 → 824 (+5 tests; el Pager proptest tampoco se rompe porque el modelo BTreeMap<id, v> no toca savepoints).

Referencias

Trabajo futuro