the ugly org - hacemos una db 1 - kv

desu

Hello,

Bienvenidos a una nueva serie que constara de dos partes:

Como decia Feynman y siguiendo uno de los mottos de la ugly org:

What i cannot create I don't understand!

Feynman

O como nos gusta decir a nosotros:

Si no sabes picarte un par de db al toque en vim sin syntax highlight eres un fpero mi rey!

x10 tech lead staff ^ 2 engineer
  • Las bases de datos se construirán ambas de zero.
  • Quizás en distintos lenguajes de programación según me levante el día que haga la postgres.
  • De esta manera cualquiera con poca experiencia (<10 años como programador) puede seguirlo de manera fácil y cómoda, aunque yo recomiendo usar el lenguaje de programación de tu confianza.
  • Recomiendo usar Rust, Zig, Go y similares para programadores mas experimentados que quieran seguirme por su cuenta para saborear mejor el hierro.

El post #2 estará reservado para teoría y poner recursos. Y como siempre, no se responderán preguntas de gente que desconozca como funciona un puntero.

6
desu

El paper:

1. Core Idea: Bitcask uses a log-structured design where writes are appended to immutable logs, ensuring fast and efficient write operations.

2.	Indexing: Keys are stored in memory as a hash table, mapping to their positions in the logs, allowing O(1) lookups.

3.	Data Layout: The storage consists of multiple files—active (for ongoing writes) and sealed files (immutable).

4.	Reads: A lookup finds the key in memory, retrieves its offset, and reads the value from the corresponding log file.

5.	Compaction: Old log files are periodically merged to eliminate stale keys and reclaim disk space.

6.	Crash Recovery: On restart, the index is rebuilt by scanning the log files for keys and their latest values.

7.	Advantages: Fast writes, efficient reads, and a simple design make it ideal for write-heavy workloads.

8.	Challenges: High memory usage for indexing and slower recovery times due to index rebuilding are key trade-offs.

9.	Use Cases: Bitcask is used in systems like Riak for storing small-to-moderate-sized key-value pairs.

10.	Design Principles: Simplicity, immutability, and log-structured organization ensure its reliability and speed.

Otros:


Imágenes adicionales:

Material opcional para sistemas distribuidos:

Ya he hecho bases de datos distribuidas en el pasado, no lo repetiré. Aquí tenéis material adicional extra.

1 respuesta
aren-pulid0

#2 muy bueno, no he visto nunca el tema de base de datos de manera "formal", pero esto me pareceria el inicio de un motor , se me ocurren ideas como añadir tipos para los datos y otras cositas.

Supongo que los motores que hay dentro de las db como postgres, mysql y etc realizarán operaciones similares de escribir en disco y compactificar, lo unico que con diferentes estructuras de datos (los famosos B-Trees) y soportando SQL.

Desde luego este bitcask parece un inicio perfecto en el tema.

Muy chulo el aporte, gracias por compartirlo.

PD: Estaria muy bien que aunque no vayas a explorar en profundidad el tema de hacerlo distribuido si que dieras ciertas pinceladas, a nivel personal lo veo muy interesante

desu

Inicializo el proyecto:
https://github.com/vrnvu/bitask

  • Lo he llamado bitask, porque esta basado en bitcask, y en lugar de hacer get haremos ask! HAHA soy el mejor!

He seguido esta recomendación para buenas practicas de CLI de no otra que Rain, una de las mejores ingenieras del faaking planeta. Asi que yo le hago caso y estructuro el codigo como me dice!

https://github.com/sunshowers-code/rust-cli-recommendations

Este seria un poco el objetivo:

This implementation provides:

  • A command-line interface (bitask) for interacting with the key-value store
  • A Rust library crate that can be used as a dependency in other projects
  • Core Bitcask features including:
    • Append-only log files
    • In-memory key directory
    • Basic compaction
    • Crash recovery
  • Only Strings are supported for now to simplify the implementation

Ya solo mirando la API ya hay mil decisiones:

  • pasamos el path de la db por parametro a la cli y al open?
  • que errores vamos a devolver? aquí necesitare thiserror para devolver errores idiomaticos bien estructurados
  • permitimos hacer bitask compact o bitask recover o parecidos en caso de un crash? ya veremos

Lo primero que hare es escribir una suite tests de los casos de uso basicos, tratare de ser muy granular porque es importante la CORRECTNESS de una base de datos, de momento ignorare la CLI, aunque me interesa mucho tenerla y no sera difícil testearlo.

desu

https://github.com/vrnvu/bitask/commit/4cf7a6f838954186ddf1b562de5b9c7cd792079a

Esto no seria la ugly org sin un poquito de magia.

use bitask::Bitask;
use std::path::Path;

// Open database with exclusive access for writing
let mut writer = Bitask::exclusive("./db")?;

// Open database with shared access for reading
let reader = Bitask::shared("./db")?;

// Multiple readers can exist simultaneously
let another_reader = Bitask::shared("./db")?;

// But only one writer can exist at a time
let another_writer = Bitask::exclusive("./db");
assert_eq!(another_writer.is_err(), true);

Tener un metodo open generico o pasandole flags? Porque no simplificar la API teniendo dos funciones bien explicitas exclusive and shared? De la misma manera que Rust usa el concepto de exclusive reference y shared reference en su modelo de ownership y borrowship? Pues eso he hecho. Nota: el beneficio de un solo open y aceptar flags es una mejor mantenibilidad y que se mas dificil romper la public API.

El codigo se entiende muy bien, basicamente he añadido un lock file para tener exclusividad a nivel de proceso, y un enum para los dos tipos de procesos y poco mas. Podria hilar mas primo en la seguridad por el thread-safety. Dejo como trabajo al lector pensar en que falla este codigo respecto a tener multiples threads y compartir memoria.

  • Aun no tengo claro como de thread-safety quiero ser. En un primer analisis yo creo que mi codigo es thread-safety, porque funciona con las restricciones que quiero.
  • En el futuro seguramente desharemos esta API, ya que la hecho for fun, tiene el problema de que debemos compartir memoria. Lo podría arreglar con un Arc<RwLock<T>>. Ya veremos, podemos tirar un benchmark y tratar de usar la libreria con threads.
desu

Serializacion y deserializacion.

https://github.com/vrnvu/bitask/commit/c6236d3100c7a332ab43fb2a2deb425967cae292
PS: Si os fijáis ya he deshecho el experimento del exclusive/shared, os puedo razonar porque y los pros/cons. Como esta, es bien de momento. Me gustaria volver a esta idea de API en algunas momento.

Vigilar con los buffers y demas, siempre pasalos por parametro para que se puedan re-usar y optimizar:

fn serialize(&self, buffer: &mut Vec<u8>) -> Result<(), Error> {}

Parece una chorrada pero es un error / despiste muy comun.

Nuestros Commands tienen:

  • timestamp: merging/compact, el valor mas reciente gana en caso de haber un duplicado, TTL para hacer eviction, debug/audit de los logs!
  • crc: para proteger de corrupción, y duplicados, importante hacer key + value siempre

Super importante, si alguien no entiende uno de estos features, que me pregunte.

El codigo para serializar/deserializar ya lo enseñe cuando hicimos el bittorrent, el dns... y otros protcolos de network, yo recomiendo este estilo de codigo porque es muy facil de leer y mantener:


Como particularidad, mi Bitask no acepta values vacios en un PUT, y usa los valores vacios como signo de tumba en el Remove. De esta manera podemos saber que se ha borrado bien.

Por ultimo unos tests gracias a Claude donde comprobamos que todo funciona.

Vamos viento en popa!

La gestion de los errores podria ser mejor, ahora le doy una vuelta, pero fijaros que el codigo ES MUY PRODUCTION READY. Es lo que tiene Rust, vas rapido y cuando funciona, ya tienes una calidad altisima.

desu

Pequeño patch. Aqui he cambiado el orden del crc, timestamp y he añadido las entries, active file y reader files...

https://github.com/vrnvu/bitask/commit/4407f3f63eb8ea8fee51b425855be03577594217

Lo he hecho tal cual el paper si no me he equivocado en nada.

Lo bueno es que si hacemos el binario exactamente igual que el paper, podemo hacer que nuestra DB sea interoperable con bitcask :-)

Si alguien esta siguiendo el diario, recomiendo implementar igual que yo lo hago, SEGUID EL PAPER TAL CUAL, NO TRATEIS DE AHORRAROS CAMPOS.

desu

Implementamos el put, get y remove!

https://github.com/vrnvu/bitask/commit/5086c3f7eafdaa3fc0384eedd9c2d233d9f3e7e5

Ahora mismo solo tenemos 1 archivo, el siguiente paso es tener multiples archivos, compactar, y recuperar el index al abrir un path ya inicializado.

He dejado en TODOs algunas optimizaciones: buffer pool, wrapper sobre writers para guardarnos la ultima posición accedida...

Nos faltan 2 pasos para tener un MVP:

  • multiples archivos
  • recuperar una db ya inicializada

La compactación y los archivos de hint no son necesarios. Diría que solo nos faltan estas 2 funcionalidades para tener una base de datos completamente funcional.

Como podeis ver muy facil, quizas tenemos la base de datos en menos de 500 lineas. Aunque nos vamos a centrar bien en los test y en la correctness. Creo que es un buen proyecto para meterle paralelismo y jugar con estos conceptos tipicos.

desu

En el patch de hoy primero voy a hacer que los nombres de los logs sigan esta convencion.

  • timestamp.active.log para el archivo activo
  • timestamp.log para los archivos antiguos

Esto tiene los siguientes beneficios (Segun nuestro amigo Claude).

  • File Rotation Policies:
    • Age-based decisions are immediate from filename
    • No need to track/store creation times separately
    • Easy to enforce policies like "rotate files older than X days"
  • Merge/Compaction Scheduling:
    • Can identify time ranges for compaction directly
    • Helps with time-based retention policies
    • Enables time-window based merging strategies
  • Recovery Procedures:
    • Natural chronological ordering without extra bookkeeping
    • Can reconstruct database state at any point in time
    • Helps identify potentially corrupted files after crashes
  • Additional Benefits:
    • Global ordering across multiple DB instances
    • Built-in audit trail
    • Easier debugging (human-readable timestamps)
    • No coordination needed for file ID generation across processes
    • Natural support for point-in-time recovery

Como digo, implementar los papers siempre tal cual los veais, siempre hay un motivo para que algo sea de la manera que es, si no lo entendeis analizadlo. La gran mayoria de veces, CASI SIEMPRE, hay un motivo.

Cuando haces una implementación a mano, el error que comete mucha gente es decir: "en lugar de seguir el paper, es muy complejo meter timestamps, le metere un u64 que se incremente y asi lo tengo ordenado". Receta para el desastre, porque cuando quieras hacer algo de lo que he listado arriba, veras que tienes que re-implementar mucho código y las ideas no fluyen tan fácil.

Commits:
1 - https://github.com/vrnvu/bitask/commit/965a63b6edff5a235832223e774d08444b40e122
2 - https://github.com/vrnvu/bitask/commit/105df780cdc8cd93e0308e084e6f9ba9ee71b5c9

desu

Memory mapping.

Una de las cosas a mirar cuando elegimos una DB que la gente no entiende, es como la vamos a usar? No todas las DB van a ir igual de bien en cuanto a rendimiento.

En este caso, estamos haciendo una KV, pero no hemos definido el caso de uso que queremos resolver exactamente, como se va a usar nuestra base de datos. Esto ES CRITICO y LO MAS IMPORTANTE.

Veamos el problema:

fn get(key) -> Vec<u8> {
   return new copy of a vector of bytes!
} 

El problema es que cada vez que hacemos un get, vamos a devolver una copia de lo que leamos de disco. Es esto lo que queremos? Yo puedo usar buffers y pools para tener zero copy y re usar los buffer entre lecturas de disco a mi base de datos. Pero como decido como devolverte los datos? Depende de que va a hacer con ellos no? Los quieres consultar? Los quieres pasar por network? Los quieres modificar y re-escribir? Los vas a usar desde el mismo proceso? En otro thread quizas? Esto es LO MAS IMPORTANTE.

Veamos como lo hacen otras bases de datos:

  • mmap para compartir memoria: rockdb
    • seguramente lo mas rapido, porque podemos literar mappear la region de nuestro log a memoria y compartirlo al toque
  • borrow referencia del pool: lmdb
    • nuestro proceso es el owner de la memoria, no se puede modificar
  • zero copy con ARC: sqlite
    • parecido al punto anterior, pero usando un ARC sobre el slice de memoria que compartimos para saber cuando droppear

En codigo gracias a Claude:

  • Memory-mapped (best for random access, shared memory):
// Zero-copy, but whole file in virtual memory
pub fn get(&self) -> Result<&[u8], Error> {
    let mmap = &self.mmaps[file_id];
    Ok(&mmap[offset..offset+len])
}
  • Buffered reads (best for streaming/sequential):
pub fn get<'a>(&'a mut self) -> Result<ValueRef<'a>, Error> {
    let buf = self.pool.pull();
    reader.read_exact(buf)?;
    Ok(ValueRef { data: buf })
}
  • Reference counted (best for concurrent/async):
// Flexible but has overhead
pub fn get(&self) -> Result<Arc<[u8]>, Error> {
    let data = Arc::new(read_data()?);
    Ok(data)
}

El ejercicio ahora es simple, tu fpero, sabes como devuelve memoria tu DB? Porque es una de las decisiones principales al elegir una base de datos.

Y que haces con estos datos? Los pasas a disco de nuevo modificados, o por network, o simplemente los consultas en memoria y después los descartas?

desu

Refactors y bug fixing:

Tenemos un par de bugs, ahora mismo solo tenemos un unico archivo de log, el activo, y no tenemos compactacion. Voy a tratar de arreglar los bugs que tenemos, EJERCICIO QUE BUGS TENEMOS? LOS SABES VER? SI NO. NO SABES NADA DE DB, y añadir el checksum y esas cosas. Simplificar el modelado de datos porque creo que me voy a cargar el Command del todo y hacerlo en una funcion. no necesitamos un Command::Remove porque nunca se deserializa.

En conclusión, voy a hacerlo production ready y diseñarlo para que sea lo mas simple posible antes de meter otra capa de complejidad que puede ser;

  • multi archivo, que en principio ya lo soportamos pero no hago rotacion de log activo
  • compactacion

Un bug por ejemplo, es que tenemos timestamps en nuestros logs, pero no los usamos en caso de tener multiples entradas. Cuando hacemos un rebuild del keydir, ese timestamp precisamente esta para eso.

Natural chronological ordering without extra bookkeeping #9

desu

Doc + refactors:
https://github.com/vrnvu/bitask/commit/ff267523f7d9b597ff82c01852d2a535adf7c2d7



Y si, los tests de la documentacion en rust se ejecutan!

Doc-tests bitask

running 1 test
test src/db.rs - db::Bitask (line 72) - compile ... ok

thread safety tests:

  • https://github.com/vrnvu/bitask/commit/467223f1e94cbabb916ce6919c40453319f5274f
  • nuestro codigo es process safe, asi que dejamos al usuario de la libreria gestionar la thread safety! el mejor codigo, es el que no se pica.
  • si el usuario quiere tener multiples reads solo necesita un RWLock y pasar una referencia del Arc. Si quiere tener multiple writes, lo que hacemos sera secuancializar con un Write lock... 0 problemas.
  • ademas como es process safe sabemos que nadie nos esta escribiendo desde otro proceso! de hecho tengo prohibido ejecutarlo 2 procesos no? quizas podemos relajar esto permitiendo procesos de lectura. pero hay que entender que esto implica EVENTUAL CONSISTENCY! queremos un sistema consistente o eventual consistency? que probelma hay de solo permitir un proceso?
  • la mejor seguridad es la que restringe la superficie de ataque.
desu

Probando el process safety he visto que si necesitaba droppear/borrar a mano el file lock! Buen bug teniamos ahi! Ahora todo funciona como deberia: https://github.com/vrnvu/bitask/commit/5b4136c57622083d9abff3399d3f468632578bd0


Esto es impresionante, en *dos dias tenemos una base de datos funcional! Creo que este nivel de test y documentación es suficiente para pasar al siguiente nivel, log rotation, log compaction, añadir el CRC y data integrity. Y con ello al final de todo tiraremos unos BENCHMARKS.

*dos dias, pero no le hemos dedicado ni 8h reales de trabajo

  • crc: en el rebuild index, merge y hasta el ask, tenemos que decidir donde queremos pagar el precio de performacne de leer todo el value... no lo tengo claro asi que de momento paso, hasta que no tenga benchmarks no lo hare creo
  • log rotation: cuando el archivo llegue a un threshold XMiB, lo rotamos
  • log compaction: los archivos viejos los compactamos, ademas el bitcask usa .hint files para leer mas rapido los headers.
desu

#13 20 minutos mas de implementacion, ya tenemos log rotation:

https://github.com/vrnvu/bitask/commit/6ee6588cc0178ba058d9c8698d0c91be27e5b53f

lo he puesto a 4MiB para testear que funcione, cuando haga benchmark lo pondremos a 64MiB como rockdb

desu

compactacion, no os engañare esta feature ha sido mas complicada:
https://github.com/vrnvu/bitask/commit/90bc735132a6a8a3de47d0ee7e5703efaf98e684

lo que he aprendido es como testear esto después de darle vueltas y vueltas y que chatgpt no tenga ni idea.

introduces miles de key/value entries, las suficientes para crear multiples log files.
luego lo borras TODO, el compact deberia borrar TODOs los logs y dejar todo vacio!!!
asi sabes que seguro funciona al 100%

luego repite el proceso de manera similar con insert y update, inserta varias paginas, haz update de todos, si no hicieses compact, todo se añade al log y deberias tener Paginas_Iniciales x 2! Pero si compactas, tendras el mismo numero de paginas y con los valores actualizados. de hecho si compactas, por como lo implemento, lo tendras todo en una pagina. porque el algoritmo funciona asi y puede darse casos extremos de que una pagina pese mas de lo que tienes seteado.

he hecho la implementacion para que mis compactados tengan como maximo THRESHOLD_SIZE, de momento lo he quitado porque asi es mas simple y puedo verificar que todo funciona, no es muy dificil meter esta optimizacion.

he decidido hacer compactacion solo en el put de momento, aunque el remove la ser un WAL también funcionara, puede darse casos de uso claro. si buscas en el codigo if false veras donde esta a medias. y tengo el compact implementado y testeado con un trigger manual.

ahora lo que hare es introducir mas logs, que la verdad no los he estado metiendo como suelo acostumar, y ya sabeis que he explicado muchas veces como se debe loggear y con que criterios para ser production ready.

desu

https://crates.io/crates/bitask
https://docs.rs/crate/bitask/latest

FeatureBitaskLevelDBRocksDBLMDBBadgerDB
ArchitectureLog-structuredLSM-treeLSM-treeB+ treeLSM-tree
Write AmplificationMedium-High (immediate fsync)Medium (configurable)Low-Medium (configurable)LowMedium
Read PerformanceO(1) with in-memory indexO(log N)O(log N)O(log N)O(log N)
Write PerformanceHigh (sequential)HighVery HighMediumHigh
Space AmplificationHigh until compactionMediumMediumLowMedium
CompressionNoYesYesNoYes
TransactionsNoNoYesYesYes
Concurrent ReadersYesYesYesYesYes
Concurrent WritersNoYesYesYesYes
Memory UsageAll keys in RAMConfigurableConfigurableConfigurableConfigurable
Max DB SizeLimited by diskLimited by diskLimited by diskLimited by diskLimited by disk
LanguageRustC++C++CGo
ChecksumsCRC32CRC32CRC32NoCRC32
CompactionManualAutomaticAutomaticNot neededAutomatic
RecoveryLog replayLog + MANIFESTLog + MANIFESTACIDLog + MANIFEST
Bloom FiltersNoYesYesNoYes
Column FamiliesNoNoYesYesYes
SnapshotsYes (timestamp-based)YesYesYesYes
Snapshot GuaranteesAvailable until compactionAvailable until explicitly releasedAvailable until explicitly releasedMVCC always availableAvailable until explicitly released
Backup/RestoreYes (file copy)Yes (API)Yes (API)YesYes

NOTAS:

  • Compaction es MANUAL porque me falta pasar el parametro por environment o constructor! pero esta implementado para poder ser manual y automáticamente!
  • Snapshot, lo unico que me falta, como nuestros logs son timestamps, podemoste reconstruirlos en un time range facilmente y devolver una vista al usuario. Podriamos tener multi-versioning a cambio de pagar mas disco si fuese necesario.

FINAL DE LA KV:

  • Tenemos la db KV mas eficiente del mundo, pagando RAM a cambio
  • Bastante bien de features no? Algunos no los implemento porque no hacen falta.
  • La usabilidad a mi me parece lo mejor, la UX es genial, nuestro modelo de proceso y threading seguramente es el mas top del mundo y deberian mas bases de datos funcionar como nosotros lo hacemos, delegando el multi-threading al usuario

I/O Patterns Comparison:

PropertyBitaskLevelDB/RocksDB
Kernel BufferingYes (unless O_DIRECT)Yes (unless O_DIRECT)
Application BufferingNo (direct WAL)Yes (MemTable)
fsync FrequencyEvery writeConfigurable
Write PatternSingle writesBatched writes

Este seria un factor importante que mucha gente mal interpreta, fsync tiene mala fama, pero realmente fsync es distinto a O_DIRECT, fsync delega el buffering al kernel, y hoy en dia esto va muy rapido y le pasas la patata caliente a unix.

Al final he tardado 3 dias en implementar esta base de datos, me estoy haciendo mayor ya no programo tan rapido como antes.

Cerealfriend

guapisimo a mas no poder, desde la doc y el planteamiento hasta la implementacion... voy a intentar replicarlo en Go, tengo que aprender mucho ademas es buen punto para ver realmente como una db esta implementada, si me quedo por el camino, eso que me llevo igual.., me parece proyectazo que poca gente se atreve ni siqueira a pensar en hacerlo

D

A mi me flipa, soy un novato fpero, pero voi a intentar replicarlo tambien a ver hasta donde tengo huevos a llegar xD. Y como buen fpero intentaré replicarlo en java.

Usuarios habituales