wandres.dev
SQLITE EN EL NAVEGADOR II · sobre OPFS, y a qué precio

Durabilidad: qué garantiza volcar a disco

Qué garantiza de verdad un volcado a disco dentro del navegador, qué papel juega el journal frente al modo de escritura anticipada y qué le ocurre a tu base si el usuario cierra la pestaña a media transacción.

⏱ 18 min

La letra D de las cuatro propiedades transaccionales es la que más promete y la que menos se examina. En un servidor tiene un significado razonablemente firme: confirmada la transacción, los bytes están en un medio que sobrevive a un corte de corriente, y el sistema operativo ofrece una llamada que lo garantiza dentro de lo que el hardware permite. Dentro de un navegador esa cadena tiene tres eslabones más —el motor, el proceso del navegador, el sistema de archivos del origen— y cada uno de ellos puede desaparecer por motivos que no existen en un servidor: una pestaña que se cierra, un proceso que el sistema mata por memoria, un presupuesto de almacenamiento que se agota. Esta lección examina qué queda en pie de la promesa y dónde está de verdad la garantía.

🎯 Al terminar esta lección sabrás
  • Distinguir lo que el volcado a disco garantiza de lo que el usuario cree que garantiza.
  • Entender por qué la recuperación no la da el volcado sino el journal, y cómo funciona esa recuperación en OPFS.
  • Conocer las condiciones exactas bajo las que el modo de escritura anticipada es utilizable aquí, y su precio.
  • Predecir el estado de la base cuando la pestaña muere a mitad de una transacción, y verificarlo.

Qué significa volcar a disco aquí

La operación de sincronización del VFS se traduce, ya lo viste, en el volcado del manejador síncrono. Lo que esa llamada garantiza es que los bytes han salido del control de tu código y han llegado a la capa de sistema de archivos que el navegador expone. Lo que no garantiza, y conviene decirlo sin adornos, es la supervivencia frente a cualquier evento situado por encima de esa capa. Hay al menos cuatro que un servidor no conoce.

🕵️

Modo privado

Las ventanas de incógnito o invitado alteran deliberadamente las capacidades de almacenamiento: cuotas menores o ausencia total de persistencia. Los navegadores dificultan a propósito su detección, así que no puedes preguntarlo.

📦

Presupuesto agotado

Cuando el origen supera su cuota, el motor no recibe un error de cuota porque ese error no existe en su contrato con el disco. Recibe un fallo genérico de entrada y salida, en cualquier punto de una escritura.

🧹

Desalojo y limpieza

Todo el nivel 11 sigue vigente. Además, el propio proyecto documenta bases que desaparecen por antivirus, limpiadores de disco, permisos de almacenamiento del navegador o decisiones internas de este.

Corte de corriente

El eslabón clásico. El volcado empuja hacia el hardware, pero cuántas cachés quedan por debajo depende del sistema operativo y del dispositivo, exactamente igual que fuera del navegador.

De esa lista, la única que puedes controlar con una perilla es la última, y la perilla es la de siempre: el ajuste de sincronía del motor decide cuántas veces se llama al volcado durante una transacción. Bajarlo hace que las escrituras vuelen y traslada el riesgo del corte de corriente a tus datos; subirlo al máximo añade volcados adicionales de escaso rendimiento marginal. La posición intermedia es la que casi todo el mundo quiere y casi nadie declara explícitamente, cosa que conviene hacer, porque el valor por defecto no es el mismo en todos los contextos.

-- Declara la politica en lugar de heredarla
PRAGMA synchronous = NORMAL;  -- OFF, NORMAL, FULL, EXTRA
PRAGMA journal_mode;          -- comprueba en que modo estas de verdad

Y conviene ser claro sobre lo que esa perilla no hace, porque es donde más gente se equivoca. Apagar la sincronía del todo no desactiva las transacciones: sigue habiendo journal, sigue habiendo atomicidad frente a la muerte del proceso, y una pestaña que se cierra a mitad sigue revirtiendo limpiamente. Lo que se pierde es la garantía frente a la muerte del sistema, porque el orden en que el sistema operativo lleve los bytes al medio físico deja de estar controlado, y ahí sí puede aparecer una base inconsistente. Dicho de otro modo: en un navegador, el evento que casi siempre te va a ocurrir —que la pestaña desaparezca— está cubierto por el journal en cualquier ajuste, y el evento que casi nunca te va a ocurrir —un corte de corriente— es el único que esa perilla gobierna.

El journal es la garantía, no el volcado

Aquí está el desplazamiento conceptual que hace utilizable todo lo anterior. La atomicidad de una transacción de SQLite no descansa en que los volcados lleguen a tiempo, sino en que exista un fichero paralelo que permita deshacer un cambio a medias. En el modo clásico, antes de tocar una página el motor copia su contenido original a un journal de reversión; solo cuando la transacción se confirma ese journal se elimina. Si el proceso muere en cualquier punto intermedio, el journal sobrevive con las páginas originales dentro, y la siguiente apertura de la base lo detecta, lo aplica al revés y devuelve el fichero al estado anterior a la transacción.

sequenceDiagram
participant M as Motor
participant J as Journal en OPFS
participant D as Base en OPFS
M->>J: copia las paginas originales
M->>D: escribe las paginas nuevas
Note over M,D: aqui muere la pestana
M->>J: en el proximo arranque encuentra el journal
J->>D: restaura las paginas originales
M->>J: elimina el journal y sigue

Lo notable es que ese mecanismo, diseñado para ficheros de un sistema operativo, funciona sin cambios sobre OPFS por una razón que la lección uno ya adelantó: el journal es un fichero como cualquier otro dentro del sistema de archivos del origen. Persiste al cierre de la pestaña, sobrevive a la muerte del worker y está ahí, esperando, la próxima vez que alguien abra la base. La recuperación no necesita que tu código haga nada: es la propia apertura la que la desencadena.

Hay un matiz que merece la pena atender porque conecta con la lección anterior. La reversión se ejecuta al abrir, y abrir requiere el bloqueo exclusivo del fichero. Si otra pestaña lo tiene tomado, la recuperación no ocurre: lo que obtienes es un error de ocupado, exactamente igual que en cualquier otra apertura, y la base sigue esperando a que alguien pueda repararla. No es una situación peligrosa —el estado en disco es consistente, con el journal intacto— pero sí explica un síntoma desconcertante: una base que parece corrupta desde una pestaña y funciona perfectamente desde otra. No estaba corrupta; estaba pendiente de recuperar y sin turno para hacerlo.

// Comprobar en que regimen estas de verdad, y no en el que creias
const modo = db.selectValue("PRAGMA journal_mode");   // delete, wal, memory...
const sinc = db.selectValue("PRAGMA synchronous");    // 0, 1, 2, 3
console.log({ modo, sinc });
💡
Ese fichero extra no es basura, y hay sitios donde hay que contarlo

Dos consecuencias prácticas se derivan de que el journal sea un fichero real. La primera: si inspeccionas OPFS con las herramientas del navegador y encuentras un fichero acompañante junto a tu base, no lo borres para hacer limpieza; puede ser exactamente lo que impide que tus datos queden a medias. La segunda afecta al VFS de conjunto de manejadores reservados: su capacidad se mide en ficheros, y el journal ocupa una plaza como cualquier otro. Dimensionarla contando solo tus bases es el error clásico, y por eso la documentación insiste en pedir al menos el doble del número de bases previstas, y más si el motor usa ficheros temporales para las tablas intermedias.

El modo de escritura anticipada y su letra pequeña

En un servidor, el modo de escritura anticipada es la respuesta obvia: en lugar de copiar las páginas viejas a un journal, se escriben las nuevas a un fichero de registro que se consolida después, y eso permite que los lectores sigan trabajando sobre la versión anterior mientras alguien escribe. Lectores y escritor a la vez, sin bloquearse. Es la mejora que cualquiera busca al llegar aquí.

Aquí no funciona así, y la razón está en la lección uno. Coordinar a varios lectores con un escritor exige una región de memoria compartida donde todos consulten qué páginas están vigentes, y esa coordinación llega al VFS por las funciones de memoria compartida que la compilación a WebAssembly no tiene. La consecuencia está documentada y no admite matices: desde la versión 3.47 el modo puede activarse, pero solo si la conexión declara bloqueo exclusivo inmediatamente después de abrirse y antes de hacer cualquier otra cosa. Es decir, se paga con la única cosa que ese modo iba a comprar.

// El orden importa: exclusivo primero, modo despues
const db = new sqlite3.oo1.OpfsDb("/app.db", "c");
db.exec("PRAGMA locking_mode=exclusive");
db.exec("PRAGMA journal_mode=WAL");

Conviene ser tajante con lo que eso significa. Activar el modo de escritura anticipada sobre el VFS con puente elimina toda su capacidad de concurrencia, que era justamente su ventaja frente al VFS de manejadores reservados. Y no aporta ninguna ventaja de concurrencia a cambio, porque la concurrencia que ese modo habilita es precisamente la que la memoria compartida ausente hacía posible. Queda una única razón legítima para activarlo: sobre el VFS de manejadores reservados, que ya renunciaba a las conexiones simultáneas, la documentación reporta una ligera mejora de rendimiento en algunos entornos. Ligera, en algunos, y sin equivalente medido en el otro VFS. Si eso te parece poco motivo para tocar el modo por defecto, has entendido bien la lección.

flowchart TD
A[Quieres modo de escritura anticipada] --> B[Estas sobre el VFS con puente]
B -->|si| C[Pierdes toda la concurrencia y no ganas ninguna]
B -->|no| D[Estas sobre manejadores reservados]
D --> E[Ya renunciaste a la concurrencia: mide si mejora]
style C fill:#f38ba8,color:#11111b
style E fill:#f9e2af,color:#11111b

La pestaña que se cierra a media transacción

Queda por señalar quién debería activarlo, y la lista es corta. Un proceso de importación masiva que corre una sola vez, sobre el VFS de manejadores reservados, con la base en exclusiva y sin nadie más mirando, es el caso arquetípico: no había concurrencia que perder y la mejora, si aparece en ese entorno, es gratis. Fuera de ese perfil, dejar el modo por defecto es la decisión informada, no la perezosa.

Vamos al escenario concreto, que es el que motiva la lección. El usuario tiene una transacción abierta —una importación, un guardado largo, un lote de inserciones— y cierra la pestaña. El worker que hospedaba la base se termina con el documento que lo creó; nadie llama a cerrar el manejador ni a confirmar nada. Lo que queda en OPFS es un fichero de base modificado a medias y un journal con las páginas originales.

La buena noticia ya la sabes: la próxima apertura detecta ese journal y revierte. La base no queda corrupta, queda en el estado anterior a la transacción. Es el mismo comportamiento que tendría un proceso de servidor al que le hubieran enviado una señal de terminación, y es exactamente el motivo por el que un motor transaccional serio vale lo que cuesta.

Conviene apreciar hasta qué punto eso es una ganga. Compáralo con lo que ocurre en el mismo escenario si tu persistencia es un almacén de objetos al que ibas escribiendo registros de uno en uno: el cierre te deja la mitad escrita y la otra mitad no, sin ninguna marca que distinga un estado del otro, y la reconciliación queda enteramente a cargo de tu código. La atomicidad no es una comodidad sintáctica; es la diferencia entre un estado inválido que hay que detectar y reparar a mano, y un estado válido que aparece solo.

📝
Importar una base ajena la saca del modo de escritura anticipada

Un detalle que sorprende al migrar datos: las utilidades de importación del VFS de manejadores reservados fuerzan la salida del modo de escritura anticipada si la base que traes venía en él. La razón es histórica y se mantiene por compatibilidad, pero el efecto práctico conviene tenerlo presente: un fichero exportado desde un servidor o desde una aplicación de escritorio no llega al navegador con la misma configuración con la que salió. Si tu proceso de arranque asume el modo del origen, comprueba el modo real después de importar en lugar de darlo por heredado.

La mala noticia es la parte que tu código sí tiene que asumir. Lo que se revirtió es trabajo del usuario que ya no existe, y el usuario no distingue entre una transacción revertida y una pérdida de datos: para él escribió algo y no está. La conclusión práctica no es intentar salvar la transacción al cerrar —no hay ningún momento garantizado para hacerlo, y confiar en los eventos de descarga de la página es confiar en algo que el navegador puede saltarse—, sino acortar las transacciones hasta que la ventana de pérdida sea despreciable. Es el mismo consejo que daba la contención, llegando por otro camino: transacciones cortas, lotes troceados, confirmaciones frecuentes. Dos problemas distintos, una sola disciplina.

// Un lote largo troceado en transacciones cortas y reanudables
async function importar(filas, tam = 1000) {
  for (let i = 0; i < filas.length; i += tam) {
    db.exec("BEGIN");
    for (const f of filas.slice(i, i + tam)) insertar(f);
    db.exec(`UPDATE progreso SET hecho = ${i + tam}`);  // en la misma transaccion
    db.exec("COMMIT");
  }
}

El detalle que hace que ese patrón valga algo es que el marcador de progreso se escribe dentro de la misma transacción que los datos. Así el progreso nunca puede adelantarse a lo que de verdad se guardó, y la reanudación tras un cierre inesperado es exacta en lugar de aproximada. Escribirlo fuera, o en otro almacén, reintroduce por la puerta de atrás el problema de dos fuentes de verdad que la transacción existía para eliminar.

Hay un corolario que conviene extraer y que reordena la relación entre este nivel y el 11. La durabilidad que el motor te da es relativa al fichero: garantiza que el fichero pasa de un estado válido a otro y nunca se queda en medio. Lo que no puede garantizar es que el fichero siga existiendo, porque eso depende del presupuesto del origen, del desalojo y de todo lo que aquel nivel estudió. Son dos garantías de naturaleza distinta y ninguna sustituye a la otra: puedes tener transacciones impecables sobre una base que el navegador borra entera el martes siguiente. La respuesta a esa segunda amenaza nunca fue transaccional, fue tener el trabajo del usuario en un segundo lugar.

Queda un caso que conviene no confundir con este. Una pestaña que pasa a la caché de vuelta atrás del navegador no se destruye: se congela, con su worker y sus manejadores dentro. Desde fuera es indistinguible de una pestaña que sigue teniendo la base bloqueada, porque eso es exactamente lo que es. Si tu diagnóstico de un fallo de apertura es que hay otra pestaña abierta y el usuario jura que no la hay, mira ahí antes que en ningún otro sitio.

La durabilidad nunca fue una propiedad del almacenamiento: es un protocolo, y aquí se ve desnudo

Hay una lectura de esta lección que reorganiza lo que uno creía saber sobre bases de datos, y es esta: nada de lo que hace segura una transacción de SQLite ocurre en el disco. Ocurre en el orden de las operaciones. Copiar antes de modificar, sincronizar antes de confirmar, borrar el journal como último acto: ese protocolo es lo que convierte un medio que puede morir en cualquier instante en un sistema que solo puede quedar en dos estados, el de antes o el de después. El disco no aporta atomicidad ninguna; aporta únicamente la promesa débil de que lo escrito hace un rato probablemente siga ahí. Toda la fuerza está en la secuencia. Esa es la razón por la que un motor pensado para tarjetas de memoria de un teléfono de 2004 funciona sin modificar sobre un sistema de archivos inventado veinte años después para un navegador, y la razón por la que sigue funcionando cuando el proceso entero desaparece sin avisar: nunca dependió del medio, dependió de no romper el orden. Y de ahí sale el consejo más incómodo de todo el nivel, que no es técnico. Si estás construyendo tu propia capa de persistencia sobre el manejador síncrono porque el motor te parece pesado —y en la última lección verás que a veces lo es—, lo que estás asumiendo no es escribir bytes en un fichero, que es trivial. Es reimplementar ese protocolo, con su secuencia exacta, sus puntos de sincronización y su rutina de recuperación al arrancar, y comprobar que sobrevive a que le corten la corriente en cada uno de los instantes intermedios. Ese trabajo es el que estás comprando cuando aceptas los ochocientos kilobytes de la próxima lección, y es lo único que separa un almacén de datos de una carpeta con ficheros dentro.

⚔️ Mátale la pestaña a media transacción
  1. Abre una transacción que inserte cien mil filas, cierra la pestaña a mitad y vuelve a abrir la base. Comprueba el número de filas y localiza el journal en OPFS antes de reabrir.
  2. Repite el experimento troceando el trabajo en lotes confirmados cada mil filas. Compara lo que se pierde en cada caso y cuánto tarda cada versión.
  3. Mide el mismo lote con el ajuste de sincronía en cada uno de sus valores y construye tú la curva entre velocidad y riesgo.
  4. Intenta activar el modo de escritura anticipada sin declarar antes el bloqueo exclusivo, anota el error y explícalo con las funciones de memoria compartida ausentes.
  5. Deja una pestaña con la base abierta, navega a otra página y vuelve atrás. Comprueba desde una segunda pestaña si el fichero seguía bloqueado durante ese intervalo.