inro

Por qué inro

Hay al menos tres motores que ya funcionan aquí: Arkeion, cuando los datos deben ser auditables a lo largo del tiempo; SQLite, cuando hay un nodo y una base de datos; y la familia LMDB, cuando la carga de trabajo necesita copy-on-write embebido con años de endurecimiento en producción. Este es el argumento para construir un cuarto motor de todos modos — dónde gana inro, qué renuncia para conseguirlo, y el único caso en que la respuesta honesta es: no lo hagas.

Frente a Arkeion

Arkeion e inro son hermanos, no competidores, y optimizan para cosas opuestas. Arkeion optimiza para auditabilidad, versionado y potencia de consulta. inro optimiza para el menor consumo posible de disco, RAM y CPU. Arkeion es el sistema de registro para datos que deben ser auditables a lo largo del tiempo; inro es el almacén local para nodos con recursos escasos, o desplegados por millares.

Lo que inro renuncia para conseguir su huella: SQL — sin parser, planner ni executor; búsqueda de texto completo, índices vectoriales, triggers, vistas y claves foráneas; acceso desde más de un proceso; y viaje en el tiempo, ramas y consultas AS OF.

El viaje en el tiempo es el que hay que desglosar, porque parece barato y no lo es. Medido contra una carga de trabajo edge real — lotes de mil registros, unos 166 commits al día — retener la ruta raíz-a-hoja de cada commit (unas cuatro páginas de 4 KB cada una) acumularía 2,6 MB de páginas inmortales al día: una sobrecarga del 40% sobre el tamaño del fichero. Peor que el tamaño: la poda por retención dejaría de liberar espacio, porque una versión antigua seguiría viendo las filas que borró. Eso obliga a un vacuum con retención — exactamente la compactación periódica por la que se rechaza LSM más abajo.

Para el único caso que realmente justificaría el versionado — demostrar que nadie manipuló una cifra de facturación pasada — la respuesta de inro es mil veces más barata: una cadena de hashes sobre los agregados de facturación, 768 bytes al día, 280 KB al año, sin versionado de ficheros en absoluto.

Un nodo, una base de datos, y necesidad de SQL, ramas o viaje en el tiempo: elige Arkeion. Miles de instancias con huella limitada que solo necesitan almacenamiento clave-valor duradero: elige inro.

Frente a SQLite

Dónde gana SQLite

Un fichero vacío con páginas de 512 bytes y una tabla ocupa aproximadamente 1 KB — menos que el 1,5 KB de inro tras la reubicación del meta B. Y más importante que el recuento de bytes: la madurez de SQLite. Su batería de pruebas tiene cobertura de ramas completa y un volumen de código de pruebas del orden de 600 veces el del propio motor. Ningún byte ahorrado compensa eso, y hay que decirlo con claridad.

Dónde gana inro, en la carga de trabajo de quota

| Dimensión | SQLite | inro | | --- | --- | --- | | Bytes por fila de 16 columnas | 42-45 B (unos 17 solo de los códigos de tipo, más rowid y puntero de celda) | 37 B, gracias a la compresión de prefijos en la clave y la ausencia de códigos de tipo | | RAM del motor por base de datos abierta | ~2 MB de caché por conexión por defecto; del orden de 100 KB incluso ajustado al mínimo | 20-30 KB | | RAM total por base de datos, con interning y agregados | Equivalente, más el esquema analizado y las sentencias preparadas | ~832 KB tras la fase 1; ~34 KB tras la fase 2 | | Ficheros satélite | En modo WAL: un -wal que crece hasta 4 MB antes del checkpoint, y un -shm de 32 KB mapeado por conexión | Ninguno | | Poda por retención | DELETE ... WHERE ts < X es O(rows): 833.000 borrados diarios por nodo | delete_range por desenganche de subárbol: ~25 páginas leídas | | Tamaño del binario | ~700 KB, ~300 KB en una compilación mínima | 100-250 KB estimados, más 60-100 KB con cifrado |

Las cifras de SQLite y LMDB son estimaciones de la fase de diseño y están por confirmar en las mediciones de salida de la fase 1.

inro no se justifica porque SQLite sea grande. Se justifica por la RAM agregada en miles de instancias, por el coste de la poda, por la ausencia de ficheros satélite, y por el control del formato. Con un nodo y una base de datos, un SQLite bien configurado es la decisión correcta y escribir un motor es una pérdida de tiempo.

Frente a LMDB, libmdbx y redb

Estos tres comparten la arquitectura de inro: B+tree copy-on-write, fichero único, sin hilos en segundo plano, lectores no bloqueantes, apertura de coste constante. Juntos cubren cerca del 70% del núcleo con código endurecido en producción durante años.

| Dimensión | LMDB / libmdbx / redb | inro | | --- | --- | --- | | Bytes por entrada | 45-55 B — sin compresión de prefijos, sin truncado de separadores | 35-37 B | | Suelo por base de datos | 8-16 KB, por una página fija de 4 KB — 40-80 MB en cinco mil inquilinos, solo en bases de datos vacías | 1,5 KB |

Las cifras de SQLite y LMDB son estimaciones de la fase de diseño y están por confirmar en las mediciones de salida de la fase 1.

Lo que la arquitectura compartida no incluye es exactamente lo que produce las cifras de este diseño:

  • Sin borrado por rango mediante desenganche de subárbol: la poda cae a O(rows).

  • Sin cifrado en LMDB ni en redb.

  • LMDB y libmdbx usan mmap, lo que agota las regiones de memoria virtual en cuanto hay miles de bases de datos abiertas.

  • Ninguno de los tres está diseñado para miles de instancias en un mismo proceso: no hay pool de páginas compartido.

Por qué copy-on-write, y qué se rechazó

Se evaluaron tres arquitecturas de almacenamiento antes de decidirse por una.

Elegida: B+tree copy-on-write con free list. Una página modificada se escribe en un hueco libre, nunca sobre datos vivos; un commit es solo escribir una nueva meta page. La ocupación es estable, entre 1,2× y 2× los datos vivos en el peor caso. Sin compactación, sin hilos en segundo plano, sin fichero auxiliar, RAM constante. Los lectores no bloqueantes salen gratis del diseño: un lector fija una meta page, y el árbol que cuelga de ella es inmutable mientras la retiene.

Un commit libera la página antigua en lugar de borrarla; vuelve a la free list para reutilizarse. Append-only, sin esa lista, haría crecer el fichero para siempre.

Rechazada: LSM (memtable más SSTables). La compactación en segundo plano es inasumible en IoT — CPU y batería — y catastrófica con miles de inquilinos por proceso. Peor aún, cada base de datos abierta necesitaría su propia memtable, multiplicando la RAM por el número de inquilinos.

Rechazada: B+tree in-place con WAL. Escribe cada página dos veces, obliga a un segundo fichero que hay que truncar, y las lecturas concurrentes necesitan una caché de páginas con latches o MVCC explícito — más código que la opción elegida, no menos.

El matiz que casi nadie dice en voz alta: un copy-on-write puramente append-only sería la opción peor en disco, porque el fichero crecería sin límite. Es la free list, con la reutilización de páginas, lo que hace que esta arquitectura sirva al objetivo del proyecto — no el copy-on-write por sí solo.

Cadena de suministro

Cero unsafe en el código propio de inro. unsafe_code = "forbid" está bajo [lints.rust] en Cargo.toml — no solo #![forbid(unsafe_code)] en lib.rs — así que la prohibición también alcanza a los tests, los benchmarks y los ejemplos.

Cero dependencias en tiempo de ejecución por defecto. El parser, el B+tree, las codificaciones, varint, CRC32, bitpacking y la compresión están todos escritos a mano. La única excepción planeada son las primitivas criptográficas detrás de la funcionalidad encryption, y ni siquiera ahí inro escribe su propia criptografía — usa las implementaciones auditadas de RustCrypto.

La salvedad honesta: forbid(unsafe_code) no se propaga a las dependencias. Los crates de RustCrypto usan unsafe en la detección de características de CPU y en sus backends SIMD. Si el backend portable de ChaCha20 puede prescindir de ese unsafe — y a qué coste de rendimiento — es una pregunta abierta para la fase 1, que se medirá y auditará con cargo geiger en CI, no se dará por hecho.

Consecuencia concreta para la fase 4: la compresión LZ4 se escribirá a mano, unas 300 líneas, en lugar de incorporar lz4_flex, que sí contiene unsafe.