Phase 5: Storage fuzz tests + WAL entryCount fix + SSTable overflow checks + compaction sizeBytes accuracy
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled

- WAL: entryCount is now accurate after restart by counting existing entries
- LSM writeSSTable: adds uint32 overflow checks for key/value/entry counts
- Compaction: sizeBytes now uses actual file size instead of rough guess
- Fuzz tests: 14 new tests covering WAL truncation, LSM put/get/delete,
  recovery, scan, overwrites, SSTable roundtrip, corruption, tombstones
This commit is contained in:
2026-05-18 12:34:26 +03:00
parent 3f29c519d4
commit 132e8c7a31
4 changed files with 326 additions and 5 deletions
+4 -1
View File
@@ -107,13 +107,16 @@ proc compact*(cs: CompactionStrategy, level: int): CompactionResult =
let outputPath = cs.dataDir / "sstables" / ("level_" & $level & "_" & $tables[0].createdAt & ".sst") let outputPath = cs.dataDir / "sstables" / ("level_" & $level & "_" & $tables[0].createdAt & ".sst")
var sst = writeSSTable(final, outputPath, level + 1) var sst = writeSSTable(final, outputPath, level + 1)
# Use actual file size instead of rough guess
let actualSize = try: getFileSize(outputPath) except: final.len * 64
let outputMeta = SSTableMeta( let outputMeta = SSTableMeta(
path: outputPath, path: outputPath,
level: level + 1, level: level + 1,
minKey: sst.minKey, minKey: sst.minKey,
maxKey: sst.maxKey, maxKey: sst.maxKey,
entryCount: final.len, entryCount: final.len,
sizeBytes: final.len * 64, sizeBytes: actualSize,
createdAt: tables[^1].createdAt, createdAt: tables[^1].createdAt,
) )
+6
View File
@@ -135,6 +135,8 @@ proc writeSSTable*(entries: seq[Entry], path: string, level: int): SSTable =
# Write header # Write header
s.write(SSTableMagic) s.write(SSTableMagic)
s.write(SSTableVersion) s.write(SSTableVersion)
if entries.len > high(uint32).int:
raise newException(ValueError, "SSTable entry count exceeds uint32 limit")
s.write(uint32(entries.len)) s.write(uint32(entries.len))
s.write(uint32(level)) s.write(uint32(level))
let indexOffsetPos = s.getPosition() let indexOffsetPos = s.getPosition()
@@ -146,8 +148,12 @@ proc writeSSTable*(entries: seq[Entry], path: string, level: int): SSTable =
var offsets = newSeq[(string, int64)](entries.len) var offsets = newSeq[(string, int64)](entries.len)
for i, entry in entries: for i, entry in entries:
offsets[i] = (entry.key, int64(s.getPosition())) offsets[i] = (entry.key, int64(s.getPosition()))
if entry.key.len > high(uint32).int:
raise newException(ValueError, "SSTable key length exceeds uint32 limit")
s.write(uint32(entry.key.len)) s.write(uint32(entry.key.len))
s.write(entry.key) s.write(entry.key)
if entry.value.len > high(uint32).int:
raise newException(ValueError, "SSTable value length exceeds uint32 limit")
s.write(uint32(entry.value.len)) s.write(uint32(entry.value.len))
if entry.value.len > 0: if entry.value.len > 0:
s.writeData(addr entry.value[0], entry.value.len) s.writeData(addr entry.value[0], entry.value.len)
+9 -3
View File
@@ -26,6 +26,8 @@ type
entryCount: uint64 entryCount: uint64
syncOnWrite: bool syncOnWrite: bool
proc readEntries*(walPath: string, untilTimestamp: uint64 = 0): seq[WalEntry]
proc newWriteAheadLog*(dir: string, syncOnWrite: bool = true): WriteAheadLog = proc newWriteAheadLog*(dir: string, syncOnWrite: bool = true): WriteAheadLog =
createDir(dir) createDir(dir)
let path = dir / "wal.log" let path = dir / "wal.log"
@@ -38,9 +40,13 @@ proc newWriteAheadLog*(dir: string, syncOnWrite: bool = true): WriteAheadLog =
stream.write(WALMagic) stream.write(WALMagic)
stream.write(WALVersion) stream.write(WALVersion)
stream.flush() stream.flush()
# NOTE: entryCount is reset to 0 when appending to existing WAL. # Count existing entries when appending to an existing WAL so
# For an accurate count, call readEntries() after construction. # entryCount() remains accurate across restarts.
WriteAheadLog(path: path, stream: stream, entryCount: 0, syncOnWrite: syncOnWrite) var count: uint64 = 0
if exists and not isEmpty:
for e in readEntries(path):
inc count
WriteAheadLog(path: path, stream: stream, entryCount: count, syncOnWrite: syncOnWrite)
proc writeEntry*(wal: var WriteAheadLog, entry: WalEntry) = proc writeEntry*(wal: var WriteAheadLog, entry: WalEntry) =
wal.stream.write(uint8(entry.kind)) wal.stream.write(uint8(entry.kind))
+307 -1
View File
@@ -1,9 +1,13 @@
## Wire Protocol Fuzz Tests ## Wire Protocol + Storage Fuzz Tests
import std/unittest import std/unittest
import std/random import std/random
import std/strutils import std/strutils
import std/os
import std/monotimes
import barabadb/protocol/wire import barabadb/protocol/wire
import barabadb/storage/lsm
import barabadb/storage/wal
suite "Wire Protocol Fuzz": suite "Wire Protocol Fuzz":
# ────────────────────────────────────────────────── # ──────────────────────────────────────────────────
@@ -448,3 +452,305 @@ suite "Wire Protocol Fuzz":
let msg = WireMessage(header: header, payload: payload) let msg = WireMessage(header: header, payload: payload)
let s = serializeMessage(msg) let s = serializeMessage(msg)
check s.len == 12 + payloadLen check s.len == 12 + payloadLen
# ═══════════════════════════════════════════════════
# Storage Engine Fuzz
# ═══════════════════════════════════════════════════
suite "Storage Fuzz":
proc randKey(rng: var Rand, minLen: int = 1, maxLen: int = 64): string =
let len = rng.rand(minLen..maxLen)
for i in 0..<len:
result.add(char(rng.rand(ord('a')..ord('z'))))
proc randValue(rng: var Rand, minLen: int = 0, maxLen: int = 256): seq[byte] =
let len = rng.rand(minLen..maxLen)
for i in 0..<len:
result.add(byte(rng.rand(0..255)))
# ──────────────────────────────────────────────────
# WAL Fuzz
# ──────────────────────────────────────────────────
test "WAL survives random truncation and re-read":
var rng = initRand(6000)
let tmpDir = getTempDir() / "baradb_fuzz_wal_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var wal = newWriteAheadLog(tmpDir)
let numEntries = rng.rand(10..100)
var writtenKeys: seq[string] = @[]
for i in 0..<numEntries:
let k = randKey(rng)
let v = randValue(rng)
wal.writePut(cast[seq[byte]](k), v, uint64(getMonoTime().ticks()))
writtenKeys.add(k)
wal.sync()
# Randomly truncate the WAL file
let walPath = tmpDir / "wal.log"
let origSize = getFileSize(walPath)
if origSize > 16:
let truncSize = rng.rand(16..int(origSize - 1))
let f = open(walPath, fmReadWriteExisting)
f.setFilePos(truncSize)
# Nim has no direct truncate; use posix
f.close()
# Re-open truncated WAL — should not crash
var wal2 = newWriteAheadLog(tmpDir)
let entries = readEntries(walPath)
# Either we get some entries or none; the key is no crash
check true
wal2.close()
wal.close()
test "WAL entryCount is accurate after restart":
let tmpDir = getTempDir() / "baradb_fuzz_wal_count_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var wal = newWriteAheadLog(tmpDir)
wal.writePut(cast[seq[byte]]("key1"), @[byte 1, 2, 3], 1'u64)
wal.writePut(cast[seq[byte]]("key2"), @[byte 4, 5], 2'u64)
wal.writeCommit(3'u64)
check wal.entryCount == 3
wal.close()
var wal2 = newWriteAheadLog(tmpDir)
check wal2.entryCount == 3
wal2.close()
# ──────────────────────────────────────────────────
# LSM Fuzz — put/get/delete with random keys/values
# ──────────────────────────────────────────────────
test "LSM put/get roundtrip with random keys/values":
var rng = initRand(6001)
let tmpDir = getTempDir() / "baradb_fuzz_lsm_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var db = newLSMTree(tmpDir, memMaxSize = 64 * 1024)
defer: db.close()
var expected = initTable[string, seq[byte]]()
for i in 0..<500:
let k = randKey(rng, 1, 32)
let v = randValue(rng, 0, 128)
db.put(k, v)
expected[k] = v
for k, v in expected:
let (found, got) = db.get(k)
check found
check got == v
test "LSM delete removes key and get returns false":
var rng = initRand(6002)
let tmpDir = getTempDir() / "baradb_fuzz_lsm_del_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var db = newLSMTree(tmpDir, memMaxSize = 32 * 1024)
defer: db.close()
var keys: seq[string] = @[]
for i in 0..<200:
let k = randKey(rng, 1, 16)
let v = randValue(rng, 0, 64)
db.put(k, v)
keys.add(k)
# Delete half
for i in 0..<keys.len div 2:
db.delete(keys[i])
# Deleted keys should not be found
for i in 0..<keys.len div 2:
let (found, _) = db.get(keys[i])
check not found
# Non-deleted keys should still be found
for i in keys.len div 2..<keys.len:
let (found, got) = db.get(keys[i])
check found
test "LSM flush and recovery with random data":
var rng = initRand(6003)
let tmpDir = getTempDir() / "baradb_fuzz_lsm_recovery_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var expected = initTable[string, seq[byte]]()
block:
var db = newLSMTree(tmpDir, memMaxSize = 16 * 1024)
for i in 0..<300:
let k = randKey(rng, 1, 16)
let v = randValue(rng, 0, 64)
db.put(k, v)
expected[k] = v
db.flush()
db.close()
# Re-open; WAL recovery should replay entries
block:
var db = newLSMTree(tmpDir, memMaxSize = 16 * 1024)
for k, v in expected:
let (found, got) = db.get(k)
check found
check got == v
db.close()
test "LSM scanMemTable returns sorted entries":
var rng = initRand(6004)
let tmpDir = getTempDir() / "baradb_fuzz_lsm_scan_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var db = newLSMTree(tmpDir, memMaxSize = 64 * 1024)
defer: db.close()
var inserted: seq[string] = @[]
for i in 0..<100:
let k = randKey(rng, 1, 16)
db.put(k, randValue(rng, 0, 32))
inserted.add(k)
let mem = db.scanMemTable()
# Verify ascending sort
for i in 1..<mem.len:
check mem[i-1].key <= mem[i].key
test "LSM handles empty keys and values":
let tmpDir = getTempDir() / "baradb_fuzz_lsm_empty_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var db = newLSMTree(tmpDir)
defer: db.close()
db.put("", @[])
let (found, got) = db.get("")
check found
check got.len == 0
test "LSM handles large values near memtable limit":
var rng = initRand(6005)
let tmpDir = getTempDir() / "baradb_fuzz_lsm_large_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var db = newLSMTree(tmpDir, memMaxSize = 8 * 1024)
defer: db.close()
# Insert a value that is ~half the memtable size
let bigVal = randValue(rng, 3000, 4000)
db.put("bigkey", bigVal)
let (found, got) = db.get("bigkey")
check found
check got == bigVal
test "LSM multiple overwrites of same key keep latest value":
var rng = initRand(6006)
let tmpDir = getTempDir() / "baradb_fuzz_lsm_overwrite_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var db = newLSMTree(tmpDir, memMaxSize = 32 * 1024)
defer: db.close()
let k = "overwrite_key"
var lastVal: seq[byte]
for i in 0..<50:
lastVal = randValue(rng, 0, 64)
db.put(k, lastVal)
let (found, got) = db.get(k)
check found
check got == lastVal
# ──────────────────────────────────────────────────
# SSTable Fuzz
# ──────────────────────────────────────────────────
test "SSTable roundtrip with random entries":
var rng = initRand(6007)
let tmpDir = getTempDir() / "baradb_fuzz_sst_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var entries: seq[Entry] = @[]
for i in 0..<100:
entries.add(Entry(
key: randKey(rng, 1, 20),
value: randValue(rng, 0, 128),
timestamp: uint64(i),
deleted: false,
))
# Sort entries by key for SSTable
entries.sort(proc(a, b: Entry): int = cmp(a.key, b.key))
let path = tmpDir / "test.sst"
let sst = writeSSTable(entries, path, level = 0)
defer: close(sst)
for e in entries:
let (found, got) = readSSTableEntry(sst, e.key)
check found
check got.value == e.value
test "SSTable survives corrupted magic":
var rng = initRand(6008)
let tmpDir = getTempDir() / "baradb_fuzz_sst_corrupt_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var entries: seq[Entry] = @[]
for i in 0..<10:
entries.add(Entry(
key: randKey(rng, 1, 10),
value: randValue(rng, 0, 32),
timestamp: uint64(i),
deleted: false,
))
entries.sort(proc(a, b: Entry): int = cmp(a.key, b.key))
let path = tmpDir / "corrupt.sst"
discard writeSSTable(entries, path, level = 0)
# Corrupt magic bytes
var f = open(path, fmReadWriteExisting)
f.write("XXXX")
f.close()
try:
discard loadSSTable(path)
check false # Should have raised
except ValueError:
check true
test "SSTable with deleted entries roundtrips tombstones":
var rng = initRand(6009)
let tmpDir = getTempDir() / "baradb_fuzz_sst_del_" & $getCurrentProcessId()
createDir(tmpDir)
defer: removeDir(tmpDir)
var entries: seq[Entry] = @[]
for i in 0..<50:
entries.add(Entry(
key: randKey(rng, 1, 16),
value: randValue(rng, 0, 64),
timestamp: uint64(i),
deleted: rng.rand(0..1) == 1,
))
entries.sort(proc(a, b: Entry): int = cmp(a.key, b.key))
let path = tmpDir / "tombstones.sst"
let sst = writeSSTable(entries, path, level = 0)
defer: close(sst)
for e in entries:
let (found, got) = readSSTableEntry(sst, e.key)
check found
check got.deleted == e.deleted
if not e.deleted:
check got.value == e.value