Release
v1.4.0(Tagv1.4.0, Commit8339114) — veröffentlicht auf crates.io. Die API ist eingefroren (Post-Release-Baseline); sieheOPERATIONS.mdfür Betriebsregeln und bekannte Einsatzgrenzen.
Eine kleine, eigenständige LSM-Engine (Log-Structured Merge Tree) in Rust — die Grundlage einer eigenen Datenbank, geschrieben von Grund auf.
Ursprung (v0.1): eine zuverlässige Key-Value-Maschine, die
put/get/delete/scanbeherrscht, einen Write-Ahead-Log zur Crash-Recovery nutzt und SSTables kompaktiert. Darauf aufbauend: Entity-Modell, Secondary- und Composite-Indexes, Query-Planner, atomare Transaktionen, CAS/Partial-Updates, Backup/Restore.
- Architektur
- Schreibpfad
- Lesepfad
- Compaction
- Crash-Recovery
- Datenformate
- Public API
- Konfiguration
- Projektstruktur
- Verwendung
- Tests
- Bekannte Einschränkungen
- v1.0 — Admin-Tooling
- v1.1 — Backup / Restore
- v1.2 — CAS / Partial Updates
- v1.3 — Composite Indexes
- Roadmap
- Operations
- Aktueller Status — v1.3 Freeze (
911c4c7)
put / get / delete / scan
│
▼
┌─────────────────────┐
│ Database │ lib.rs (öffentliche API)
└─────────┬───────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
MemTable Merge-Iterator Manifest
(RAM) │ (Level-Liste)
│ │
▼ ▼
flush ◄───── SSTables (sstable.rs)
│
┌───────┴───────┐
▼ ▼
WAL (wal.rs) Compaction (compaction.rs)
│
▼
DISK
Grundidee: Neue Schreibzugriffe landen zuerst in der MemTable (RAM) und im Write-Ahead-Log (Platte). Wird die MemTable zu groß, wird sie als unveränderliche SSTable (Sorted String Table) geflusht. Viele kleine SSTables werden im Hintergrund zu größeren zusammengeführt (Compaction). So werden zufällige Schreibzugriffe in sequenzielle umgewandelt — schnell für write-heavy Workloads.
db.put(key, value)
│
▼ 1. Append ans WAL (crc-gesichert)
▼ 2. In die MemTable (byte-sortiert)
│
├─ MemTable voll?
│ └─ ja → flush():
│ │ SSTable schreiben (Level 0)
│ │ Manifest aktualisieren + atomar speichern
│ │ WAL leeren
│ │ MemTable zurücksetzen
│ └─ Level 0 zu voll? → compact_level(0)
│
└─ nein → fertig
- Delete wird als Tombstone (leerer Wert) in MemTable und WAL geschrieben — der Key bleibt solange sichtbar, bis die Compaction ihn endgültig entfernt.
get / scan mergen die Quellen neueste zuerst, damit der neueste Wert gewinnt:
- MemTable (neueste Quelle) — autoritativ, wird zuerst geprüft.
- Level 0, Level 1, … (älter) — per
TableReader::get(mit Bloom-Filter) bzw. Merge-Iterator.
Für scan werden alle Quellen über einen Merge-Iterator zusammengeführt. Bei gleichen Keys gewinnt die kleinere Quell-Index-Nummer (frischer).
Seit v0.5.1 ist der Lesepfad lazy: scan_stream materialisiert die Datenmenge nicht, sondern hält nur einen Cursor pro Quelle (MemTable-Kopie + je einen TableIter pro SSTable, positioniert per Sparse-Index). Ein ScanIter besitzt exklusiv &mut Database für seine Lebensdauer, wodurch put/delete/flush/compact während der Iteration unmöglich sind und die konsistente Snapshot-Sicht durch Borrowing garantiert ist. scan ist ein Komfort-Wrapper, der den Stream einsammelt. Dieselben Quellen werden auch im Entity-/Query-Layer gestreamt: Limit hört nach n Zeilen auf zu ziehen, Sort blockiert (muss alles konsumieren).
- Nach dem Flush: Wenn Level 0 ≥
l0_compact_thresholdTabellen hat, wird Level 0 in Level 1 gemergt. - Die Tabellen beider Ebenen werden gelesen, per Merge-Iterator dedupliziert (neuere Werte gewinnen, Tombstones überschatten alte Werte) und als eine neue SSTable in Level 1 geschrieben.
- Die alten Tabellen werden aus Manifest und Platte entfernt.
- Hinweis: v0.1 führt genau einen Merge-Schritt pro Flush aus (kein mehrstufiges Level-System).
Beim Start Database::open:
- Manifest laden → weiß, welche SSTables existieren und in welchem Level.
- WAL neu einspielen (
wal::replay) → ungeflushte Schreibzugriffe aus der MemTable rekonstruieren.
Das WAL ist damit der Durability-Mechanismus, die SSTables der persistierte Zustand. Ein abgeschnittener oder beschädigter Log-Eintrag (typisch bei einem Crash mitten im Schreiben) wird ignoriert — alles davor bleibt gültig.
Seit v0.4 löst wal::replay Transaktionen auf: Es wendet nur Transaktionen an, für die ein Commit-Record vorhanden ist, und verwirft alle Mutationen von abgebrochenen/abgestürzten Transaktionen. Damit überlebt ein committeter Block einen Crash vollständig, ein uncommitteter nicht.
Seit v0.4 ist jeder Datensatz typisiert:
[u32 crc][u8 type][payload]
Die CRC wird über [type][payload] berechnet; fsync über sync().
type |
Bedeutung | Payload |
|---|---|---|
0 Put |
Nicht-transaktionales Schreiben | [u32 key_len][u32 val_len][key][value] |
1 Delete |
Nicht-transaktionales Löschen | [u32 key_len][key] |
2 Begin |
Transaktions-Start | [u64 tx] |
3 TxPut |
Transaktions-Schreiben | [u64 tx][u32 key_len][u32 val_len][key][value] |
4 TxDelete |
Transaktions-Löschen | [u64 tx][u32 key_len][key] |
5 Commit |
Transaktions-Commit | [u64 tx] |
6 Abort |
Transaktions-Abbruch | [u64 tx] |
Recovery-Regel: Eine Transaktion wird genau dann angewandt, wenn ein Commit-Record für ihre ID vorhanden ist. Begin/TxPut/TxDelete einer Transaktion ohne Commit (abgebrochen oder abgestürzt) werden verworfen. Nicht-transaktionale Records werden unverändert übernommen.
[records] [sparse index] [bloom filter] [footer]
- Records:
[u32 key_len][u32 val_len][u8 flags][key][value] - Sparse Index: alle
spacing(16) Records ein Eintrag[key_len][key][offset]→ binäre Suche für Punkt-Lookups. - Bloom-Filter:
[u32 nbits][u32 k][bits]→ sagt zuverlässig "definitiv nicht enthalten". - Footer (44 Bytes):
index_offset, index_len, bloom_offset, bloom_len, spacing (usize), num_records, magic. Über die Footer-Offsets wird Index und Bloom direkt adressiert.
N <next_table_id>
L <level> <table_id> ...
Atomar ersetzt (Temp-Datei + rename), damit nie ein halbes Manifest entsteht.
| Methode | Beschreibung |
|---|---|
Database::open(dir) |
Öffnet oder erstellt eine DB, führt Recovery durch. |
Database::open_with(dir, opts) |
Wie oben, mit eigener Konfiguration. |
db.put(key, value) |
Setzt einen Wert. |
db.delete(key) |
Löscht einen Key (Tombstone). |
db.get(key) |
Liefert Option<Vec<u8>> (None = nicht vorhanden oder gelöscht). |
db.scan(start, end) |
Sortierter Bereichs-Scan, liefert Vec<(Vec<u8>, Option<Vec<u8>>)> (Komfort-Wrapper). |
db.scan_stream(start, end) |
v0.5.1: lazy Bereichs-Scan, liefert einen Iterator<Item=Result<...>> (materialisiert nichts). |
db.flush() |
Erzwingt das Flushen der MemTable als SSTable. |
db.close() |
Sauber schließen: MemTable flushen, WAL + Manifest synchen. |
db.table_count() |
Anzahl bekannter SSTables. |
db.level_tables(level) |
Anzahl Tabellen in einem Level. |
db.level_count() |
Anzahl Level. |
EntityStore::open(dir) |
v0.2: öffnet Entity-Store (legt darunter eine KV-Engine an). |
store.collection(name)? |
v0.2: Handle auf eine Collection (stabile ID). |
col.put(entity_id, &entity) |
v0.2: ersetzt eine Entität (entfernt veraltete Felder). |
col.get(entity_id) |
v0.2: liefert Option<Entity>. |
col.delete(entity_id) |
v0.2: löscht eine Entität. |
col.create_index(field) |
v0.3: legt einen geordneten Index auf einem Feld an (auch mit Bestandsdaten). |
col.drop_index(field) |
v0.3: löscht Index-Definition + -Daten. |
col.find(field, FindOp) |
v0.3: Index-Abfrage, liefert verifizierte Vec<EntityId>. |
store.transaction() |
v0.4: eröffnet eine Transaktion (exklusiv, solange sie lebt). |
store.transaction_with(f) |
v0.4: führt f in einer Transaktion aus (committet bei Ok, bricht bei Err). |
tx.update(collection, id, &entity) |
v0.4: Entität innerhalb der Transaktion ersetzt/angelegt. |
tx.delete(collection, id) |
v0.4: Entität innerhalb der Transaktion löschen. |
tx.get(collection, id) |
v0.4: liest committete Daten + eigene uncommittete Writes. |
tx.scan_collection(collection) |
v0.4: scannt committete Daten + eigene Writes. |
tx.find(collection, field, FindOp) |
v0.4: Index-Abfrage inkl. eigener uncommitteter Writes. |
tx.commit() |
v0.4: committet atomar (WAL Begin→TxPut/TxDelete→Commit, fsync, dann MemTable). |
tx.abort() |
v0.4: verwirft alle uncommitteten Writes (schreibt nichts Persistentes). |
store.query(collection)? |
v0.5: erzeugt einen QueryBuilder (filter/sort/limit). |
builder.filter(pred) / .sort(field, dir) / .limit(n) |
v0.5: kettet Bedingungen an (jeweils Self). |
store.execute_query(builder)? |
v0.5: führt den Plan aus, liefert Vec<(String, Entity)>. |
store.explain_query(&builder)? |
v0.5: zeigt den geplanten Physical-Plan als Baum (String). |
builder.project(&[&str]) |
v0.5: schränkt die gelieferten Felder ein (Projektion). |
store.execute_aggregate(builder)? |
v0.5: führt Aggregate aus (Count/Sum/Avg/Min/Max), liefert Option<Value>. |
store.scan_collection(name)? |
v0.2: liefert alle Entities einer Collection (read-only). |
store.get_entity(cid, &[u8])? |
Low-Level: Entity direkt per collection_id + Byte-ID. |
col.create_composite_index(&[field])? |
v1.3: geordneter Composite-Index über mehrere Felder (auch mit Bestandsdaten, BUILDING→READY). |
col.find_composite(&[field], &[(usize, Bound, Bound)])? |
v1.3: Bereichs-/Punkt-Abfrage über Composite-Index, liefert verifizierte Vec<EntityId>. |
store.cas_update(coll, id, &Expected, &[Patch])? |
v1.2: Compare-and-Swap mit Expected (Field/Any/Absent/Entity) und Patch (Set/Remove/Increment). |
store.backup(target_dir)? |
v1.1: konsistenter Verzeichnis-Snapshot (lokales Backup). |
EntityStore::restore(backup_dir, target_dir)? |
v1.1: materialisiert einen Backup-Snapshot in ein neues Verzeichnis. |
Hinweis zu
close(): Das ist der primäre Durability-Mechanismus für einen sauberen Shutdown.drop(db)ist nur ein Best-Effort-Fallback (flusht beim Verwerfen, ignoriert Fehler) — es ist keine Durability-Garantie. Rufeclose()bewusst auf.
use my_lsm_db::{Database, Options};
let opts = Options {
memtable_limit: 4 * 1024 * 1024, // Byte, ab wann geflusht wird
l0_compact_threshold: 4, // ab wie vielen L0-Tabellen kompaktiert wird
segment_max_records: 30_000, // deterministische L1-Segment-Split-Regel
};my-lsm-db/
├── Cargo.toml
├── src/
│ ├── lib.rs → Database, Options, öffentliche API, Flush/Compaction
│ ├── error.rs → Fehlertypen (Io, Corrupt, NotFound, InvalidFormat)
│ ├── wal.rs → append-only Log, CRC, replay, clear
│ ├── memtable.rs → byte-sortierte In-Memory-Struktur (BTreeMap)
│ ├── sstable.rs → TableBuilder/TableReader + BloomFilter + sparse Index
│ ├── manifest.rs → persistentes SSTable-Set je Level, atomares Speichern
│ ├── compaction.rs → neue SSTable aus sortiertem Stream schreiben
│ ├── iterator.rs → Merge-Iterator über mehrere sortierte Quellen
│ ├── codec.rs → v0.2: getypter Value-Codec (Null/Bool/Int/Float/String/Bytes)
│ ├── keycodec.rs → v0.2: binäres Entity-Key-Encoding + Bereichs-Grenzen
│ ├── schema.rs → v0.2/v0.3: persistente Collection-/Field-/Index-Registry
│ ├── ordering.rs → v0.3: order-preserving Encoding für Index-Werte
│ ├── index.rs → v0.3: Secondary Indexes (create/drop/find/rebuild)
│ ├── query/ → v0.5: Query-Planner/Executor (ast, expression, logical, physical, planner, executor, explain)
│ └── entity.rs → v0.2-v0.5: Entity + EntityStore + Transaction + Query (put/get/delete, Reconstruction, Index-Maintenance)
└── tests/
├── engine.rs → Integrationstests (Flush, Recovery, Compaction, ...)
├── entity.rs → v0.2: Smoke-Tests (put/get, Persistenz über Reopen)
├── index.rs → v0.3: Oracle-Test (find vs Full-Scan)
├── transaction.rs → v0.4: Random-Modell-Oracle (commit/abort/crash/restart/index/entity)
└── query.rs → v0.5: Oracle-Test (query vs Full-Scan)
Als Dependency in Cargo.toml:
[dependencies]
my-lsm-db = { path = "../my-lsm-db" }use my_lsm_db::Database;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut db = Database::open("./data")?;
db.put(b"user:1:name", b"Tobias")?;
db.put(b"user:1:age", b"29")?;
db.put(b"user:2:name", b"Eva")?;
assert_eq!(db.get(b"user:1:name")?, Some(b"Tobias".to_vec()));
for (key, value) in db.scan(Some(b"user:"), Some(b"user;"))? {
println!("{:?} = {:?}", key, value);
}
db.delete(b"user:1:age")?;
assert_eq!(db.get(b"user:1:age")?, None);
Ok(())
}cargo test- Unit-Tests (WAL-Roundtrip/Recovery + Transaktions-Replay, MemTable, SSTable-Build/Read, Bloom, Manifest, Merge-Iterator, Compaction, Codec, Schema, Entity + Transactions, Index-Maintenance, Query-Planner/Executor, CAS, Composite-Index)
- Integrationstests (
tests/engine.rs): put/get/delete, Range-Scan, Flush + Compaction, WAL-Recovery, Recovery nach Flush+Compaction, Overwrite-Neueste-gewinnt, Tombstone-Verhalten. - Crash-Tests (
tests/crash.rs): Clean-Close-Persistenz + Crash-Recovery ohne Korruption. - Index-Oracle-Test (
tests/index.rs): 8000 zufällige Mutationen,findvs. Full-Scan identisch. - Transaktions-Oracle-Test (
tests/transaction.rs): Random-Modell-Tester mischt commit/abort/crash/restart/index/entity und prüft get/scan/find gegen ein In-Memory-Modell. - Query-Oracle-Test (
tests/query.rs): zufällige Prädikate (AND/OR/NOT + 6 Vergleiche + fehlende Felder) gegen einen Full-Scan geprüft — Ausführung und Oracle teilen sich dieselbeeval-Semantik. - Composite-Index-Test (
tests/composite_index.rs): Oracle-Test prüftfind_compositevs. Full-Scan (inkl. Feldänderungen, Crash/Rebuild, Reopen). - Phase-K-Usage (
tests/phase_k_usage.rs): Real-World-Harness (task/project-DB) — CRUD/CAS/Composite/Query/Aggregation/Transaktion/Backup-Restore, semantisch konsistent. Bleibt untracked (Validierungs-Harness, kein Produktions-Test).
Vollständige Suite grün:
cargo testläuft über alle oben genannten Suites hinweg fehlerfrei (Unit- + Integrations-Tests). Die Oracle-Ausführung dauert im Debug-Modus einige Minuten (Voller Full-Scan-Vergleich).
- Einzelprozess, ein Thread — kein MVCC, keine Sperren, keine Nebenläufigkeit. Seit v0.4 existiert genau eine Transaktion pro Store (
&mut-Borrow); keine gleichzeitigen Schreiber. - Kein MVCC / keine Snapshot-Isolation — eine Transaktion sieht committete Daten plus ihre eigenen Writes, aber keine anderen Isolationsstufen.
scanlädt alle Datensätze in den Speicher (kein Streaming-Iterator über die Platte).- Compaction führt nur einen Merge-Schritt pro Flush aus (kein mehrstufiges Level-System, keine selektive Compaction).
- Bloom-Filter sind fest auf
1024Bit gesetzt, nicht an die Datenmenge angepasst. - Der
TableCachehält Reader im RAM; bei sehr vielen Tabellen wächst er entsprechend. limitohnesortist undefiniert (v0.5): Nur mitsortist die Reihenfolge der Ergebniszeilen und damitlimitdeterministisch.- Bulk-Load mit vielen Indexen ist bewusst nicht optimiert (Phase-K-Befund, C-Kandidat): die Indexpflege läuft synchron pro Entity. Große Bulk-Loads können entsprechend langsam werden. Siehe
OPERATIONS.md. - Betriebsgrenzen (Single-Handle, Durability-Grenzen, Concurrency) sind in
OPERATIONS.mdzusammengefasst.
Ergänzt nach der initialen v0.1:
- Clean-Shutdown:
close()(primär) +Drop(Best-Effort-Fallback). - Benchmark-Binary:
cargo run --release --example bench [n]misst put / random get / sequential get / scan / flush / reopen+recovery und berichtetwrites/s,reads/s,MB/s, DB-/WAL-/SSTable-Größe. - Crash-Test-Harness:
src/bin/crash_tester.rs(Modiseed/write/verify) +tests/crash.rs— schreibt Keys, killt den Prozess perabortan einem zufälligen Punkt und verifiziert danach, dass keine Korruption entstanden ist (20 Runs, O(N)-Verify viascan). get-Optimierung: Behebt ein echtes O(N²)-Problem. Ursprünglich baute jedesgeteine volle Snapshot (liest alle SSTables in den RAM) → katastrophal langsam. Jetzt:- gezielter Punkt-Lookup (neueste Quelle zuerst, Bloom + sparse Index), O(Anzahl Tabellen)
TableReader::lookupunterscheidet sauber "vorhanden (auch Tombstone)" von "fehlt"- TableCache (hält geöffnete Reader, invalidiert bei Flush/Compaction)
- Index-Cache (sparse Index einmalig beim Öffnen geparst)
Messwerte (Release, 100k Keys, eine SSTable):
| Metrik | vor Härtung | nach Härtung |
|---|---|---|
| put | 1,5M/s | 1,52M/s |
| random get | ~230 reads/s | 132k reads/s |
| sequential get | ~250 reads/s | 211k reads/s |
| scan | 33 MB/s | 43 MB/s |
| reopen+recover | — | 0,16s |
Der Entity-Layer baut getypte Entitäten auf der unveränderten v0.1-KV-Maschine auf. Die KV-Engine bleibt "dumm" — sie kennt diese Schicht nicht; die Abhängigkeit geht nur in eine Richtung.
Scope-Grenze (hart gehalten): Entity-Layer + Typed Codec + Key-Encoding. Keine Sekundärindizes, Query-Sprache, Transactions, MVCC, Netzwerk, Replication.
use my_lsm_db::codec::Value;
use my_lsm_db::entity::{Entity, EntityStore};
let mut store = EntityStore::open("data")?;
let mut user = Entity::new();
user.insert("name", Value::String("Tobias".into()));
user.insert("age", Value::Int(31));
user.insert("active", Value::Bool(true));
store.collection("users")?.put("123", &user)?;
let got = store.collection("users")?.get("123")?.expect("exists");
assert_eq!(got["age"], Value::Int(31));Vier neue Bausteine:
- Typed Value Codec (
codec.rs) — eindeutiges, versionierbares Binärformat[type tag][payload]. Typen:Null,Bool,Int64,Float64,String,Bytes. Später erweiterbar (Timestamp,UUID,Array,Object). - Key Codec (
keycodec.rs) — kein String-Kleben, sondern length-/type-sicheres Binär-Encoding[E][collection_id u32][entity_id_len][entity_id][field_id u32]. Keys bleiben eindeutig auch bei Sonderzeichen in IDs. Liefert außerdem die Bereichs-Grenzen für Entity-Scans. - Persistente Schema-Registry (
schema.rs) — weistcollection_id/field_idstabil und dauerhaft zu (nichtHash(name), nicht neu pro Start), sonst würde die Bedeutung eines Keys zwischen Starts wechseln. Atomar gespeichert (SCHEMA), nur geschrieben wenn sie sich ändert. - Entity-Reconstruction (
entity.rs) —EntityStorezerlegt einEntityin Feld-Keys und rekonstruiert es beim Lesen.putersetzt die Entität exakt (entfernt auch veraltete Felder);getliefertNone, wenn keine lebenden Felder mehr existieren.
Intern landet alles weiterhin nur als KV-Zeilen:
E|1|123|0 → [4]["Tobias"]
E|1|123|1 → [2][31]
E|1|123|2 → [1][1]
Durability-Reihenfolge: Die Schema-Registry wird (fsync) vor den Entitätsdaten persistiert, damit eine Feld-ID nach einem Crash nie eine andere Bedeutung hat.
| Komponente | Test-Ergebnis |
|---|---|
| Codec (encode/decode, alle Typen, Unicode, leer, negativ, groß) | ok |
| Key-Codec (roundtrip, Sonderzeichen, Bereichs-Grenzen, Sortierung) | ok |
| Schema (stabile IDs, save/load roundtrip, Reopen) | ok |
| Entity (put/get/delete, Stale-Field-Removal, Reopen, Unicode) | ok |
Smoke-Tests (tests/entity.rs) |
ok |
Getypte Secondary Indexes über der KV-Engine. Ein einzelner, geordneter Value-Index (order-preserving) deckt =, <, <=, >, >=, between ab — keine separaten Index-Implementierungen pro Operator.
use my_lsm_db::codec::Value;
use my_lsm_db::index::FindOp;
store.collection("users")?.create_index("age")?;
let eq = store.collection("users")?.find("age", FindOp::Eq(Value::Int(31)))?; // Vec<EntityId>
let range = store.collection("users")?
.find("age", FindOp::Between(Value::Int(18), Value::Int(65)))?;Index-Key-Format:
I | collection_id | field_id | encoded_value | entity_id
Der encoded_value ist ordnungserhaltend (a < b ⇔ encode(a) < encode(b)) und selbst-delimitierend:
| Typ | Encoding |
|---|---|
Int64 |
(v as u64) ^ i64::MIN, Big-Endian |
Float64 |
monotone Bits; totale Ordnung -∞ < … < -0 == +0 < … < +∞ < NaN |
Bool |
1 Byte |
String/Bytes |
null-freies Escaping (0x00→0x00 0x01) + Terminator 0x00 0x00 |
Architektur-Invarianten (in index.rs dokumentiert):
- Die Entity ist immer Source of Truth. Ein Index liefert nur Kandidaten, nie den Entity-Zustand.
- Ein Index darf False Positives enthalten, aber NIEMALS False Negatives.
find()verifiziert deshalb jede Kandidaten-Entity gegen ihren echten Wert. - Write-Reihenfolge:
PUT neuer Index-Eintrag → PUT Entity → DELETE alter Index-Eintrag. Dadurch ist der Index während einer Änderung temporär ein Superset (nie ein Subset) der korrekten Einträge — die False-Negative-Invariante gilt ohne Transactions.
Index-Verwaltung: create_index persistiert die Definition als BUILDING (fsync), baut den Index aus den vorhandenen Entities auf, dann READY (fsync). Ein Crash während des Builds hinterlässt BUILDING → beim nächsten open wird der Index vollständig neu gebaut (idempotent).
Proof: Der Oracle-Test (tests/index.rs) vergleicht nach 8000 zufälligen Mutationen (Updates, Deletes, Re-Inserts) plus Flush, Compaction und Restart jede find()-Abfrage gegen einen Full-Scan der Entity-Daten — beide sind identisch.
| Komponente | Test-Ergebnis |
|---|---|
ordering (order-preserving Codec: Int/Float/String/Bytes/Bool/Null) |
ok |
ordering Property-Tests (Byte-Ordnung == Wert-Ordnung, Sortier-Stabilität) |
ok |
| Schema (Index-Definitionen, BUILDING/READY, save/load) | ok |
| Index-Key-Codec (roundtrip, Sonderzeichen, Bereichs-Grenzen) | ok |
find (Eq/Gt/Gte/Lt/Lte/Between) + Maintenance (Update/Delete) |
ok |
Oracle-Test (tests/index.rs, 8000 Mutationen vs Full-Scan) |
ok |
Eine Einzel-Transaktion über Entity + Indexe: Alle Mutationen eines commit() werden atomar sichtbar — über die Entity und die Secondary Indexe. Der WAL ist die Autorität für den Commit-Point.
store.transaction_with(|tx| {
tx.update("users", "u1", &user)?; // nur gepuffert
tx.update("users", "u2", &other)?;
Ok(()) // → commit()
})?;
// oder manuell:
let mut tx = store.transaction()?;
tx.update("users", "u1", &user)?;
tx.get("users", "u1")?; // sieht den eigenen (uncommitteten) Write
tx.commit()?; // atomar, durableKernprinzipien:
- Read-your-own-writes:
get/scan_collection/findüberlagern die committete DB mit einem Pending-Overlay (TxMutator). Lookup-Reihenfolge: Pending zuerst, dann committed; beiscangewinnt Pending (inkl. Tombstones). - Nichts persistiert bis
commit():transaction()erzeugt nur ein Objekt;update/deleteschreiben in den Pending-Puffer, nicht in den WAL. Einabort()/dropohne Commit hinterlässt keine Spur. - Commit-Ablauf (WAL ist der Commit-Point):
Nach dem
calculate pending → BEGIN → TxPut/TxDelete… → COMMIT → fsync → MemTablefsyncist der Block dauerhaft; das anschließende MemTable-Apply ist infallibel. Ein Crash vor demfsyncverwirft den ganzen Block, einer danach überlebt ihn. Mutator-Abstraktion:core_put_entity/core_delete_entity/findlaufen überDirectMutator(committed) oderTxMutator(Transaktion) — dieselbe Entity-/Index-Semantik in beiden Pfaden, kein Auseinanderlaufen.- TX-ID-Eindeutigkeit:
next_tx_idstartet nachmax_seen_tx_id + 1(aus allen WAL-Records, auch uncommitteten), sodass nach einem Crash keine ID wiederverwendet wird.
| Komponente | Test-Ergebnis |
|---|---|
| WAL-Typen + tx-aware Replay (committed overlebt, aborted/uncommitted verworfen) | ok |
| Atomarer Commit (Entity + Index konsistent) | ok |
Abort (keine Spur) / transaction_with Fehler-Abbruch |
ok |
| Read-your-own-writes (get/scan/find), Write-Sequenz-Matrix | ok |
| Crash-before-commit (verworfen) / Crash-after-commit (überlebt) | ok |
Random-Modell-Oracle (tests/transaction.rs, commit/abort/crash/restart) |
ok |
Eine kleine deklarative Query-Sprache über dem Entity-Layer. Die Query wird nicht zeilenweise "einfach so" ausgeführt, sondern vom Planner in einen physischen Plan übersetzt — er entscheidet, ob über einen Secondary Index gesucht oder voll gescannt wird, und was als Rest-Filter übrig bleibt.
use my_lsm_db::codec::Value;
use my_lsm_db::entity::EntityStore;
use my_lsm_db::query::{SortDir, eq, ge};
let mut store = EntityStore::open("data")?;
let b = store
.query("users")?
.filter(ge("age", Value::Int(30)))
.filter(eq("country", Value::String("DE".into())))
.sort("age", SortDir::Asc)
.limit(50);
let rows: Vec<(String, Entity)> = store.execute_query(b)?; // (entity_id, entity)
// oder: den physischen Plan als Baum ansehen:
println!("{}", store.explain_query(&b)?);explain_query zeigt, wie der Planner sucht (echte Ausgabe):
Limit { n: 50 }
Sort { field: age, dir: Asc }
Filter { predicate: country = "DE" }
Fetch { collection: users }
IndexScan { collection: users, field: age, range: [30,]..∅ }
Hier wählt der Planner den Index auf age für age >= 30; country = "DE" wird als Residual-Filter auf die Index-Kandidaten angewendet. Die Query-Sprache ist nur die Oberfläche — der eigentliche Wert steckt darunter im Planner.
Wie der Planner arbeitet (Kurzfassung):
- DNF: Das Prädikat wird in eine Disjunktive Normalform gebracht (
AND-Klauseln, die mitORverbunden sind). Negation bleibt als Literal erhalten — sie wird nie in inverse Bereichs-Operatoren umgeformt (semantischer Unterschied bei fehlenden Feldern). - Regelbasierte Indexwahl: Für jede
AND-Klausel wird ein indexierbares Feld gewählt (Eqschlägt Bereichs-Vergleiche, sonst lexikografisch kleinstes Feld, deterministisch). Die Index-Bedingungen werden zu einem(lower, upper)-Bound-Paar verdichtet (auch gemischte Exklusivität wie>30 AND <40). - Residual-Filter: Alle nicht index-abgedeckten Bedingungen (und die eines
ORimmer das volle Prädikat) bleiben alsFilterstehen. Ein Index liefert nur Kandidaten; der Filter re-prüft exakt. - OR → Union:
OR-Klauseln werden perUnionIds(dedupliziert) zusammengeführt.
Bewusste Einschränkungen (v0.5-Scope):
- Read-only:
query/execute_query/explain_queryplanen und lesen nur — sie mutieren nie. Transaktions-Queries (tx.query),join, Aggregationen, Projektion und SQL sind nicht Teil von v0.5. - Ein Index pro
AND-Klausel: keine index-order-Sortierung, kein Cost-Based Optimizer, keine Kardinalitäts-Schätzung — der Planner ist bewusst einfach und regelbasiert. Neist kein Index-Zugriff (es bleibt ein Residual-Filter);Newird alsNot(Eq)ausgewertet — konsistent auch für fehlende Felder.limitohnesort= undefinierte Reihenfolge (dokumentiert; nur mitsortistlimitdeterministisch).- Clippy-Warnings sind hier bewusst nicht Teil des v0.6-Scope; sie stammen aus älteren Bereichen und werden separat angegangen.
| Komponente | Test-Ergebnis |
|---|---|
AST (Predicate: And/Or/Not/Field, Cmp, Builder eq/ne/lt/le/gt/ge) |
ok |
Eval (Missing-Field-Semantik, Ne ≡ Not(Eq), Not kompositional) |
ok |
| Planner (DNF, Bound-Merging, regelbasierte Indexwahl, OR→Union) | ok |
| Executor (IndexScan/FullScan/Fetch/Filter/Sort/Limit, deterministisch) | ok |
| Explain (Baum-Darstellung) | ok |
Oracle-Test (tests/query.rs, 200 Random-Queries vs Full-Scan) |
ok |
Technische Härtung des Lesepfads: Vorher materialisierte jeder Read (Entity-Lookup, Collection-Scan, Query ohne Sort, sogar das veraltete Feld-Lesen beim Update) die gesamte Datenmenge der Engine (read_snapshot → merge_level → TableReader::iter), also O(ganze DB). Seit v0.5.1 ist das Lesen lazy und O(gesuchter Bereich):
Database::scan_stream(start, end)liefert einenScanIter(stattVec). Es hält je Quelle einen Cursor: eine beschränkte MemTable-Kopie + pro SSTable einenTableIter, der sich per Sparse-Index exakt auf den Block mitstartpositioniert und nur die Records des Ranges liest.- Snapshot-Konsistenz ohne Lock:
ScanIterbesitzt exklusiv&mut Databasefür seine Lebensdauer (Borrowing). Damit sindput/delete/flush/compactwährend der Iteration unmöglich und der beim Erzeugen festgelegte SSTable-Satz bleibt stabil. Bewusst kein Public-Snapshot-Struct (Variante B/MVCC ist später als Drop-in denkbar). MergeIter(binärer Heap, O(#Quellen)-Speicher, nie O(#Records)): neueste Quelle gewinnt, ältere Quellen desselben Keys werden vollständig überschattet, Tombstones sind reguläre Werte und verdecken ältere Werte. Derselbe Mechanismus dient auch der Compaction (merge_vecs).Mutator::scan(Breaking Change) gibt nun einen lazyScanStreamzurück stattVec;TxMutatorbaut daraus einen lazy 2-Wege-Merge (committed ∘ pending, pending gewinnt). Die Entity-Kernfunktionen sammeln ihre schmalen Entity-Ranges weiterhin ein — Logik unverändert.- Executor-Pull-Model:
FullScanstreamt Entities ausscan_stream,Fetchpoint-fetcht je Kandidaten-ID,Limit=take(n)(hört auf zu ziehen), nurSortblockiert.UnionIdsmerge eager (ein&mut db) mit Dedup per ID.
| Komponente | Test-Ergebnis |
|---|---|
TableIter (Seek exakt >= start, Blockgrenzen, end exklusiv) |
ok |
MergeIter (newest-wins, Tombstone-Shadowing, leere Quellen) |
ok |
| Lazy-vs-Eager Equivalence (MemTable + mehrere SSTables, Überschattungen, Range-Grenzen, leere/nicht vorhandene Ranges) | ok |
Mutator-Migration (Direct + Tx, DirectScan) |
ok |
| Bestehende Index-/Transaction-/Query-Oracles | ok (unverändert grün) |
Konsolidierung der öffentlichen Verträge — keine neuen Features, keine Vereinfachung der v0.5.1-Lazy-Read-Oracles:
- Entity-ID = strikt UTF-8: Die ID bleibt
&str/String. Nicht-UTF-8-Bytes werden beim Schreiben (put_entity/delete_entity) alsError::InvalidArgumentabgelehnt, nie persistiert. Im Speicher gefundene, nicht-UTF-8-IDs (Korruption) liefern über den neuenkeycodec::decode_entity_id-HelferError::InvalidFormat— keinfrom_utf8_lossy-Ersatz mehr auf dem ID-Decode-Pfad. - Fehler-Taxonomie: Neues
Error::InvalidArgumentfür Nutzungs-/Aufruf-Fehler (inaktive Transaktion, unbekanntes Feld ohne Index, read-only-View).InvalidFormatbleibt ausschließlich Persistenz-/Encoding-Korruption (Codec, SSTable, Manifest, Schema-Parse, Keys). - Read-only-Schema-Invariante: Lese-Operationen (
scan_collection,Transaction::get/scan_collection/find) mutieren das Schema nie mehr — sie nutzenlookup_collection_idstatt des mutierendencollection_id()und persistieren das Schema nicht. Unbekannte Collection ⇒ konsistent leer (Ok(vec![])/Ok(None)), keinSCHEMA-File wird erzeugt.
Regressionstest (tests/hardening.rs) |
Ergebnis |
|---|---|
Nicht-UTF-8-ID bei put_entity/delete_entity ⇒ InvalidArgument |
ok |
Im Speicher korrupte (nicht-UTF-8) ID ⇒ InvalidFormat |
ok |
Lese-Operationen auf unbekannter Collection leer + kein SCHEMA-Write |
ok |
| Bestehendes Schema bleibt nach Reads byte-identisch | ok |
Persistenz-Korruption bleibt InvalidFormat |
ok |
v0.6 erweitert die Query-Schicht, ohne die Storage-Architektur umzubauen:
- Transaktionale Queries: Der Executor läuft über
Mutator, sodassget,scan_collection,findund Query-Ausführung dieselben Pending-Writes sehen. - Cost-Based Indexwahl: Der Planner wählt pro DNF-Klausel deterministisch den günstigsten READY-Index über ein kleines Kostenmodell (
Eq < Between < OneSided), ohne Statistik-Infrastruktur. - Index-Order / Top-K: Wenn das Sortierfeld in jeder DNF-Klausel positiv vorhanden ist und ein READY-Index existiert, wird
ORDER BY indexed_field LIMIT nalsLimit { Filter { IndexOrderScan } }geplant. Sonst bleibt der bestehendeSort-Fallback unverändert blockierend.
Wichtig ist die Invariante: Presence-Garantie schaltet den geordneten Pfad erst frei. Die Entity-Verifikation bleibt trotzdem Pflicht, damit Index-/Entity-Abweichungen nie sichtbar werden.
explain()-Beispiel mit v0.6-Pfad:
Limit { n: 5 }
Filter { predicate: age >= 0 }
IndexOrderScan { collection: users, field: age, range: [0,]..∅, dir: Asc }
v0.7 ist kein Feature-Schritt, sondern die Behebung zweier O(N²)-Bugs im Lazy-Read-Pfad, die jeden Entity-Aufbau (und jeden Write mit vorgeschaltetem Scan) quadratisch machten. Beide Fixes sind als eigenständige Commits abgegrenzt, um ihre Einzeleffekte nachvollziehbar zu halten.
Der Auslöser war ein Benchmark-Befund: Der Setup einer indexierten 10k-Collection brauchte 62–68 s, obwohl die eigentlichen Messungen (get, scan) schnell waren. Eine Isolation (setup-diag) zeigte, dass schon ohne Index 10k Writes ~40 s dauerten — das war kein Index-Problem, sondern der allgemeine Write-/Scan-Pfad.
memtable_source erzeugte bei jedem Scan eine Vektor-Kopie der gesamten MemTable (alle Key/Value-Clones), egal wie klein der angefragte Bereich war. Da core_put_entity vor jedem Put einen schmalen Range-Scan ausführt, wurde jeder Put O(MemTable) und der Gesamtaufbau quadratisch.
Fix: Statt memtable.iter().collect() wird der angefragte Bereich via lazy BTreeMap::range(start, end) materialisiert. Ein Guard liefert für leere Intervalle (start >= end) eine leere Quelle, statt an BTreeMap::range zu gehen (das dort panickt).
Regressionstest (src/iterator.rs) |
Ergebnis |
|---|---|
| Nur der angefragte Range wird materialisiert | ok |
Empty-Range (start >= end) ⇒ leere Quelle, kein Panic |
ok |
| Unbounded ⇒ alle Einträge (unverändert) | ok |
scan_stream öffnete bei jedem Scan für jede SSTable eine neue Datei und reparte Footer/Index/Bloom erneut. Sobald mehrere SSTables existieren (durch MemTable-Flushes), wurde jeder Scan O(Tabellen) und das Setup über viele Puts wieder quadratisch.
Fix: Der vorhandene table_cache (den der get-Pfad schon nutzte) wird wiederverwendet. TableReader teilt Datei-Handle, sparse Index und Bloom per Arc; ein neues fork() erzeugt einen billigen Klon mit eigenem Lesepuffer (kein erneuter Datei-/Index-Read). TableIter::from_reader baut daraus ohne Neu-Öffnen einen Iterator.
Regressionstest (src/lib.rs) |
Ergebnis |
|---|---|
| Nach dem 1. Scan ist der Cache mit allen Tabellen befüllt | ok |
| Folge-Scans lassen die Cache-Größe nicht mehr wachsen | ok |
| Scan-Ergebnis bleibt über wiederholte Scans stabil | ok |
| Workload | Wert | Bemerkung |
|---|---|---|
get |
1.28 M reads/s | skaliert gesund (kein Kollaps) |
scan (full) |
29.5 MB/s | konstant über 10k/50k/100k |
index-eq |
21.4 k lookups/s | kaum Degradation |
index-range |
54.3 k lookups/s | 49010 Hits, validiert |
top-k |
10.4 k rows/s | 10 Rows, korrekt |
| setup (mit Index) | 5.86 s | zuvor 62–68 s bei nur 10k |
| recovery | 0.27 s | unkritisch |
| flush | 0.07 s | unkritisch |
Hinweis: Messungen mit <20 ms Messzeit (z. B. 13 ms bei kleinen Ranges) gelten als Rauschen und werden nicht für Performance-Vergleiche herangezogen.
Der Kern des Storage-Read-Pfads ist nach den Fixes bei 100k gesund: kein Grund, blind Reader-Cache, Bloom oder Compaction umzubauen. Die verbleibende Auffälligkeit (Setup-Kurve 10k 0.11 s → 50k 1.48 s → 100k 5.86 s) ist Gegenstand weiterer Instrumentierung, bevor ein dritter Hebel gewählt wird.
Operationales Werkzeug rund um Schema und Collections: Collection- und Feld-IDs
sind stabil persistent (schema.rs); Admin-Aufrufe erlauben Inspektion und
gezielte Wartung, ohne die Storage-Engine umzubauen. (Details sind im
Commit-Verlauf von 911c4c7 nachvollziehbar.)
Lokaler, konsistenter Verzeichnis-Snapshot:
store.backup("./backup")?; // Snapshot von dir_a
// Weiterarbeit auf dem Original verfälscht den Snapshot nicht.
EntityStore::restore("./backup", "./restored")?; // neues Verzeichnis
let restored = EntityStore::open("./restored")?;backup kopiert den konsistenten Zustand zum Aufrufzeitpunkt; nachträgliche
Writes auf der Quelle beeinflussen den Snapshot nicht. Remote-Backup ist
Host/Infra-Sache, nicht Teil der Library. Siehe OPERATIONS.md.
Compare-and-Swap mit结构性 Patching — atomar über die Entity, ohne die ganze Entity neu schreiben zu müssen:
use my_lsm_db::entity::{Expected, Patch};
store.cas_update(
"tasks", "t-1",
&Expected::Field("status".into(), Value::String("todo".into())),
&[
Patch::Set("status".into(), Value::String("doing".into())),
Patch::Increment("priority".into(), Value::Int(1)),
],
)?;Expected varianten: Field (Wert muss passen), Any (Entity muss existieren),
Absent (darf nicht existieren), Entity (ganze Entity muss matchen). Ein
misslungener Vergleich liefert einen Fehler, die Entity bleibt unverändert —
Contention wird sauber zurückgewiesen.
Geordnete Indexe über mehrere Felder ((project_id, status),
(status, priority), …). Ein Composite-Eintrag ist selbst wieder
order-preserving, sodass Bereichs- und Punkt-Abfragen über Präfixe möglich sind:
store.collection("tasks")?.create_composite_index(&["project_id", "status"])?;
let ids = store.collection("tasks")?.find_composite(
&["project_id", "status"],
&[
(0, Bound::Inclusive(Value::String("p2".into())), Bound::Inclusive(Value::String("p2".into()))),
(1, Bound::Inclusive(Value::String("doing".into())), Bound::Inclusive(Value::String("doing".into()))),
],
)?;Invarianten (aus design-v1.3-composite-index.md):
- Die Entity bleibt Source of Truth;
find_compositeverifiziert jeden Kandidaten gegen die echte Entity (keine False Negatives). find_composite_mre-verifiziert zusätzlich jede Leading-Component gegen die tatsächliche Entity, damit nach einer Feldänderung keine veralteten Composite-Einträge durchsickern.- Index-Aufbau läuft
BUILDING→READY(fsync an beiden Punkten); ein Crash während des Builds hinterlässtBUILDING→ beim nächstenopenvollständig neu aufgebaut (idempotent). Der Planner schließtBUILDING-Indizes von der Nutzung aus.
Bewusste Grenze: ORDER BY über einen Composite-Index ist nicht
implementiert (nur Single-Field IndexOrderScan); Composite-Abfragen liefern
unsortiert. Das ist eine C-Optimierung, kein Correctness-Problem.
| Version | Inhalt |
|---|---|
| v0.1 -fertig- | LSM-Engine: put/get/delete/scan, WAL, MemTable, SSTable, Bloom, Compaction, Recovery |
| v0.1.1 -fertig- | Härtung: Clean-Shutdown, Benchmark, Crash-Test, get-Optimierung (Punkt-Lookup + Caches) |
| v0.2 -fertig- | Entity-Layer: Typed Codec, binäres Key-Encoding, persistente Schema-Registry, Entity-Reconstruction |
| v0.3 -fertig- | Secondary Indexes: order-preserving Codec, geordneter Value-Index, Index-Maintenance, Index-Rebuild, Oracle-Test |
| v0.4 -fertig- | Transactions: atomarer Commit über WAL (BEGIN/COMMIT), Read-your-own-writes, Index-Konsistenz, Random-Modell-Oracle |
| v0.5 -fertig- | Query-Planner/Optimizer: deklarative Queries, DNF, regelbasierte Indexwahl, Residual-Filter, Explain, Full-Scan-Oracle |
| v0.5.1 -fertig- | Lazy-Read-Pfad: scan_stream/ScanIter (Snapshot via exklusivem Borrow), TableIter-Seek, Lazy-Merge, Pull-Model-Executor (Limit = take), kein O(DB)-Materialisieren auf dem Lesepfad |
| v0.5.2 -fertig- | API-/Semantik-Härtung: strikt-UTF-8-Entity-IDs (Write ⇒ InvalidArgument, Korruption ⇒ InvalidFormat), Error::InvalidArgument-Taxonomie, Read-only-Schema-Invariante (Reads mutieren kein Schema) |
| v0.6 -fertig- | Query-Ausführung, Tx-Queries, Cost-Based Indexwahl, Index-Order / Top-K |
| v0.7.1 -fertig- | Lazy-Read-Komplexität: memtable_source materialisiert nur den angefragten Range (BTreeMap::range), Empty-Range-Guard — behebt O(N²) beim Entity-Aufbau |
| v0.7.2 -fertig- | Lazy-Read-Komplexität: scan_stream nutzt den table_cache, TableReader::fork()/Arc-Reader-Sharing — behebt O(N²) bei vielen SSTables |
| v1.0 -fertig- | Admin-Tooling: Schema-/Collection-Inspektion und Wartung |
| v1.1 -fertig- | Backup / Restore: lokaler konsistenter Verzeichnis-Snapshot |
| v1.2 -fertig- | CAS / Partial Updates: Expected + Patch, atomar über die Entity |
| v1.3 -fertig- | Composite Indexes: geordnete Multi-Feld-Indexe, Entity-verifiziert |
Das Gesamtkonzept (LSM-Engine + Entity-Modell + Indexes + Query-Optimizer) ist in konzept-kombination.md beschrieben.
Post-Release-Baseline.
8339114ist der stabile v1.4-Checkpoint und ==origin/main. Phase L (LSMQL Release-Hardening) bestätigte: 28/28 LSMQL-Oracle-Tests grün, 10/10 taskdb-HTTP-Vertragstests grün, Release-Build + Docs grün. LSMQL ist jetzt die stabile read-only Query-Schnittstelle.
Kanonische Dokumentation:
- Architektur/Spezifikation v1.3:
design-v1.3-composite-index.md - Betriebsregeln & Einsatzgrenzen:
OPERATIONS.md
Versionslinie (abgeschlossen):
v0.8 Query/Aggregation/Projection
v0.9 Format-Versionierung
v1.0 Admin-Tooling
v1.1 Backup/Restore
v1.2 CAS/Partial Updates
v1.3 Composite Indexes
v1.4 LSMQL Read-Only Query Interface
Phase K Real-World-Validation (911c4c7)
Phase L LSMQL Release-Hardening (8339114)
Freigegebene Produkteigenschaften: vollständige Query-/Mutation-/Ops-Fläche,
dokumentierter Durability-Vertrag (transaktional durable nach commit(),
Direct-Writes deferred-durable nach flush()/close()/backup()), Crash-Recovery
über WAL-Replay inkl. transaktionalem Replay.
Offene Befunde: keine A/B-Correctness-/Durability-Befunde. Einziger
technischer Befund aus Phase K: Bulk-Load wird durch synchrone Indexpflege pro
Entity dominiert (C-Performance-Backlog, kein Correctness-Problem, keine
zugesagte v1.4-Arbeit). Details in OPERATIONS.md.
Regel: Kein neuer technischer Zweig ohne konkreten neuen Befund (realer Nutzerbedarf oder reproduzierbarer A-/B-Befund). Nächster Zyklus dann nach dem bewährten Verfahren: Befund → Forensik → Regression/Oracle → minimaler Fix → Volltest → Checkpoint → Push.
Siehe OPERATIONS.md für:
- Single-Handle-Regel: ein aktiver
EntityStorepro DB-Verzeichnis. - Durability: Direct Writes deferred-durable;
flush()/close()/backup()als Persistenzgrenzen. - Bulk-Load: synchrone Indexpflege kann bei großen Bulk-Loads langsam werden.
- Backup/Restore: sichere Betriebsweise (lokaler Snapshot).
- Concurrency-Grenze: kein Cross-Process-Locking / Multi-Writer.
- Backlog:
Bulk Index Buildals C-Kandidat (keine v1.4-Verpflichtung).
Die On-Disk-Format-Version ist 1 (FORMAT_VERSION). Die vollständige
Produktregel — inkl. Verhalten bei einem künftigen Format 2 und der
Backup/Restore-Versionprüfung — steht in CHANGELOG.md
(Abschnitt Compatibility Policy). Kurzfassung:
- v1.x lesbar: jeder v1.x-Binary liest Datenbanken von einem beliebigen
v1.x- oder Legacy-v1-Binary (Migration-by-read beim Öffnen ohne
VERSION). - Format 2 (zukünftig): wird nicht lesend unterstützt; die Engine
liefert
UnsupportedFormatVersionund lässt die DB unverändert. Ein Upgrade erfolgt ausschließlich über ein explizites, separates Migrationswerkzeug — niemals durch einen stillen Schreibzugriff der Engine.
cargo run --example intro # open → put/get → query → backup/restoreDie empfohlene Anwendungs-API ist EntityStore; die rohe
KV-Engine Database bleibt für Low-Level-Zugriffe verfügbar.