La decisión central: el fichero es el WAL
La mayoría de las bases de datos combinan un B-tree modificado in situ con un write-ahead log separado: los cambios se añaden primero al log y más tarde se consolidan en el árbol, en su sitio. Arkeion funde esas dos estructuras en una sola. Es un B-tree copy-on-write (CoW) que vive en un fichero append-only. Una página de datos, una vez escrita, no se modifica jamás. Cada transacción de escritura añade las páginas nuevas y modificadas al final del fichero, seguidas de una pequeña página de commit que registra las raíces de la nueva versión.
Esa única decisión es todo el motor. Las cuatro propiedades que la gente suele atornillar después salen de ella gratis:
| Propiedad | Por qué es automática |
|---|---|
| Time-travel | Las páginas antiguas son inmutables, así que toda versión pasada sigue en disco. Leer la versión N consiste en resolver el commit N y leer desde su raíz — O(log n), sin replay del log. |
| Ramificación | Una rama no es más que una ref con nombre que apunta a un commit, exactamente como en git. Dos ramas comparten físicamente todas las páginas que no hayan cambiado. |
| Cadena de hashes | Cada página de commit lleva el SHA-256 de su propio contenido más el hash encadenado del commit anterior. La cadena existe desde el primer byte, no se añade a posteriori. |
| Recuperación ante caídas | Un commit solo cuenta si su página de commit está intacta. Tras una caída, una cola a medio escribir simplemente se ignora. El “replay” es un escaneo hacia delante; no hay nada que deshacer. |
| Lecturas sin bloqueos | Un lector fija un commit y lee páginas inmutables. Nunca se coordina con el escritor. |
El precio es que el fichero crece con su historia. Eso se recupera con vacuum, que compacta el fichero según una política de retención (véase Formato de fichero).
Copy-on-write, paso a paso
Supón un árbol pequeño cuya raíz apunta a dos hojas, A y B, y que actualizas una fila que vive en B. Arkeion no toca B. Escribe una hoja nueva B′ con el cambio, después una raíz nueva que apunta a la A intacta y a la nueva B′, y añade ambas al final — seguidas de la página de commit de la nueva versión. A es compartida por ambas versiones; B sigue en disco sin tocar. La raíz antigua sigue siendo válida, así que la versión anterior permanece íntegramente legible.
Capas y módulos
El motor es una pila estricta: las dependencias apuntan solo hacia abajo, y ninguna capa inferior sabe nada de una superior. Eso es lo que mantiene cada pieza testeable de forma aislada.
| Módulo | Responsabilidad | Tipos clave |
|---|---|---|
format |
Constantes, números mágicos, offsets del layout. Sin lógica. | PAGE_SIZE, PageId, PageType |
io |
Lectura/escritura posicional portable (read_at en Unix, seek_read en Windows). |
DbFile |
crypto |
trait CryptoProvider { seal(page), open(page) }. Implementaciones: Aes256GcmProvider, PlainProvider (integridad vía SHA-256 truncado). |
CryptoProvider, Key |
pager |
Append de páginas, lecturas inmutables cacheadas (Arc<PageBuf>), slots meta A/B, validación de integridad. |
Pager, PageBuf |
btree |
B-tree CoW: get / insert / delete / scan sobre claves de bytes, direccionado por PageId. Overflow para valores grandes. |
Tree, Cursor |
commit |
Construye la página de commit (raíces, hashes, contador de nonce), el protocolo de fdatasync, el escaneo de recuperación, la verificación de la cadena. | CommitHeader, ChainVerifier |
tx |
Snapshot (una lectura, fija un commit) y WriteTx (único, serializado por un Mutex). |
Snapshot, WriteTx |
record |
Codificación memcomparable de claves y el formato compacto de filas. | Value, RowCodec, KeyCodec |
catalog |
El esquema de tablas en el árbol de datos (se ramifica con los datos); las refs y el índice de historia en el árbol meta (global). | Catalog, TableDef |
sql |
Lexer escrito a mano y parser de descenso recursivo. Sin dependencias. | Token, Stmt, Expr |
exec |
Planificador (full scan + filtro) y un ejecutor iterador fila a fila. | Plan, Executor |
branch |
Diff entre ramas (saltándose los subárboles compartidos físicamente) y un merge a 3 vías a nivel de fila. | Diff, MergeReport |
api |
La fachada pública ergonómica, al estilo de rusqlite. El único módulo re-exportado. | Database, Connection |
Los dos árboles
Cada commit referencia dos raíces, y se comportan de forma distinta a propósito:
- Árbol de datos (
data_root) — el catálogo y las filas. Sigue a su rama: un commit en una rama parte de la raíz de datos anterior de esa rama, de modo que una migración o una escritura en una rama es invisible para las demás. - Árbol meta (
meta_root) — las refs de rama más el índice de historia (versión → commit, timestamp → versión). Es global y lineal: cada commit, venga de la rama que venga, parte del árbol meta del commit global anterior. Por eso las refs y el índice de versiones son una única fuente de verdad compartida que nunca diverge entre ramas.
Guardar el esquema en el árbol de datos es lo que hace honesta la ramificación: como el esquema viaja con la rama, puedes evolucionar la forma de tus datos en una rama y fusionarla deliberadamente, en vez de que se filtre a todas las ramas a la vez.
Modelo de concurrencia
- Lectores —
Connection::snapshot()fija una página de commit y lee solo páginas inmutables a través de una caché compartida. Puede haber cualquier número de lectores concurrentes, y ninguno comparte bloqueo con el escritor. El aislamiento es snapshot isolation. - Escritor — exactamente uno, serializado por un
Mutex<Writer>a nivel deDatabase. Las escrituras son por tanto serializables por construcción. Hoy el caudal está limitado a un único hilo escritor; el group commit (agrupar fsyncs concurrentes) es una optimización planificada, aún no publicada. - Multiproceso — al hacer
open()se toma un advisory lock sobre el fichero, para un único proceso escritor. Arbitrar escritores entre procesos queda fuera de alcance por ahora.
Flujo de escritura
Una escritura se convierte en páginas en memoria, las añade al final, añade una página de commit y alcanza la durabilidad con un único fdatasync:
execute("UPDATE …")
→ sql::parse → exec::plan
→ WriteTx: btree CoW produces new pages in memory
→ pager: append the new pages, then the commit page
→ commit: one fdatasync ← the single durability point
→ meta slot A/B updated (lazy — a boot hint, off the critical path)
Deliberadamente no hay fsync entre las páginas de datos y la página de commit. Las etiquetas de integridad por página hacen ilegible cualquier página rota o ausente, y la recuperación se detiene en la primera página ilegible — así que un commit a medio escribir sencillamente nunca se adopta. Una sola barrera por commit basta.
Flujo de lectura con time-travel
Leer el pasado no es replay; es una búsqueda y un scan normal desde una raíz antigua:
query("SELECT … AS OF VERSION 42")
→ meta tree: history[42] → PageId of commit 42
→ Snapshot{commit 42} → btree::scan from data_root(42)