wandres.dev
SQLITE EN EL NAVEGADOR I · WASM y la capa VFS

La capa VFS: el punto de extensión

El sistema de ficheros virtual de SQLite es una interfaz estrecha de bytes, desplazamientos y bloqueos que aísla el motor del sistema operativo, y es el punto de extensión que permite ejecutarlo sobre el almacenamiento del navegador.

⏱ 20 min

Todo lo que has visto en las dos lecciones anteriores converge aquí. Un motor de ocho capas cuya única superficie de contacto con el exterior es la de abajo; un entorno que no tiene nada de lo que esa capa presupone; y entre ambos, una interfaz que su autor definió hace décadas con un objetivo modesto —que el mismo código corriera en Unix y en Windows— y que resultó ser lo bastante estrecha y lo bastante honesta como para admitir un sistema operativo que nadie había concebido. Esta lección disecciona esa interfaz operación por operación, porque las decisiones de la lección 4 y los límites de concurrencia que arrastrarás en producción no son otra cosa que consecuencias de lo que este contrato exige y de lo que el navegador puede prometer.

🎯 Al terminar esta lección sabrás
  • Situar con precisión dónde termina el motor y empieza la plataforma, y por qué esa frontera es tan estrecha.
  • Conocer las tres estructuras del contrato y el significado de sus operaciones principales.
  • Registrar y seleccionar una implementación concreta, incluyendo el uso de capas intermedias que envuelven a otras.
  • Explicar por qué el carácter síncrono de este contrato es la restricción que gobierna todo el nivel.

Dónde está la frontera

El pager es el último componente del motor que razona en términos de base de datos. Por debajo de él ya no hay tablas, ni filas, ni índices, ni transacciones: hay un fichero, hay desplazamientos, hay longitudes y hay un puñado de estados de bloqueo. La capa que atiende esas peticiones se llama VFS, por sistema de ficheros virtual, y es deliberadamente ignorante de todo lo que ocurre encima.

flowchart TB
P[Pager con transacciones y diario] -->|leer 4096 bytes en el desplazamiento N| V[Capa VFS]
V --> U[Implementacion unix]
V --> W[Implementacion win32]
V --> M[Implementacion en memoria]
V --> O[Implementacion sobre OPFS]
V --> K[Implementacion sobre almacen clave valor]
style V fill:#f9e2af,color:#11111b
style O fill:#a6e3a1,color:#11111b
style K fill:#a6e3a1,color:#11111b

Merece la pena enunciar también lo que esta capa no sabe, porque la lista es larga y sorprendentemente liberadora. No sabe qué es una tabla, ni una fila, ni un índice, ni una transacción. No conoce el SQL que originó la petición. No distingue una lectura de índice de una lectura de datos. Lo único que intuye del mundo de arriba es que además del fichero principal existen otros ficheros auxiliares —el diario, los temporales— cuyos nombres se derivan del principal por convención, y ni siquiera necesita entender para qué sirven: le basta con saber crearlos, escribirlos y borrarlos.

Esa ignorancia es deliberada y tiene una consecuencia práctica que verás en la lección siguiente. Como los ficheros auxiliares son ficheros de pleno derecho, cualquier implementación tiene que poder crear más de uno por base de datos, y en entornos donde los ficheros son un recurso escaso y reservado por adelantado —que es exactamente el caso de una de las opciones de OPFS— hay que dimensionar el conjunto contando el diario y los temporales, no solo las bases. Quien reserva sitio para dos bases y luego se pregunta por qué falla la tercera operación ha olvidado que el pager pide ficheros que él nunca nombró.

Esa estrechez es la propiedad clave y conviene medirla. El vocabulario completo que el motor necesita del mundo exterior cabe en tres ideas: leer un rango de bytes, escribir un rango de bytes y coordinarse con otros que quieran hacer lo mismo. Todo lo demás —crear el fichero, saber su tamaño, truncarlo, borrarlo, preguntar si existe, obtener bytes aleatorios y la hora— es accesorio. Un contrato de ese tamaño puede implementarse sobre casi cualquier cosa que sepa persistir bytes, y esa es literalmente la razón por la que este nivel es posible.

Las tres estructuras y sus operaciones

El contrato se materializa en tres tipos. Uno describe la plataforma y sus operaciones globales sobre nombres. Otro representa un fichero abierto. El tercero es la tabla de funciones que actúan sobre ese fichero abierto.

struct sqlite3_vfs {
  int  iVersion, szOsFile, mxPathname;
  const char *zName;                    /* el nombre con el que se registra */
  int (*xOpen)(sqlite3_vfs*, const char *zName, sqlite3_file*, int flags, int *pOut);
  int (*xDelete)(sqlite3_vfs*, const char *zName, int syncDir);
  int (*xAccess)(sqlite3_vfs*, const char *zName, int flags, int *pResOut);
  int (*xFullPathname)(sqlite3_vfs*, const char *zIn, int nOut, char *zOut);
  int (*xRandomness)(sqlite3_vfs*, int nByte, char *zOut);
  int (*xSleep)(sqlite3_vfs*, int microseconds);
  /* ... y las de carga dinamica, que en WebAssembly quedan a nulo ... */
};

struct sqlite3_io_methods {
  int  iVersion;
  int (*xClose)(sqlite3_file*);
  int (*xRead) (sqlite3_file*, void *buf, int iAmt, sqlite3_int64 iOfst);
  int (*xWrite)(sqlite3_file*, const void *buf, int iAmt, sqlite3_int64 iOfst);
  int (*xTruncate)(sqlite3_file*, sqlite3_int64 size);
  int (*xSync)(sqlite3_file*, int flags);
  int (*xFileSize)(sqlite3_file*, sqlite3_int64 *pSize);
  int (*xLock)(sqlite3_file*, int lockLevel);
  int (*xUnlock)(sqlite3_file*, int lockLevel);
  int (*xCheckReservedLock)(sqlite3_file*, int *pResOut);
  int (*xSectorSize)(sqlite3_file*);
  int (*xDeviceCharacteristics)(sqlite3_file*);
  /* xShmMap, xShmLock, xShmBarrier y xShmUnmap para la memoria compartida del WAL */
};

Cuatro observaciones sobre esta tabla justifican la mitad de las dificultades del nivel. La primera: xRead y xWrite reciben un desplazamiento absoluto de sesenta y cuatro bits, de modo que la implementación no mantiene una posición de lectura y no necesita nada parecido a un cursor de fichero. La segunda: xLock y xUnlock no son un cerrojo binario sino una escalera de estados —ninguno, compartido, reservado, pendiente, exclusivo— cuyo cumplimiento es lo que permite que varios lectores coexistan con un escritor que aún no ha empezado a escribir. La tercera: xSync es el único punto donde se juega la durabilidad de todo el sistema, y en el navegador es también el más débil. Y la cuarta, la más interesante en clave de rendimiento, es xDeviceCharacteristics.

Detente un momento en la escalera de bloqueos, porque es la parte del contrato que peor se traduce al navegador. Los cinco estados no son caprichosos: el estado compartido permite leer y admite varios ocupantes; el reservado señala la intención de escribir sin impedir que los lectores actuales terminen; el pendiente cierra la puerta a lectores nuevos mientras espera que salgan los antiguos; y solo el exclusivo permite tocar las páginas de datos. Toda la concurrencia de lectores frente a escritor que SQLite ofrece está en esa gradación, y toda ella descansa en que el medio subyacente sepa distinguir un bloqueo compartido de uno exclusivo. Cuando el medio solo sabe conceder acceso exclusivo, como ocurre con el manejador síncrono del nivel 10, la escalera se aplana y la concurrencia desaparece por construcción, no por un fallo de implementación.

Repara también en las operaciones que parecen triviales y no lo son en este entorno. xFullPathname debe convertir un nombre en su forma canónica, pero en un almacenamiento sin rutas de verdad esa canonicalización es una convención que inventa la propia implementación. xAccess responde si un nombre existe y si es escribible, algo que en un almacén de clave y valor exige mantener metadatos propios. Y xRandomness y las funciones de tiempo tienen que resolverse con las fuentes del navegador, cuya resolución temporal está deliberadamente degradada por motivos de privacidad. Ninguna de estas es difícil; todas son sitios donde una implementación descuidada introduce diferencias sutiles de comportamiento.

💡
Las características del dispositivo son una negociación de trabajo

Esa función devuelve un conjunto de banderas con las que la implementación declara garantías del almacenamiento subyacente: que una escritura del tamaño de página es atómica, que los añadidos al final son seguros, que las escrituras se aplican en orden, que sobrescribir no daña los bytes vecinos ante un corte. El pager lee esas banderas y ajusta cuánto trabajo de diario necesita hacer. Declarar atomicidad cuando el almacenamiento no la ofrece hace tu base más rápida y también corruptible; no declararla cuando sí existe la hace innecesariamente lenta. Es el sitio donde una implementación de VFS deja de ser un ejercicio de traducción y se convierte en un juicio de ingeniería sobre el medio real.

Registrar, seleccionar y envolver

Una implementación se da de alta en tiempo de ejecución y el motor mantiene una lista con todas las registradas, cada una con su nombre. Elegir cuál usa una conexión concreta admite tres vías, en orden creciente de prioridad: la que esté marcada como predeterminada, la que se pase explícitamente al abrir, y la que indique el propio nombre del fichero cuando se usa la sintaxis de URI. Esta última es la que verás por todas partes en el mundo del navegador, y también funciona al adjuntar una base adicional a una conexión ya abierta.

// Comprobar disponibilidad antes de asumir nada
if (sqlite3.capi.sqlite3_vfs_find("opfs")) {
  const db = new sqlite3.oo1.DB("file:cuaderno.sqlite3?vfs=opfs", "c");
  // Distintas conexiones del mismo proceso pueden usar implementaciones distintas
}

En C el mecanismo es igual de directo, y verlo ayuda a entender que la elección es de tiempo de ejecución y no de compilación: se busca una implementación por nombre y se registra, opcionalmente marcándola como predeterminada para todas las conexiones futuras.

/* Cambiar la predeterminada del proceso entero */
sqlite3_vfs_register(sqlite3_vfs_find("unix-excl"), 1);

/* O elegirla solo para esta conexion */
int rc = sqlite3_open_v2("demo.db", &db, SQLITE_OPEN_READWRITE, "unix-none");

Hay una consecuencia de diseño que la gente descubre tarde y que abre un abanico entero de técnicas: como el contrato es simétrico, una implementación puede envolver a otra, interceptar las llamadas, hacer algo con ellas y delegar. El árbol de fuentes del proyecto trae varios ejemplos de estas capas intermedias, y merece la pena conocerlos porque el catálogo revela hasta dónde llega la palanca.

🔎

Trazar

Una capa que registra cada operación con sus argumentos antes de delegar convierte el comportamiento del pager en algo observable. Es la mejor herramienta que existe para entender por qué una consulta toca el disco tantas veces.

📏

Imponer cuota

Otra vigila el tamaño acumulado de un grupo de ficheros y avisa o falla al superar un umbral. Es exactamente el mecanismo que necesitarías para respetar el presupuesto de almacenamiento del nivel 11.

🧩

Trocear

Una tercera parte cada fichero lógico en varios ficheros físicos más pequeños, presentando arriba la ilusión de un único fichero enorme. La misma idea sirve para almacenamientos que solo saben guardar bloques.

💥

Inyectar fallos

Otra simula errores de entrada y salida y cortes de corriente en puntos arbitrarios para verificar que la base siempre queda recuperable. Es cómo se prueba la atomicidad de verdad.

Por qué esta es exactamente la palanca

Hay un último aspecto del contrato que se aprecia mejor mirando la pila entera de una vez, y es dónde se decide cada propiedad que le importa a tu producto. La corrección del SQL se decide arriba, en el analizador y el generador de código, y no depende del medio. La atomicidad se decide en el pager y depende del medio solo a través de las banderas que la implementación declara. La velocidad y la concurrencia se deciden abajo, íntegramente en esta capa, y es la razón por la que la lección siguiente puede plantearse como una elección de rendimiento sin que nada de lo aprendido sobre consultas se ponga en duda.

ℹ️
Una implementación mínima cabe en una tarde

El árbol de fuentes del proyecto incluye una implementación deliberadamente simplista, escrita como material didáctico, que atiende el contrato con las llamadas más elementales del sistema y renuncia a casi todas las optimizaciones. Leerla entera lleva menos de lo que se tarda en configurar un servidor de bases de datos, y es el mejor ejercicio que conozco para interiorizar el nivel: cuando ves que el punto de apoyo de un motor entero son unas doscientas líneas de funciones aburridas, la idea de que el navegador es un sistema operativo más deja de sonar a metáfora.

Reúne ahora las piezas. El motor entero está aislado tras una interfaz de una docena de funciones. Esa interfaz no menciona rutas del sistema, ni descriptores, ni llamadas concretas del núcleo: menciona bytes y desplazamientos. Se registra en tiempo de ejecución, admite varias implementaciones simultáneas y permite componerlas. Y la propia compilación oficial a WebAssembly demuestra el punto de la forma más elocuente posible: sus implementaciones para el navegador están escritas íntegramente en JavaScript, conectadas al motor a través de esa misma tabla de funciones que en Unix apunta a llamadas al sistema.

Ese hecho merece subrayarse porque desactiva una objeción frecuente. Nadie tuvo que modificar el motor para que hablara con OPFS: alguien escribió unas cuantas funciones en JavaScript con la firma adecuada, las conectó a la tabla y el motor siguió creyendo que estaba hablando con un sistema operativo. La cantidad de código C que hubo que tocar para que SQLite persistiera en un navegador es, en lo esencial, ninguna.

Queda una pieza del contrato que no es una función pero condiciona todo lo demás: estas operaciones son síncronas. Cuando el pager llama a xRead, espera que los bytes estén en el búfer al retornar. No hay ninguna versión del contrato que admita devolver una promesa, y no la hay porque el motor está a mitad de ejecutar bytecode con su pila viva y no tiene forma de suspenderse. Todo lo que verás en la lección siguiente —por qué el almacenamiento del navegador que sí es síncrono solo existe dentro de un Worker, por qué usar un almacén asíncrono obliga a transformar el binario o a apoyarse en la suspensión de pilas, por qué leer bloquea el fichero— se deduce de esa única línea del contrato.

La interfaz estrecha es la unidad real de portabilidad, y casi nadie la diseña a tiempo

Hay una asimetría cruel en el modo en que este tipo de fronteras se paga y se cobra. Diseñarla cuesta caro y en el peor momento: al principio, cuando el equipo solo tiene una plataforma en la cabeza, cuando cada indirección parece ceremonia y cuando la voz sensata de la sala dice que ya se abstraerá cuando haga falta. Cobrarla, en cambio, ocurre años después, en un contexto que nadie de los presentes había imaginado, y quien la cobra rara vez es quien la pagó. Lo que hace del VFS un caso de estudio no es que exista una abstracción del sistema de ficheros —eso lo tiene medio mundo— sino que su vocabulario se mantuvo por debajo de lo que cualquier plataforma concreta ofrecía: no habla de rutas, ni de descriptores, ni de permisos, ni de directorios, ni de nada que Unix tenga y otro sistema pueda no tener, sino solo de aquello que cualquier medio capaz de persistir información debe poder hacer. Esa disciplina de mínimos es lo contrario del instinto habitual, que es exponer todo lo que la plataforma de referencia sabe hacer por si acaso hiciera falta, y es también la razón por la que la mayoría de las capas de abstracción de la industria fracasan en su primera prueba real: no eran abstracciones, eran la primera plataforma con otro nombre. Aplícalo a tu propio código y la prueba es incómoda de pasar. Tu capa de persistencia, ¿habla de bytes y operaciones, o habla de IndexedDB con sinónimos? Si mañana tuvieras que sustituir el almacén por otro con semántica distinta, ¿tocarías una carpeta o tocarías la aplicación entera? La respuesta honesta a esa pregunta predice, con una fiabilidad que asusta, cuánto te costará adoptar el motor que este nivel te está enseñando a instalar.

⚔️ Instrumenta el contrato
  1. Enumera con sqlite3_vfs_find las implementaciones disponibles en tu entorno y anota cuáles aparecen en el hilo principal y cuáles solo dentro de un Worker.
  2. Abre la misma base lógica dos veces en la misma página usando dos implementaciones distintas mediante la sintaxis de URI y comprueba que son almacenamientos independientes.
  3. Escribe en papel la secuencia de llamadas al contrato que produciría una transacción que inserta una fila: apertura, escalada de bloqueos, escrituras del diario, sincronización, escritura de la página y liberación.
  4. Diseña una capa intermedia que solo cuente llamadas por tipo de operación y las vuelque al terminar. Predice antes de ejecutarla cuántos xRead genera un SELECT con un índice de cobertura frente a uno sin él.
  5. Redacta las banderas de características que declararías para el almacenamiento que pienses usar, y justifica cada una con lo que ese almacenamiento garantiza de verdad, no con lo que te convendría.