🦀 bions-rust vague 1 : 6 briques build-your-own-x — on ne les rebuild plus jamais

Principe RS-7 : « dès qu'on build un truc, plus personne n'a à le rebuild —
la seule chose à faire est l'optimisation. » (nexus/RepoVerse)
- bion-vc      : horloges vectorielles + MvReg fork-visible (LA spec
                 xion-relativiste-v0 enfin codée — CRDT testé par permutations)
- bion-triplet : l'Adressage Génératif (gen_hash BLAKE3, coords, résidu ;
                 résidu vide quand déjà-su ; align décidable au bit)
- bion-tsoinlog: journal append-only rejouable (CRC32 maison, crash-recovery)
- bion-kv      : magasin clé-valeur bitcask (compaction atomique, tombstones)
- bion-regex   : moteur Thompson NFA linéaire (jamais exponentiel — Russ Cox)
- bion-git     : mini-git content-addressed (SHA-1 maison + vecteurs officiels,
                 branches divergentes = le fork visible)
129 tests verts, clippy 0 warning, doc française = chaque bion est un cours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cloudion-labo
2026-08-16 01:07:31 +00:00
commit 2556698dd3
27 changed files with 6518 additions and 0 deletions

8
bion-kv/Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "bion-kv"
version = "0.1.0"
edition = "2021"
description = "Magasin clé-valeur append-only style Bitcask : log + index mémoire + compaction. std-only, build-your-own-database."
license = "MIT"
[dependencies]

93
bion-kv/README.md Normal file
View File

@@ -0,0 +1,93 @@
# bion-kv 🗃️
Magasin clé-valeur durable, **std-only, zéro dépendance**, style
[Bitcask](https://riak.com/assets/bitcask-intro.pdf) : un log append-only sur
disque + un index en mémoire + une compaction atomique. La brique
*build-your-own-database* du xerboxion — construite une fois, plus jamais
rebâtie, seulement optimisée (principe RS-7).
## Quoi
- `Kv::open(dir)` — ouvre/crée le magasin, reconstruit l'index en scannant le log
- `put(k, v)` — un append séquentiel, O(1)
- `get(k) -> Option<Vec<u8>>` — un lookup mémoire + au plus un seek/read
- `delete(k)` — appose un **tombstone** (on n'efface jamais le passé)
- `compact()` — réécrit uniquement les données vivantes, bascule **atomique par `rename`**
- `sync()` — fsync explicite quand on veut la garantie coupure-de-courant
- CRC32 (IEEE) **implémenté maison** (`bion_kv::crc32`, table `const` calculée à la compilation)
## Pourquoi Bitcask
1. **Rapide en écriture** : tout est un append séquentiel — le motif d'E/S le
plus rapide sur disque comme sur SSD. Pas d'arbre à rééquilibrer, pas de
page à réécrire.
2. **Robuste** : le passé est immuable ; un crash ne peut abîmer que la
*queue* du log. À la réouverture, chaque record est vérifié par CRC32 et
la queue malade est tronquée — les données saines survivent toujours.
3. **Simple** : le format tient en une ligne
(`crc32 | klen | vlen | clé | valeur`, tombstone = `vlen == 0xFFFFFFFF`),
la récupération = relire le log. Tout le moteur tient dans un fichier
source lisible en une soirée : c'est aussi un cours.
Le compromis assumé : la RAM porte l'index (proportionnel au **nombre de
clés**, pas au volume des valeurs), et le log grossit avec les versions
mortes — d'où `compact()`.
## Lien avec le boxion store
Le boxion store (`jOSBoxion`) expose déjà un KV par-utilisateur aux ploxions.
`bion-kv` en est le moteur côté **core Rust** : même contrat
(put/get/delete durable et réouvrable), mais embarquable partout — dans
`xerboxion-rt`, dans l'OS bare-metal, dans un bion WASM. On ne réinvente pas
le KV à chaque étage : on branche ce bloc.
## Exemple
```rust
let mut kv = bion_kv::Kv::open("/tmp/mon-magasin")?;
kv.put(b"xer", b"renderer")?;
assert_eq!(kv.get(b"xer")?.as_deref(), Some(&b"renderer"[..]));
kv.delete(b"xer")?; // tombstone dans le log
assert_eq!(kv.get(b"xer")?, None);
kv.compact()?; // le log ne garde que le vivant
# Ok::<(), std::io::Error>(())
```
## Build-your-own-x
Même famille que ces guides du dépôt
[codecrafters-io/build-your-own-x](https://github.com/codecrafters-io/build-your-own-x)
(section *Build your own Database*) :
- *Build Your Own Fast, Persistent KV Store in Rust* — exactement ce crate
- l'article fondateur : **Bitcask — A Log-Structured Hash Table for Fast
Key/Value Data** (Riak)
- cousins de design : les WAL de SQLite/Postgres, les SSTables de
LevelDB/RocksDB (Bitcask = le cas dégénéré à un seul niveau)
## Comment l'optimiser (l'invitation au fork)
L'API est **stable pour toujours** ; tout ce qui suit se fait dessous, sans
casser un seul appelant :
- **Fichiers multiples + hint files** (le vrai Bitcask) : fermer le segment
actif à N Mo, compacter segment par segment, et écrire un *hint file*
(clé → offset) pour rouvrir sans relire les valeurs.
- **Compaction incrémentale** : aujourd'hui `compact()` copie tout le vivant
d'un coup ; en segments, on ne compacte que les segments les plus « morts ».
- **Batching/`put_many`** : grouper plusieurs records dans un seul `write_all`
+ un seul fsync.
- **CRC matériel** : remplacer la table par l'instruction `crc32c` (SSE4.2)
ou du SIMD — le format ne change pas si on garde le polynôme IEEE.
- **Index plus dense** : `HashMap<Vec<u8>, _>` → arène de clés + table à
adressage ouvert, ou un ART pour les scans par préfixe.
- **`get` sans `&mut`** : `pread`/`read_at` (FileExt) pour des lectures
concurrentes sans déplacer le curseur.
## Tests
`cargo test -p bion-kv` — 17 tests unitaires + 2 doc-tests : put/get/delete,
écrasements, clés/valeurs binaires et vides, persistance après réouverture,
tombstones rejoués, compaction (taille réduite, données intactes, réouverture),
queue corrompue (bruit, record tronqué, bit-flip détecté par CRC), fichier
étranger refusé, brouillon de compaction orphelin ignoré, 500 clés.

137
bion-kv/src/crc32.rs Normal file
View File

@@ -0,0 +1,137 @@
//! # CRC32 (IEEE 802.3) — implémenté depuis les principes
//!
//! Un CRC (*Cyclic Redundancy Check*) est le reste d'une division polynomiale
//! dans **GF(2)** — le corps à deux éléments où l'addition est un XOR.
//! On voit le message comme un gros polynôme à coefficients binaires, on le
//! divise par un polynôme générateur fixé, et le reste (32 bits ici) sert
//! d'empreinte : la moindre rafale d'erreurs ≤ 32 bits change le reste.
//!
//! ## Le polynôme
//!
//! CRC-32/IEEE utilise `x³² + x²⁶ + x²³ + x²² + x¹⁶ + x¹² + x¹¹ + x¹⁰ + x⁸ +
//! x⁷ + x⁵ + x⁴ + x² + x + 1`. En représentation *reflected* (bit de poids
//! faible = plus haut degré), cela donne la constante `0xEDB8_8320`.
//!
//! ## L'algorithme table-driven
//!
//! Diviser bit à bit coûte 8 itérations par octet. L'astuce classique :
//! pré-calculer, pour chacun des 256 octets possibles, l'effet de ces
//! 8 itérations. La table est construite **à la compilation** (`const fn`),
//! donc zéro coût à l'exécution et zéro dépendance.
//!
//! ## Conventions (celles de zlib, PNG, gzip, Ethernet…)
//!
//! * registre initialisé à `0xFFFF_FFFF` ;
//! * bits réfléchis (on traite le LSB d'abord) ;
//! * complément final (`XOR 0xFFFF_FFFF`).
//!
//! Vecteur de test canonique : `crc32(b"123456789") == 0xCBF4_3926`.
/// Polynôme CRC-32/IEEE, forme *reflected*.
const POLY: u32 = 0xEDB8_8320;
/// Table des 256 restes partiels, construite à la compilation.
const TABLE: [u32; 256] = build_table();
const fn build_table() -> [u32; 256] {
let mut table = [0u32; 256];
let mut i = 0;
while i < 256 {
let mut crc = i as u32;
let mut bit = 0;
while bit < 8 {
// Si le bit sortant vaut 1, on « soustrait » (XOR) le polynôme.
crc = if crc & 1 != 0 {
(crc >> 1) ^ POLY
} else {
crc >> 1
};
bit += 1;
}
table[i] = crc;
i += 1;
}
table
}
/// Calcule le CRC32 (IEEE, conventions zlib) de `data` en une passe.
///
/// ```
/// assert_eq!(bion_kv::crc32::crc32(b"123456789"), 0xCBF4_3926);
/// ```
pub fn crc32(data: &[u8]) -> u32 {
let mut h = Hasher::new();
h.update(data);
h.finish()
}
/// Hachage incrémental : permet de sommer plusieurs tranches sans les
/// concaténer (utile pour `en-tête + clé + valeur` dans le log).
#[derive(Debug, Clone)]
pub struct Hasher {
state: u32,
}
impl Hasher {
/// Nouveau calcul, registre initialisé à `0xFFFF_FFFF`.
pub fn new() -> Self {
Self { state: 0xFFFF_FFFF }
}
/// Absorbe une tranche d'octets.
pub fn update(&mut self, data: &[u8]) {
for &b in data {
let idx = ((self.state ^ b as u32) & 0xFF) as usize;
self.state = (self.state >> 8) ^ TABLE[idx];
}
}
/// Termine et renvoie le CRC (complément final).
pub fn finish(&self) -> u32 {
self.state ^ 0xFFFF_FFFF
}
}
impl Default for Hasher {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vecteur_canonique() {
// LE vecteur de test du CRC-32/IEEE, présent dans toutes les RFC.
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
}
#[test]
fn vide_et_connus() {
assert_eq!(crc32(b""), 0x0000_0000);
assert_eq!(crc32(b"a"), 0xE8B7_BE43);
assert_eq!(crc32(b"abc"), 0x3524_41C2);
assert_eq!(
crc32(b"The quick brown fox jumps over the lazy dog"),
0x414F_A339
);
}
#[test]
fn incremental_equivaut_une_passe() {
let mut h = Hasher::new();
h.update(b"123");
h.update(b"456");
h.update(b"789");
assert_eq!(h.finish(), crc32(b"123456789"));
}
#[test]
fn sensible_au_moindre_bit() {
let a = crc32(b"bion-kv");
let b = crc32(b"bion-kw"); // un seul bit d'écart sur le dernier octet
assert_ne!(a, b);
}
}

712
bion-kv/src/lib.rs Normal file
View File

@@ -0,0 +1,712 @@
//! # bion-kv — le magasin clé-valeur *build-your-own-database*, style Bitcask
//!
//! ## Pourquoi Bitcask ?
//!
//! Bitcask (le moteur historique de Riak) repose sur une idée d'une simplicité
//! radicale : **on n'écrit jamais au milieu d'un fichier**. Toute mutation —
//! `put` comme `delete` — est *ajoutée à la fin* d'un log. Un index en mémoire
//! (`HashMap` clé → position dans le fichier) dit où vit la dernière version
//! de chaque clé. Conséquences :
//!
//! * **rapide en écriture** : une écriture = un `append` séquentiel, le motif
//! d'E/S le plus rapide qui existe (disque comme SSD) ;
//! * **robuste** : le passé n'est jamais modifié, donc un crash ne peut
//! corrompre au pire que la *queue* du fichier — et la réouverture la
//! détecte (CRC32) et la tronque proprement ;
//! * **simple** : `get` = un seek + un read ; la récupération = relire le log
//! du début à la fin pour reconstruire l'index.
//!
//! Le prix à payer : le log grossit avec les versions mortes (valeurs
//! écrasées, clés supprimées). D'où [`Kv::compact`], qui réécrit uniquement
//! les données *vivantes* dans un nouveau fichier puis bascule **atomiquement**
//! par `rename` — à tout instant, il existe sur disque un log complet et
//! valide.
//!
//! ## Format du log (`bion-kv.log`)
//!
//! ```text
//! [ en-tête "BIONKV1\n" (8 octets) ]
//! [ record ]*
//!
//! record :
//! crc32 : u32 LE — CRC32 (IEEE) de tout ce qui suit (klen‖vlen‖clé‖valeur)
//! klen : u32 LE — longueur de la clé
//! vlen : u32 LE — longueur de la valeur, ou 0xFFFF_FFFF = TOMBSTONE
//! clé : [u8; klen]
//! valeur : [u8; vlen] (absente pour un tombstone)
//! ```
//!
//! Un **tombstone** (pierre tombale) est le record qui matérialise une
//! suppression : on ne peut pas « retirer » une clé d'un log append-only,
//! alors on ajoute un record qui dit « cette clé est morte ». À la
//! reconstruction de l'index, le tombstone retire la clé ; à la compaction,
//! clé et tombstone disparaissent physiquement.
//!
//! ## Lien avec le boxion store
//!
//! Le boxion store (`jOSBoxion`) offre déjà un KV par-utilisateur côté
//! ploxions ; `bion-kv` en est la **brique moteur côté core Rust** : le même
//! contrat (`put`/`get`/`delete`, durable, réouvrable) mais au niveau du
//! métal, embarquable dans `xerboxion-rt`, un OS bare-metal ou un binaire
//! WASM. On le construit UNE fois ; ensuite on ne fait plus que l'optimiser
//! (principe RS-7 : des blocs, pas des roues réinventées).
//!
//! ## Exemple
//!
//! ```
//! let dir = std::env::temp_dir().join(format!("bion-kv-doc-{}", std::process::id()));
//! let mut kv = bion_kv::Kv::open(&dir).unwrap();
//! kv.put(b"xer", b"renderer").unwrap();
//! assert_eq!(kv.get(b"xer").unwrap().as_deref(), Some(&b"renderer"[..]));
//! kv.delete(b"xer").unwrap();
//! assert_eq!(kv.get(b"xer").unwrap(), None);
//! # drop(kv); std::fs::remove_dir_all(&dir).ok();
//! ```
pub mod crc32;
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
/// Nom du fichier de log dans le dossier du magasin.
const LOG_NAME: &str = "bion-kv.log";
/// Fichier temporaire de compaction (basculé par `rename`).
const COMPACT_NAME: &str = "bion-kv.log.compact";
/// En-tête magique : identifie le format et sa version. Versionner le format
/// dans le fichier lui-même, c'est ce qui permet la rétrocompatibilité
/// éternelle : un futur `BIONKV2` saura toujours relire un `BIONKV1`.
const MAGIC: &[u8; 8] = b"BIONKV1\n";
/// Sentinelle `vlen` marquant un tombstone (suppression).
const TOMBSTONE: u32 = u32::MAX;
/// Taille de l'en-tête fixe d'un record : crc(4) + klen(4) + vlen(4).
const REC_HDR: u64 = 12;
/// Position de la *valeur* d'une clé vivante dans le log.
#[derive(Debug, Clone, Copy)]
struct Slot {
/// Offset du premier octet de la valeur dans le fichier.
value_off: u64,
/// Longueur de la valeur.
value_len: u32,
}
/// Le magasin clé-valeur. Une instance = un dossier sur disque.
///
/// Toutes les clés vivantes tiennent dans l'index mémoire (des `Vec<u8>`),
/// les valeurs restent sur disque : c'est le compromis Bitcask — RAM
/// proportionnelle au *nombre de clés*, pas au volume de données.
pub struct Kv {
/// Dossier du magasin (contient le log).
dir: PathBuf,
/// Le log, ouvert en lecture + append.
file: File,
/// Longueur logique du fichier = offset du prochain record.
write_pos: u64,
/// L'index : clé → position de sa dernière valeur vivante.
index: HashMap<Vec<u8>, Slot>,
}
impl Kv {
/// Ouvre (ou crée) un magasin dans `dir`.
///
/// Scanne le log du début à la fin pour reconstruire l'index. Si la
/// **queue** du fichier est corrompue (crash en pleine écriture : record
/// tronqué ou CRC invalide), elle est **tronquée** et l'ouverture réussit
/// avec toutes les données saines qui précèdent — c'est le contrat de
/// récupération de Bitcask.
pub fn open<P: AsRef<Path>>(dir: P) -> io::Result<Kv> {
let dir = dir.as_ref().to_path_buf();
fs::create_dir_all(&dir)?;
// Un `.compact` orphelin = compaction interrompue AVANT le rename :
// le log principal est intact, le brouillon est simplement jeté.
let _ = fs::remove_file(dir.join(COMPACT_NAME));
let path = dir.join(LOG_NAME);
let mut file = OpenOptions::new()
.read(true)
.append(true)
.create(true)
.open(&path)?;
let len = file.metadata()?.len();
if len == 0 {
file.write_all(MAGIC)?;
} else {
let mut magic = [0u8; 8];
file.seek(SeekFrom::Start(0))?;
if len < 8 || {
file.read_exact(&mut magic)?;
&magic != MAGIC
} {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"bion-kv : en-tête de log inconnu (fichier étranger ?)",
));
}
}
let mut kv = Kv {
dir,
file,
write_pos: 0,
index: HashMap::new(),
};
kv.rebuild_index()?;
Ok(kv)
}
/// Écrit (ou remplace) la valeur associée à `key`.
///
/// Un `put` = un seul `append` au log + une entrée d'index : O(1),
/// séquentiel, jamais de réécriture en place.
pub fn put(&mut self, key: &[u8], value: &[u8]) -> io::Result<()> {
let vlen = u32::try_from(value.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "bion-kv : valeur > 4 Gio"))?;
if vlen == TOMBSTONE {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"bion-kv : valeur > 4 Gio",
));
}
let rec_off = self.append_record(key, Some(value))?;
self.index.insert(
key.to_vec(),
Slot {
value_off: rec_off + REC_HDR + key.len() as u64,
value_len: vlen,
},
);
Ok(())
}
/// Lit la dernière valeur associée à `key`, ou `None` si absente/supprimée.
///
/// Un `get` = une consultation de l'index mémoire puis, au plus, **un**
/// seek + un read sur disque.
pub fn get(&mut self, key: &[u8]) -> io::Result<Option<Vec<u8>>> {
let slot = match self.index.get(key) {
Some(s) => *s,
None => return Ok(None),
};
let mut buf = vec![0u8; slot.value_len as usize];
self.file.seek(SeekFrom::Start(slot.value_off))?;
self.file.read_exact(&mut buf)?;
Ok(Some(buf))
}
/// Supprime `key` en ajoutant un **tombstone** au log.
///
/// No-op silencieux si la clé n'existe pas (rien à tuer, rien à écrire).
pub fn delete(&mut self, key: &[u8]) -> io::Result<()> {
if !self.index.contains_key(key) {
return Ok(());
}
self.append_record(key, None)?;
self.index.remove(key);
Ok(())
}
/// Réécrit le log en ne gardant que les données **vivantes**, puis bascule
/// atomiquement via `rename`.
///
/// Déroulé crash-sûr :
/// 1. écrire toutes les paires vivantes dans `bion-kv.log.compact` ;
/// 2. `sync_all()` — le brouillon est durable ;
/// 3. `rename(compact, log)` — atomique sur un même système de fichiers :
/// avant, l'ancien log est complet ; après, le nouveau l'est. Aucune
/// fenêtre où le magasin serait invalide ;
/// 4. rouvrir le fichier et rebrancher l'index sur les nouveaux offsets.
pub fn compact(&mut self) -> io::Result<()> {
let tmp_path = self.dir.join(COMPACT_NAME);
let log_path = self.dir.join(LOG_NAME);
// 1. Brouillon : en-tête + records vivants, en calculant au passage
// les futurs offsets de l'index.
let mut tmp = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)?;
tmp.write_all(MAGIC)?;
let mut pos = MAGIC.len() as u64;
let mut new_index: HashMap<Vec<u8>, Slot> = HashMap::with_capacity(self.index.len());
// (tri des clés = sortie déterministe, agréable pour tester/diff-er)
let mut keys: Vec<Vec<u8>> = self.index.keys().cloned().collect();
keys.sort();
for key in keys {
let value = self
.get(&key)?
.expect("clé indexée forcément vivante avant compaction");
let rec = encode_record(&key, Some(&value));
tmp.write_all(&rec)?;
new_index.insert(
key.clone(),
Slot {
value_off: pos + REC_HDR + key.len() as u64,
value_len: value.len() as u32,
},
);
pos += rec.len() as u64;
}
// 2. Durabilité du brouillon avant de s'y engager.
tmp.sync_all()?;
drop(tmp);
// 3. La bascule atomique.
fs::rename(&tmp_path, &log_path)?;
// Durcir aussi l'entrée de répertoire (le rename lui-même).
if let Ok(d) = File::open(&self.dir) {
let _ = d.sync_all();
}
// 4. Le handle ouvert pointe encore sur l'ancien inode : rouvrir.
self.file = OpenOptions::new().read(true).append(true).open(&log_path)?;
self.write_pos = pos;
self.index = new_index;
Ok(())
}
/// Nombre de clés vivantes.
pub fn len(&self) -> usize {
self.index.len()
}
/// `true` si aucune clé vivante.
pub fn is_empty(&self) -> bool {
self.index.is_empty()
}
/// Les clés vivantes, dans un ordre arbitraire (celui du `HashMap`).
pub fn keys(&self) -> impl Iterator<Item = &[u8]> {
self.index.keys().map(|k| k.as_slice())
}
/// Force l'écriture physique du log sur le support (`fsync`).
///
/// `put`/`delete` laissent l'OS vider son cache quand il veut (comme
/// Bitcask par défaut) : appeler `sync` quand on veut la garantie
/// « survivra à une coupure de courant maintenant ».
pub fn sync(&mut self) -> io::Result<()> {
self.file.sync_all()
}
// ---------------------------------------------------------------- privé
/// Ajoute un record au log (valeur `None` = tombstone) et renvoie l'offset
/// où il commence.
fn append_record(&mut self, key: &[u8], value: Option<&[u8]>) -> io::Result<u64> {
if u32::try_from(key.len()).is_err() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"bion-kv : clé > 4 Gio",
));
}
let rec = encode_record(key, value);
let off = self.write_pos;
// Le fichier est en mode append : write_all écrit toujours à la fin,
// même si un `get` a déplacé le curseur entre-temps.
self.file.write_all(&rec)?;
self.write_pos += rec.len() as u64;
Ok(off)
}
/// Rejoue tout le log pour reconstruire l'index. Tronque la queue au
/// premier record invalide (crash en cours d'écriture).
fn rebuild_index(&mut self) -> io::Result<()> {
let file_len = self.file.metadata()?.len();
let mut pos = MAGIC.len() as u64;
self.index.clear();
self.file.seek(SeekFrom::Start(pos))?;
// Lecture bufferisée : un scan séquentiel, pas un syscall par champ.
let mut reader = io::BufReader::new(&mut self.file);
loop {
// Assez d'octets pour un en-tête de record ?
if file_len - pos < REC_HDR {
break;
}
let mut hdr = [0u8; REC_HDR as usize];
reader.read_exact(&mut hdr)?;
let crc_lu = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
let klen = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]);
let vlen = u32::from_le_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]);
let data_len = klen as u64 + if vlen == TOMBSTONE { 0 } else { vlen as u64 };
// Longueurs annoncées > octets restants ⇒ record tronqué (ou
// en-tête de bruit) ⇒ queue corrompue. On vérifie AVANT d'allouer
// pour qu'un `klen` fantaisiste ne fasse pas exploser la RAM.
if data_len > file_len - pos - REC_HDR {
break;
}
let mut data = vec![0u8; data_len as usize];
reader.read_exact(&mut data)?;
let mut h = crc32::Hasher::new();
h.update(&hdr[4..]);
h.update(&data);
if h.finish() != crc_lu {
break; // corruption : on s'arrête au dernier record sain
}
let key = data[..klen as usize].to_vec();
if vlen == TOMBSTONE {
self.index.remove(&key);
} else {
self.index.insert(
key,
Slot {
value_off: pos + REC_HDR + klen as u64,
value_len: vlen,
},
);
}
pos += REC_HDR + data_len;
}
drop(reader);
if pos < file_len {
// Amputer la queue malade pour que les prochains appends
// repartent d'une base 100 % saine.
self.file.set_len(pos)?;
}
self.write_pos = pos;
Ok(())
}
}
impl std::fmt::Debug for Kv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Kv")
.field("dir", &self.dir)
.field("keys", &self.index.len())
.field("log_bytes", &self.write_pos)
.finish()
}
}
/// Sérialise un record complet (CRC compris). `None` = tombstone.
fn encode_record(key: &[u8], value: Option<&[u8]>) -> Vec<u8> {
let klen = key.len() as u32;
let vlen = match value {
Some(v) => v.len() as u32,
None => TOMBSTONE,
};
let value = value.unwrap_or(&[]);
let mut rec = Vec::with_capacity(REC_HDR as usize + key.len() + value.len());
rec.extend_from_slice(&[0u8; 4]); // place du CRC, rempli à la fin
rec.extend_from_slice(&klen.to_le_bytes());
rec.extend_from_slice(&vlen.to_le_bytes());
rec.extend_from_slice(key);
rec.extend_from_slice(value);
let crc = crc32::crc32(&rec[4..]);
rec[..4].copy_from_slice(&crc.to_le_bytes());
rec
}
// ============================================================== TESTS =====
#[cfg(test)]
mod tests {
use super::*;
/// Dossier de test unique, nettoyé au drop.
struct TmpDir(PathBuf);
impl TmpDir {
fn new(tag: &str) -> Self {
let p = std::env::temp_dir().join(format!(
"bion-kv-test-{}-{}-{:?}",
tag,
std::process::id(),
std::thread::current().id(),
));
let _ = fs::remove_dir_all(&p);
TmpDir(p)
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn log_len(dir: &Path) -> u64 {
fs::metadata(dir.join(LOG_NAME)).unwrap().len()
}
#[test]
fn put_get_delete() {
let t = TmpDir::new("base");
let mut kv = Kv::open(&t.0).unwrap();
assert!(kv.is_empty());
kv.put(b"a", b"1").unwrap();
kv.put(b"b", b"2").unwrap();
assert_eq!(kv.get(b"a").unwrap().as_deref(), Some(&b"1"[..]));
assert_eq!(kv.get(b"b").unwrap().as_deref(), Some(&b"2"[..]));
assert_eq!(kv.get(b"absent").unwrap(), None);
assert_eq!(kv.len(), 2);
kv.delete(b"a").unwrap();
assert_eq!(kv.get(b"a").unwrap(), None);
assert_eq!(kv.len(), 1);
// delete d'une clé absente : no-op sans erreur
kv.delete(b"jamais-vue").unwrap();
}
#[test]
fn ecrasement_derniere_valeur_gagne() {
let t = TmpDir::new("overwrite");
let mut kv = Kv::open(&t.0).unwrap();
kv.put(b"k", b"v1").unwrap();
kv.put(b"k", b"v2").unwrap();
kv.put(b"k", b"la-bonne").unwrap();
assert_eq!(kv.get(b"k").unwrap().as_deref(), Some(&b"la-bonne"[..]));
assert_eq!(kv.len(), 1);
}
#[test]
fn cles_et_valeurs_binaires_et_vides() {
let t = TmpDir::new("binaire");
let mut kv = Kv::open(&t.0).unwrap();
let key = [0u8, 255, 10, 13, 0]; // NUL, non-UTF-8, retours ligne
kv.put(&key, b"").unwrap(); // valeur vide = légitime
kv.put(b"", b"valeur-de-la-cle-vide").unwrap();
assert_eq!(kv.get(&key).unwrap().as_deref(), Some(&b""[..]));
assert_eq!(
kv.get(b"").unwrap().as_deref(),
Some(&b"valeur-de-la-cle-vide"[..])
);
}
#[test]
fn persistance_apres_reouverture() {
let t = TmpDir::new("persist");
{
let mut kv = Kv::open(&t.0).unwrap();
kv.put(b"xer", b"renderer").unwrap();
kv.put(b"kion", b"3d").unwrap();
kv.put(b"xer", b"renderer-v2").unwrap(); // écrasement pré-fermeture
}
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(kv.len(), 2);
assert_eq!(
kv.get(b"xer").unwrap().as_deref(),
Some(&b"renderer-v2"[..])
);
assert_eq!(kv.get(b"kion").unwrap().as_deref(), Some(&b"3d"[..]));
}
#[test]
fn tombstone_survit_a_la_reouverture() {
let t = TmpDir::new("tombstone");
{
let mut kv = Kv::open(&t.0).unwrap();
kv.put(b"mort", b"bientot").unwrap();
kv.put(b"vif", b"toujours").unwrap();
kv.delete(b"mort").unwrap();
}
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(kv.get(b"mort").unwrap(), None, "le tombstone doit rejouer");
assert_eq!(kv.get(b"vif").unwrap().as_deref(), Some(&b"toujours"[..]));
assert_eq!(kv.len(), 1);
}
#[test]
fn compaction_reduit_et_preserve() {
let t = TmpDir::new("compact");
let mut kv = Kv::open(&t.0).unwrap();
// Beaucoup de versions mortes : 50 écrasements + des suppressions.
for i in 0..50u32 {
kv.put(b"chaud", format!("version-{i}").as_bytes()).unwrap();
}
kv.put(b"stable", b"inchangee").unwrap();
kv.put(b"ephemere", b"grosse-valeur-condamnee-aaaaaaaaaaaa")
.unwrap();
kv.delete(b"ephemere").unwrap();
let avant = log_len(&t.0);
kv.compact().unwrap();
let apres = log_len(&t.0);
assert!(
apres < avant,
"compaction doit réduire : {apres} >= {avant}"
);
// Données intactes, tombstones physiquement disparus.
assert_eq!(
kv.get(b"chaud").unwrap().as_deref(),
Some(&b"version-49"[..])
);
assert_eq!(
kv.get(b"stable").unwrap().as_deref(),
Some(&b"inchangee"[..])
);
assert_eq!(kv.get(b"ephemere").unwrap(), None);
assert_eq!(kv.len(), 2);
// Et le magasin reste pleinement utilisable après compaction…
kv.put(b"apres", b"compaction").unwrap();
assert_eq!(
kv.get(b"apres").unwrap().as_deref(),
Some(&b"compaction"[..])
);
drop(kv);
// … y compris après réouverture (les offsets rebranchés sont bons).
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(
kv.get(b"chaud").unwrap().as_deref(),
Some(&b"version-49"[..])
);
assert_eq!(
kv.get(b"apres").unwrap().as_deref(),
Some(&b"compaction"[..])
);
assert_eq!(kv.len(), 3);
}
#[test]
fn queue_corrompue_garbage_toleree() {
let t = TmpDir::new("garbage");
{
let mut kv = Kv::open(&t.0).unwrap();
kv.put(b"sain", b"avant-le-crash").unwrap();
}
// Simule un crash : des octets de bruit atterrissent en fin de log.
let path = t.0.join(LOG_NAME);
let mut f = OpenOptions::new().append(true).open(&path).unwrap();
f.write_all(&[
0xDE, 0xAD, 0xBE, 0xEF, 0x42, 0x42, 0x42, 0x42, 1, 2, 3, 4, 5,
])
.unwrap();
drop(f);
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(
kv.get(b"sain").unwrap().as_deref(),
Some(&b"avant-le-crash"[..])
);
// La queue a été tronquée : on peut ré-écrire par-dessus sans souci.
kv.put(b"repart", b"proprement").unwrap();
drop(kv);
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(
kv.get(b"repart").unwrap().as_deref(),
Some(&b"proprement"[..])
);
assert_eq!(kv.len(), 2);
}
#[test]
fn queue_corrompue_record_tronque() {
let t = TmpDir::new("tronque");
{
let mut kv = Kv::open(&t.0).unwrap();
kv.put(b"complet", b"ok").unwrap();
kv.put(b"coupe", b"cette-valeur-va-perdre-sa-fin").unwrap();
}
// Ampute les 5 derniers octets : le dernier record devient invalide.
let path = t.0.join(LOG_NAME);
let len = fs::metadata(&path).unwrap().len();
let f = OpenOptions::new().write(true).open(&path).unwrap();
f.set_len(len - 5).unwrap();
drop(f);
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(kv.get(b"complet").unwrap().as_deref(), Some(&b"ok"[..]));
assert_eq!(
kv.get(b"coupe").unwrap(),
None,
"le record amputé est perdu"
);
assert_eq!(kv.len(), 1);
}
#[test]
fn crc_detecte_un_bit_flip_au_milieu_de_la_queue() {
let t = TmpDir::new("bitflip");
{
let mut kv = Kv::open(&t.0).unwrap();
kv.put(b"avant", b"intacte").unwrap();
kv.put(b"victime", b"un bit va sauter ici").unwrap();
}
// Flip d'un bit dans la VALEUR du dernier record (pas dans l'en-tête).
let path = t.0.join(LOG_NAME);
let mut bytes = fs::read(&path).unwrap();
let n = bytes.len();
bytes[n - 3] ^= 0x01;
fs::write(&path, &bytes).unwrap();
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(kv.get(b"avant").unwrap().as_deref(), Some(&b"intacte"[..]));
assert_eq!(
kv.get(b"victime").unwrap(),
None,
"CRC doit rejeter le record"
);
}
#[test]
fn fichier_etranger_refuse() {
let t = TmpDir::new("etranger");
fs::create_dir_all(&t.0).unwrap();
fs::write(t.0.join(LOG_NAME), b"PAS-UN-LOG-BIONKV-DU-TOUT").unwrap();
let err = Kv::open(&t.0).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn brouillon_de_compaction_orphelin_ignore() {
let t = TmpDir::new("orphelin");
{
let mut kv = Kv::open(&t.0).unwrap();
kv.put(b"k", b"v").unwrap();
}
// Simule une compaction interrompue avant le rename.
fs::write(t.0.join(COMPACT_NAME), b"brouillon a moitie ecrit").unwrap();
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(kv.get(b"k").unwrap().as_deref(), Some(&b"v"[..]));
assert!(
!t.0.join(COMPACT_NAME).exists(),
"le brouillon doit être jeté"
);
}
#[test]
fn keys_et_sync() {
let t = TmpDir::new("keys");
let mut kv = Kv::open(&t.0).unwrap();
kv.put(b"a", b"1").unwrap();
kv.put(b"b", b"2").unwrap();
kv.delete(b"a").unwrap();
let ks: Vec<&[u8]> = kv.keys().collect();
assert_eq!(ks, vec![&b"b"[..]]);
kv.sync().unwrap(); // fsync explicite : ne doit pas échouer
}
#[test]
fn gros_volume_et_reouverture() {
let t = TmpDir::new("volume");
{
let mut kv = Kv::open(&t.0).unwrap();
for i in 0..500u32 {
kv.put(format!("cle-{i}").as_bytes(), &i.to_le_bytes())
.unwrap();
}
for i in (0..500u32).step_by(2) {
kv.delete(format!("cle-{i}").as_bytes()).unwrap();
}
kv.compact().unwrap();
}
let mut kv = Kv::open(&t.0).unwrap();
assert_eq!(kv.len(), 250);
assert_eq!(kv.get(b"cle-0").unwrap(), None);
assert_eq!(
kv.get(b"cle-499").unwrap().as_deref(),
Some(&499u32.to_le_bytes()[..])
);
}
}