🦀 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:
8
bion-kv/Cargo.toml
Normal file
8
bion-kv/Cargo.toml
Normal 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
93
bion-kv/README.md
Normal 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
137
bion-kv/src/crc32.rs
Normal 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
712
bion-kv/src/lib.rs
Normal 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()[..])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user