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.

root · versión 1 root · versión 2 root v1 root v2 B antigua · conservada A compartida B′ añadida
Solo se reescribe el camino hasta el cambio. Todo lo demás se comparte; la versión antigua queda intacta y 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.

api Database · Connection · Transaction · Rows motor de consultas sql (lexer → parser → AST) · exec (planificador + ejecutor) lógica branch · catalog · record transaccional tx (snapshots · escritor único) · commit (cadena de hashes) · btree (CoW) física pager · crypto · io · format depende de ↓
Dependencias estrictas de arriba abajo. El B-tree nunca sabe si las páginas están cifradas; el motor de consultas nunca sabe cómo llega una página al disco.
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.

meta_root — un historial global y lineal · nunca se bifurca v1 v2 v3 v4 c1 c2 c3main c4feature data_root — sigue a la rama · se bifurca con ella
Los data roots se bifurcan con su rama; la cadena meta sigue siendo una única línea global, así que los números de versión y las refs nunca son ambiguos.

Modelo de concurrencia

  • LectoresConnection::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 de Database. 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)