const std = @import("std"); const encode = @import("encode.zig"); const memtable = @import("memtable.zig"); const sstable = @import("sstable.zig"); const cache = @import("cache.zig"); const statsmod = @import("stats.zig"); const heap = @import("heap.zig"); const version = @import("version.zig"); const walmod = @import("wal.zig"); const Wal = walmod.Wal; const Allocator = std.mem.Allocator; const Kind = encode.Kind; const Record = encode.Record; const Memtable = memtable.Memtable; const Reader = sstable.Reader; const Builder = sstable.Builder; const BlockCache = cache.BlockCache; const VersionSet = version.VersionSet; const VersionEdit = version.VersionEdit; pub const WriteBatch = @import("batch.zig").WriteBatch; pub const Stats = statsmod.Stats; fn order(a: []const u8, b: []const u8) std.math.Order { return std.mem.order(u8, a, b); } const walName = "wal.log"; pub const Options = struct { flushThreshold: usize = 4 * 1024 * 1024, blockCacheBytes: usize = 8 * 1024 * 1024, l0CompactionTrigger: usize = 4, l1BaseBytes: usize = 8 * 1024 * 1024, levelMultiplier: usize = 10, sstableTargetBytes: usize = 2 * 1024 * 1024, }; pub const Entry = struct { key: []const u8, value: []const u8, }; pub const Db = struct { allocator: Allocator, dir: std.fs.Dir, dirPath: []u8, memtable: Memtable, wal: Wal, sstables: std.ArrayList(*Reader), blockCache: BlockCache, versionSet: VersionSet, versionState: std.AutoHashMap(u64, usize), nextFileId: u64, nextSeq: u64, flushThreshold: usize, l0Trigger: usize, l1BaseBytes: usize, levelMultiplier: usize, sstableTargetBytes: usize, pub fn open(allocator: Allocator, dirPath: []const u8, options: Options) !Db { try std.fs.cwd().makePath(dirPath); var dir = try std.fs.cwd().openDir(dirPath, .{ .iterate = true }); errdefer dir.close(); var sstables = std.ArrayList(*Reader).init(allocator); errdefer { for (sstables.items) |reader| { reader.deinit(); allocator.destroy(reader); } sstables.deinit(); } var ids = std.ArrayList(u64).init(allocator); defer ids.deinit(); var walker = dir.iterate(); while (try walker.next()) |item| { if (item.kind != .file) continue; if (parseSstId(item.name)) |id| try ids.append(id); } std.mem.sort(u64, ids.items, {}, std.sort.desc(u64)); var maxSeq: u64 = 0; var maxId: u64 = 0; for (ids.items) |id| { var nameBuf: [64]u8 = undefined; const name = try sstName(&nameBuf, id); const reader = try allocator.create(Reader); errdefer allocator.destroy(reader); reader.* = try Reader.open(allocator, dir, name, id); errdefer reader.deinit(); try sstables.append(reader); if (reader.maxSeq > maxSeq) maxSeq = reader.maxSeq; if (id > maxId) maxId = id; } var opened = try VersionSet.open(allocator, dir); errdefer opened.set.close(); errdefer opened.state.deinit(); for (sstables.items) |reader| { reader.level = opened.state.get(reader.fileId) orelse 0; } var table = try Memtable.init(allocator); errdefer table.deinit(); var wal = try Wal.open(allocator, dir, walName); errdefer wal.close(); const walMaxSeq = try wal.replay(&table); if (walMaxSeq > maxSeq) maxSeq = walMaxSeq; // The directory scan alone can regress: a leveled compaction that // fully garbage-collects a run of tombstones produces zero output // files (see runMerge), so no fresh fileId is minted and the // deleted inputs' seq/fileId watermark vanishes from disk. The // manifest's persisted counters (stamped on every flush/compact, // see VersionEdit.nextFileId/nextSeq) are the authoritative floor // for exactly that case; take whichever recovery source is higher // so this is purely additive safety, never a regression versus the // scan-only behaviour this project already relied on. const scanNextFileId = maxId + 1; const scanNextSeq = maxSeq + 1; const nextFileId = @max(scanNextFileId, opened.set.counters.nextFileId orelse 0); const nextSeq = @max(scanNextSeq, opened.set.counters.nextSeq orelse 0); const dirPathCopy = try allocator.dupe(u8, dirPath); return .{ .allocator = allocator, .dir = dir, .dirPath = dirPathCopy, .memtable = table, .wal = wal, .sstables = sstables, .blockCache = BlockCache.init(allocator, options.blockCacheBytes), .versionSet = opened.set, .versionState = opened.state, .nextFileId = nextFileId, .nextSeq = nextSeq, .flushThreshold = options.flushThreshold, .l0Trigger = options.l0CompactionTrigger, .l1BaseBytes = options.l1BaseBytes, .levelMultiplier = options.levelMultiplier, .sstableTargetBytes = options.sstableTargetBytes, }; } pub fn close(self: *Db) void { self.wal.close(); for (self.sstables.items) |reader| { reader.deinit(); self.allocator.destroy(reader); } self.sstables.deinit(); self.blockCache.deinit(); self.versionSet.close(); self.versionState.deinit(); self.memtable.deinit(); self.dir.close(); self.allocator.free(self.dirPath); } pub fn put(self: *Db, key: []const u8, value: []const u8) !void { try self.write(.value, key, value); } pub fn delete(self: *Db, key: []const u8) !void { try self.write(.tombstone, key, &.{}); } fn write(self: *Db, kind: Kind, key: []const u8, value: []const u8) !void { const seq = self.nextSeq; try self.wal.append(kind, seq, key, value); try self.wal.sync(); try self.memtable.upsert(kind, seq, key, value); self.nextSeq += 1; if (self.memtable.byteSize >= self.flushThreshold) try self.flush(); } pub fn apply(self: *Db, batch: *WriteBatch) !void { if (batch.ops.items.len == 0) return; var records = try self.allocator.alloc(Record, batch.ops.items.len); defer self.allocator.free(records); for (batch.ops.items, 0..) |op, i| { records[i] = .{ .key = op.key, .value = op.value, .seq = self.nextSeq + @as(u64, @intCast(i)), .kind = op.kind }; } try self.wal.appendBatch(records); try self.wal.sync(); for (records) |record| { try self.memtable.upsert(record.kind, record.seq, record.key, record.value); } self.nextSeq += records.len; if (self.memtable.byteSize >= self.flushThreshold) try self.flush(); } pub fn get(self: *Db, allocator: Allocator, key: []const u8) !?[]u8 { return self.getAsOf(allocator, key, std.math.maxInt(u64)); } pub fn getAsOf(self: *Db, allocator: Allocator, key: []const u8, maxSeq: u64) !?[]u8 { var bestSeq: ?u64 = null; var bestKind: Kind = .value; var owned: ?[]u8 = null; errdefer if (owned) |v| allocator.free(v); if (self.memtable.findAsOf(key, maxSeq)) |rec| { bestSeq = rec.seq; bestKind = rec.kind; owned = try allocator.dupe(u8, rec.value); } for (self.sstables.items) |reader| { const hit = try reader.findCachedAsOf(&self.blockCache, allocator, key, maxSeq) orelse continue; if (bestSeq == null or hit.seq > bestSeq.?) { if (owned) |v| allocator.free(v); bestSeq = hit.seq; bestKind = hit.kind; owned = hit.value; } else { allocator.free(hit.value); } } if (bestSeq == null) return null; if (bestKind == .tombstone) { if (owned) |v| allocator.free(v); return null; } return owned; } pub fn snapshot(self: *Db) !Snapshot { try self.flush(); return .{ .db = self, .seq = if (self.nextSeq == 0) 0 else self.nextSeq - 1 }; } pub fn flush(self: *Db) !void { if (self.memtable.count == 0) return; const fileId = self.nextFileId; var nameBuf: [64]u8 = undefined; const name = try sstName(&nameBuf, fileId); { var builder = try Builder.init(self.allocator, self.dir, name, self.memtable.count); defer builder.deinit(); var cur = self.memtable.cursor(null); while (cur.valid()) : (cur.advance()) try builder.add(cur.record()); try builder.finish(); } const reader = try self.allocator.create(Reader); errdefer self.allocator.destroy(reader); reader.* = try Reader.open(self.allocator, self.dir, name, fileId); errdefer reader.deinit(); reader.level = 0; try self.sstables.insert(0, reader); self.nextFileId += 1; self.memtable.clear(); try self.wal.reset(); _ = try walmod.bumpGeneration(self.dir); var edit = VersionEdit.init(self.allocator); defer edit.deinit(); try edit.addFile(fileId, 0); edit.setNextFileId(self.nextFileId); edit.setNextSeq(self.nextSeq); try self.versionSet.logAndApply(&edit, &self.versionState); std.posix.fsync(self.dir.fd) catch {}; } pub fn compact(self: *Db) !void { if (self.sstables.items.len <= 1) return; const fileId = self.nextFileId; var nameBuf: [64]u8 = undefined; const name = try sstName(&nameBuf, fileId); var expected: usize = 0; for (self.sstables.items) |reader| expected += @intCast(reader.numEntries); const cursors = try self.allocator.alloc(sstable.Cursor, self.sstables.items.len); defer self.allocator.free(cursors); for (self.sstables.items, 0..) |reader, i| cursors[i] = reader.cursor(null); { var builder = try Builder.init(self.allocator, self.dir, name, expected); defer builder.deinit(); var merger = try SstableMerger.init(self.allocator, cursors); defer merger.deinit(); while (try merger.next()) |winner| { if (winner.kind == .value) try builder.add(winner); } try builder.finish(); } const reader = try self.allocator.create(Reader); errdefer self.allocator.destroy(reader); reader.* = try Reader.open(self.allocator, self.dir, name, fileId); errdefer reader.deinit(); var edit = VersionEdit.init(self.allocator); defer edit.deinit(); for (self.sstables.items) |old| { try edit.deleteFile(old.fileId); var oldNameBuf: [64]u8 = undefined; const oldName = sstName(&oldNameBuf, old.fileId) catch unreachable; old.deinit(); self.allocator.destroy(old); self.dir.deleteFile(oldName) catch {}; } self.sstables.clearRetainingCapacity(); reader.level = 0; try self.sstables.append(reader); self.nextFileId += 1; try edit.addFile(fileId, 0); edit.setNextFileId(self.nextFileId); edit.setNextSeq(self.nextSeq); try self.versionSet.logAndApply(&edit, &self.versionState); std.posix.fsync(self.dir.fd) catch {}; } pub fn maybeCompact(self: *Db) !void { while (true) { if (self.countLevel(0) >= self.l0Trigger) { try self.compactL0(); continue; } var acted = false; var level: usize = 1; const bottom = self.maxDataLevel(); while (level <= bottom) : (level += 1) { if (self.levelSize(level) > self.levelLimit(level)) { try self.compactDown(level); acted = true; break; } } if (!acted) break; } } fn compactL0(self: *Db) !void { var inputs = std.ArrayList(*Reader).init(self.allocator); defer inputs.deinit(); var lo: ?[]const u8 = null; var hi: ?[]const u8 = null; for (self.sstables.items) |reader| { if (reader.level != 0) continue; try inputs.append(reader); if (reader.index.len == 0) continue; if (lo == null or order(reader.minKey, lo.?) == .lt) lo = reader.minKey; if (hi == null or order(reader.maxKey, hi.?) == .gt) hi = reader.maxKey; } if (inputs.items.len == 0) return; if (lo) |lowBound| { for (self.sstables.items) |reader| { if (reader.level != 1 or reader.index.len == 0) continue; if (rangesOverlap(reader.minKey, reader.maxKey, lowBound, hi.?)) try inputs.append(reader); } } const dropTombstones = !self.hasDataBelow(1); try self.runMerge(inputs.items, 1, dropTombstones); } fn compactDown(self: *Db, level: usize) !void { var picked: ?*Reader = null; for (self.sstables.items) |reader| { if (reader.level != level or reader.index.len == 0) continue; if (picked == null or order(reader.minKey, picked.?.minKey) == .lt) picked = reader; } if (picked == null) return; var inputs = std.ArrayList(*Reader).init(self.allocator); defer inputs.deinit(); try inputs.append(picked.?); const target = level + 1; for (self.sstables.items) |reader| { if (reader.level != target or reader.index.len == 0) continue; if (rangesOverlap(reader.minKey, reader.maxKey, picked.?.minKey, picked.?.maxKey)) try inputs.append(reader); } const dropTombstones = !self.hasDataBelow(target); try self.runMerge(inputs.items, target, dropTombstones); } fn runMerge(self: *Db, inputs: []*Reader, targetLevel: usize, dropTombstones: bool) !void { const cursors = try self.allocator.alloc(sstable.Cursor, inputs.len); defer self.allocator.free(cursors); for (inputs, 0..) |reader, i| cursors[i] = reader.cursor(null); var outputs = std.ArrayList(*Reader).init(self.allocator); errdefer { for (outputs.items) |produced| { produced.deinit(); self.allocator.destroy(produced); } outputs.deinit(); } var estimate: usize = 0; for (inputs) |reader| estimate += @intCast(reader.numEntries); if (estimate == 0) estimate = 1; var builder: ?Builder = null; var outputId: u64 = 0; var outputName: [64]u8 = undefined; errdefer if (builder) |*b| b.deinit(); var merger = try SstableMerger.init(self.allocator, cursors); defer merger.deinit(); while (try merger.next()) |winner| { if (dropTombstones and winner.kind == .tombstone) continue; if (builder == null) { outputId = self.nextFileId; self.nextFileId += 1; const name = try sstName(&outputName, outputId); builder = try Builder.init(self.allocator, self.dir, name, estimate); } try builder.?.add(winner); if (builder.?.logicalBytes >= self.sstableTargetBytes) { const done = builder.?; builder = null; try self.finishOutput(done, outputId, targetLevel, &outputs); } } if (builder) |done| { builder = null; try self.finishOutput(done, outputId, targetLevel, &outputs); } var edit = VersionEdit.init(self.allocator); defer edit.deinit(); for (inputs) |old| { try edit.deleteFile(old.fileId); self.removeReader(old); var oldNameBuf: [64]u8 = undefined; const oldName = sstName(&oldNameBuf, old.fileId) catch unreachable; old.deinit(); self.allocator.destroy(old); self.dir.deleteFile(oldName) catch {}; } for (outputs.items) |produced| { try edit.addFile(produced.fileId, produced.level); try self.sstables.append(produced); } outputs.deinit(); // Stamped even when this round produced zero output files (every // input was a dropped tombstone): that is exactly the case where no // fresh fileId gets minted and a directory scan on the next restart // could otherwise regress below the seq/fileId already consumed by // the now-deleted inputs (see the comment on VersionEdit.nextFileId // and Db.open's counter merge). edit.setNextFileId(self.nextFileId); edit.setNextSeq(self.nextSeq); try self.versionSet.logAndApply(&edit, &self.versionState); std.posix.fsync(self.dir.fd) catch {}; } fn finishOutput(self: *Db, builderIn: Builder, fileId: u64, targetLevel: usize, outputs: *std.ArrayList(*Reader)) !void { var builder = builderIn; { errdefer builder.deinit(); try builder.finish(); } builder.deinit(); var nameBuf: [64]u8 = undefined; const name = try sstName(&nameBuf, fileId); const reader = try self.allocator.create(Reader); errdefer self.allocator.destroy(reader); reader.* = try Reader.open(self.allocator, self.dir, name, fileId); errdefer reader.deinit(); reader.level = targetLevel; try outputs.append(reader); } fn removeReader(self: *Db, target: *Reader) void { for (self.sstables.items, 0..) |reader, i| { if (reader == target) { _ = self.sstables.orderedRemove(i); return; } } } fn countLevel(self: *Db, level: usize) usize { var n: usize = 0; for (self.sstables.items) |reader| { if (reader.level == level) n += 1; } return n; } fn levelSize(self: *Db, level: usize) usize { var total: usize = 0; for (self.sstables.items) |reader| { if (reader.level == level) total += reader.buffer.len; } return total; } fn levelLimit(self: *Db, level: usize) usize { var limit = self.l1BaseBytes; var i: usize = 1; while (i < level) : (i += 1) limit *= self.levelMultiplier; return limit; } fn maxDataLevel(self: *Db) usize { var maxLevel: usize = 0; for (self.sstables.items) |reader| { if (reader.level > maxLevel) maxLevel = reader.level; } return maxLevel; } fn hasDataBelow(self: *Db, level: usize) bool { for (self.sstables.items) |reader| { if (reader.level > level and reader.numEntries > 0) return true; } return false; } pub fn levelOf(self: *Db, fileId: u64) ?usize { for (self.sstables.items) |reader| { if (reader.fileId == fileId) return reader.level; } return null; } pub fn latestSeq(self: *Db, key: []const u8) ?u64 { var best: ?u64 = null; if (self.memtable.find(key)) |rec| best = rec.seq; for (self.sstables.items) |reader| { const rec = reader.find(key) orelse continue; if (best == null or rec.seq > best.?) best = rec.seq; } return best; } pub fn stats(self: *Db) Stats { var result = Stats{ .memtableEntries = self.memtable.count, .memtableBytes = self.memtable.byteSize, .sstableCount = self.sstables.items.len, .cacheHits = self.blockCache.stats.hits, .cacheMisses = self.blockCache.stats.misses, .nextSeq = self.nextSeq, }; for (self.sstables.items) |reader| { result.liveEntries += reader.numEntries; result.physicalBytes += reader.physicalBytes; if (reader.level < statsmod.maxTrackedLevels) { result.levels[reader.level].tables += 1; result.levels[reader.level].entries += reader.numEntries; result.levels[reader.level].physicalBytes += reader.physicalBytes; } } return result; } pub fn scan(self: *Db, allocator: Allocator, start: ?[]const u8, end: ?[]const u8) !RangeIterator { return self.scanRangeAsOf(allocator, .{ .start = start, .end = end }, std.math.maxInt(u64)); } pub fn scanAsOf(self: *Db, allocator: Allocator, start: ?[]const u8, end: ?[]const u8, maxSeq: u64) !RangeIterator { return self.scanRangeAsOf(allocator, .{ .start = start, .end = end }, maxSeq); } pub fn scanRange(self: *Db, allocator: Allocator, options: ScanOptions) !RangeIterator { return self.scanRangeAsOf(allocator, options, std.math.maxInt(u64)); } pub fn scanRangeAsOf(self: *Db, allocator: Allocator, options: ScanOptions, maxSeq: u64) !RangeIterator { const cursorCount = 1 + self.sstables.items.len; var cursors = try allocator.alloc(SourceCursor, cursorCount); errdefer allocator.free(cursors); cursors[0] = .{ .mem = self.memtable.cursor(options.start) }; for (self.sstables.items, 0..) |reader, i| cursors[i + 1] = .{ .sst = reader.cursor(options.start) }; var mergeHeap = AscKeyHeap.init(allocator, {}); errdefer mergeHeap.deinit(); for (cursors, 0..) |*c, i| { if (c.valid()) try mergeHeap.push(.{ .key = c.record().key, .idx = i }); } var startCopy: ?[]u8 = null; errdefer if (startCopy) |s| allocator.free(s); if (options.start) |bound| startCopy = try allocator.dupe(u8, bound); var endCopy: ?[]u8 = null; if (options.end) |bound| endCopy = try allocator.dupe(u8, bound); return .{ .resultAllocator = allocator, .arena = std.heap.ArenaAllocator.init(allocator), .cursors = cursors, .heap = mergeHeap, .start = startCopy, .end = endCopy, .startInclusive = options.startInclusive, .endInclusive = options.endInclusive, .maxSeq = maxSeq, }; } pub fn scanPrefix(self: *Db, allocator: Allocator, prefix: []const u8) !RangeIterator { return self.scanPrefixAsOf(allocator, prefix, std.math.maxInt(u64)); } pub fn scanPrefixAsOf(self: *Db, allocator: Allocator, prefix: []const u8, maxSeq: u64) !RangeIterator { const upper = try prefixUpperBound(allocator, prefix); defer if (upper) |u| allocator.free(u); return self.scanRangeAsOf(allocator, .{ .start = prefix, .end = upper }, maxSeq); } pub fn scanReverse(self: *Db, allocator: Allocator, options: ScanOptions) !ReverseRangeIterator { return self.scanReverseAsOf(allocator, options, std.math.maxInt(u64)); } pub fn scanReverseAsOf(self: *Db, allocator: Allocator, options: ScanOptions, maxSeq: u64) !ReverseRangeIterator { const cursorCount = 1 + self.sstables.items.len; var cursors = try allocator.alloc(ReverseSource, cursorCount); errdefer allocator.free(cursors); var built: usize = 0; errdefer { var i: usize = 0; while (i < built) : (i += 1) cursors[i].deinit(); } cursors[0] = .{ .mem = self.memtable.cursorRev(options.end, options.endInclusive) }; built = 1; for (self.sstables.items, 0..) |reader, i| { cursors[i + 1] = .{ .sst = try reader.reverseCursor(allocator, options.end, options.endInclusive) }; built += 1; } var mergeHeap = DescKeyHeap.init(allocator, {}); errdefer mergeHeap.deinit(); for (cursors, 0..) |*c, i| { if (c.valid()) try mergeHeap.push(.{ .key = c.record().key, .idx = i }); } var startCopy: ?[]u8 = null; if (options.start) |bound| startCopy = try allocator.dupe(u8, bound); return .{ .resultAllocator = allocator, .arena = std.heap.ArenaAllocator.init(allocator), .cursors = cursors, .heap = mergeHeap, .start = startCopy, .startInclusive = options.startInclusive, .maxSeq = maxSeq, }; } }; pub const ScanOptions = struct { start: ?[]const u8 = null, end: ?[]const u8 = null, startInclusive: bool = true, endInclusive: bool = false, }; fn prefixUpperBound(allocator: Allocator, prefix: []const u8) !?[]u8 { var end = prefix.len; while (end > 0 and prefix[end - 1] == 0xff) end -= 1; if (end == 0) return null; const upper = try allocator.alloc(u8, end); @memcpy(upper, prefix[0..end]); upper[end - 1] += 1; return upper; } pub const Snapshot = struct { db: *Db, seq: u64, pub fn get(self: Snapshot, allocator: Allocator, key: []const u8) !?[]u8 { return self.db.getAsOf(allocator, key, self.seq); } pub fn scan(self: Snapshot, allocator: Allocator, start: ?[]const u8, end: ?[]const u8) !RangeIterator { return self.db.scanAsOf(allocator, start, end, self.seq); } pub fn scanRange(self: Snapshot, allocator: Allocator, options: ScanOptions) !RangeIterator { return self.db.scanRangeAsOf(allocator, options, self.seq); } pub fn scanReverse(self: Snapshot, allocator: Allocator, options: ScanOptions) !ReverseRangeIterator { return self.db.scanReverseAsOf(allocator, options, self.seq); } pub fn scanPrefix(self: Snapshot, allocator: Allocator, prefix: []const u8) !RangeIterator { return self.db.scanPrefixAsOf(allocator, prefix, self.seq); } }; const SourceCursor = union(enum) { mem: memtable.Cursor, sst: sstable.Cursor, fn valid(self: *SourceCursor) bool { return switch (self.*) { .mem => |*c| c.valid(), .sst => |*c| c.valid(), }; } fn record(self: *SourceCursor) Record { return switch (self.*) { .mem => |*c| c.record(), .sst => |*c| c.record(), }; } fn advance(self: *SourceCursor) void { switch (self.*) { .mem => |*c| c.advance(), .sst => |*c| c.advance(), } } }; /// One live position in a k-way merge: the key it currently points at /// (a snapshot valid for the underlying source's whole lifetime, since /// sstable/memtable cursors never overwrite already-decoded key bytes) /// plus the index of the source cursor it belongs to. const KeyedIndex = struct { key: []const u8, idx: usize }; fn keyIndexLess(_: void, a: KeyedIndex, b: KeyedIndex) bool { return order(a.key, b.key) == .lt; } fn keyIndexGreater(_: void, a: KeyedIndex, b: KeyedIndex) bool { return order(a.key, b.key) == .gt; } /// Ascending k-way merge heap: root always holds the smallest live key. const AscKeyHeap = heap.MinHeap(KeyedIndex, void, keyIndexLess); /// Descending k-way merge heap: root always holds the largest live key. const DescKeyHeap = heap.MinHeap(KeyedIndex, void, keyIndexGreater); /// Merges N sorted sstable cursors (compaction inputs) in ascending key /// order using a binary heap: O(log N) per advance instead of the O(N) /// full rescan a naive linear-scan merge needs for every emitted record. /// Ties (same key present in several inputs) resolve to the entry with /// the highest seq, matching last-writer-wins semantics. const SstableMerger = struct { cursors: []sstable.Cursor, heap: AscKeyHeap, fn init(allocator: Allocator, cursors: []sstable.Cursor) !SstableMerger { var h = AscKeyHeap.init(allocator, {}); errdefer h.deinit(); for (cursors, 0..) |*c, i| { if (c.valid()) try h.push(.{ .key = c.record().key, .idx = i }); } return .{ .cursors = cursors, .heap = h }; } fn deinit(self: *SstableMerger) void { self.heap.deinit(); } fn next(self: *SstableMerger) !?Record { const top = self.heap.peek() orelse return null; const minKey = top.key; var winner: ?Record = null; while (self.heap.peek()) |entry| { if (order(entry.key, minKey) != .eq) break; const popped = self.heap.pop().?; var c = &self.cursors[popped.idx]; const rec = c.record(); if (winner == null or rec.seq > winner.?.seq) winner = rec; c.advance(); if (c.valid()) try self.heap.push(.{ .key = c.record().key, .idx = popped.idx }); } return winner.?; } }; pub const RangeIterator = struct { resultAllocator: Allocator, arena: std.heap.ArenaAllocator, cursors: []SourceCursor, heap: AscKeyHeap, start: ?[]u8, end: ?[]u8, startInclusive: bool, endInclusive: bool, maxSeq: u64, pub fn next(self: *RangeIterator) !?Entry { while (true) { const top = self.heap.peek() orelse return null; const minKey = top.key; if (self.end) |bound| { const past = if (self.endInclusive) order(minKey, bound) == .gt else order(minKey, bound) != .lt; if (past) return null; } var winner: ?Record = null; while (self.heap.peek()) |entry| { if (order(entry.key, minKey) != .eq) break; const popped = self.heap.pop().?; var c = &self.cursors[popped.idx]; const rec = c.record(); if (rec.seq <= self.maxSeq and (winner == null or rec.seq > winner.?.seq)) winner = rec; c.advance(); if (c.valid()) try self.heap.push(.{ .key = c.record().key, .idx = popped.idx }); } if (winner == null) continue; if (!self.startInclusive) { if (self.start) |bound| { if (order(winner.?.key, bound) == .eq) continue; } } if (winner.?.kind == .tombstone) continue; const arena = self.arena.allocator(); return Entry{ .key = try arena.dupe(u8, winner.?.key), .value = try arena.dupe(u8, winner.?.value), }; } } pub fn deinit(self: *RangeIterator) void { self.arena.deinit(); self.heap.deinit(); if (self.start) |bound| self.resultAllocator.free(bound); if (self.end) |bound| self.resultAllocator.free(bound); self.resultAllocator.free(self.cursors); } }; const ReverseSource = union(enum) { mem: memtable.ReverseCursor, sst: sstable.ReverseCursor, fn valid(self: *ReverseSource) bool { return switch (self.*) { .mem => |*c| c.valid(), .sst => |*c| c.valid(), }; } fn record(self: *ReverseSource) Record { return switch (self.*) { .mem => |*c| c.record(), .sst => |*c| c.record(), }; } fn advance(self: *ReverseSource) void { switch (self.*) { .mem => |*c| c.advance(), .sst => |*c| c.advance(), } } fn deinit(self: *ReverseSource) void { switch (self.*) { .mem => {}, .sst => |*c| c.deinit(), } } }; pub const ReverseRangeIterator = struct { resultAllocator: Allocator, arena: std.heap.ArenaAllocator, cursors: []ReverseSource, heap: DescKeyHeap, start: ?[]u8, startInclusive: bool, maxSeq: u64, pub fn next(self: *ReverseRangeIterator) !?Entry { while (true) { const top = self.heap.peek() orelse return null; const maxKey = top.key; if (self.start) |bound| { const past = if (self.startInclusive) order(maxKey, bound) == .lt else order(maxKey, bound) != .gt; if (past) return null; } var winner: ?Record = null; while (self.heap.peek()) |entry| { if (order(entry.key, maxKey) != .eq) break; const popped = self.heap.pop().?; var c = &self.cursors[popped.idx]; const rec = c.record(); if (rec.seq <= self.maxSeq and (winner == null or rec.seq > winner.?.seq)) winner = rec; c.advance(); if (c.valid()) try self.heap.push(.{ .key = c.record().key, .idx = popped.idx }); } if (winner == null) continue; if (winner.?.kind == .tombstone) continue; const arena = self.arena.allocator(); return Entry{ .key = try arena.dupe(u8, winner.?.key), .value = try arena.dupe(u8, winner.?.value), }; } } pub fn deinit(self: *ReverseRangeIterator) void { self.arena.deinit(); self.heap.deinit(); for (self.cursors) |*c| c.deinit(); if (self.start) |bound| self.resultAllocator.free(bound); self.resultAllocator.free(self.cursors); } }; fn rangesOverlap(aMin: []const u8, aMax: []const u8, bMin: []const u8, bMax: []const u8) bool { return order(aMin, bMax) != .gt and order(bMin, aMax) != .gt; } fn sstName(buf: []u8, id: u64) ![]const u8 { return std.fmt.bufPrint(buf, "sst-{d:0>10}.cbt", .{id}); } fn parseSstId(name: []const u8) ?u64 { const prefix = "sst-"; const suffix = ".cbt"; if (!std.mem.startsWith(u8, name, prefix)) return null; if (!std.mem.endsWith(u8, name, suffix)) return null; const digits = name[prefix.len .. name.len - suffix.len]; return std.fmt.parseInt(u64, digits, 10) catch null; } fn openTemp(tmp: *std.testing.TmpDir, allocator: Allocator, options: Options) !Db { var pathBuf: [std.fs.max_path_bytes]u8 = undefined; const base = try tmp.dir.realpath(".", &pathBuf); var dbPathBuf: [std.fs.max_path_bytes]u8 = undefined; const dbPath = try std.fmt.bufPrint(&dbPathBuf, "{s}/store", .{base}); return Db.open(allocator, dbPath, options); } test "put get delete" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{}); defer db.close(); try db.put("apple", "red"); try db.put("banana", "yellow"); const apple = (try db.get(allocator, "apple")).?; defer allocator.free(apple); try std.testing.expectEqualStrings("red", apple); try db.put("apple", "green"); const apple2 = (try db.get(allocator, "apple")).?; defer allocator.free(apple2); try std.testing.expectEqualStrings("green", apple2); try db.delete("apple"); try std.testing.expectEqual(@as(?[]u8, null), try db.get(allocator, "apple")); const banana = (try db.get(allocator, "banana")).?; defer allocator.free(banana); try std.testing.expectEqualStrings("yellow", banana); } test "wal recovery after reopen" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var db = try openTemp(&tmp, allocator, .{}); defer db.close(); try db.put("city", "warsaw"); try db.put("river", "vistula"); try db.delete("city"); try std.testing.expectEqual(@as(usize, 0), db.sstables.items.len); } var db = try openTemp(&tmp, allocator, .{}); defer db.close(); try std.testing.expectEqual(@as(usize, 0), db.sstables.items.len); try std.testing.expectEqual(@as(?[]u8, null), try db.get(allocator, "city")); const river = (try db.get(allocator, "river")).?; defer allocator.free(river); try std.testing.expectEqualStrings("vistula", river); } test "flush to sstable and reopen" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 64 }); defer db.close(); var buf: [16]u8 = undefined; var i: usize = 0; while (i < 200) : (i += 1) { const key = try std.fmt.bufPrint(&buf, "k{d:0>5}", .{i}); try db.put(key, "value-payload"); } try std.testing.expect(db.sstables.items.len > 0); } var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 64 }); defer db.close(); var buf: [16]u8 = undefined; var i: usize = 0; while (i < 200) : (i += 1) { const key = try std.fmt.bufPrint(&buf, "k{d:0>5}", .{i}); const got = (try db.get(allocator, key)).?; defer allocator.free(got); try std.testing.expectEqualStrings("value-payload", got); } } test "compaction dedups and drops tombstones" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 1 }); defer db.close(); try db.put("a", "1"); try db.put("b", "2"); try db.put("c", "3"); try db.put("d", "4"); try db.put("a", "11"); try db.delete("c"); try db.flush(); try std.testing.expect(db.sstables.items.len > 1); try db.compact(); try std.testing.expectEqual(@as(usize, 1), db.sstables.items.len); const a = (try db.get(allocator, "a")).?; defer allocator.free(a); try std.testing.expectEqualStrings("11", a); const b = (try db.get(allocator, "b")).?; defer allocator.free(b); try std.testing.expectEqualStrings("2", b); try std.testing.expectEqual(@as(?[]u8, null), try db.get(allocator, "c")); const d = (try db.get(allocator, "d")).?; defer allocator.free(d); try std.testing.expectEqualStrings("4", d); } test "scan merges memtable and sstable with tombstones" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{}); defer db.close(); try db.put("a", "1"); try db.put("b", "2"); try db.put("c", "3"); try db.put("d", "4"); try db.put("e", "5"); try db.flush(); try db.put("c", "cNew"); try db.delete("b"); var iter = try db.scan(allocator, null, null); defer iter.deinit(); const expectedKeys = [_][]const u8{ "a", "c", "d", "e" }; const expectedVals = [_][]const u8{ "1", "cNew", "4", "5" }; var idx: usize = 0; while (try iter.next()) |item| { try std.testing.expect(idx < expectedKeys.len); try std.testing.expectEqualStrings(expectedKeys[idx], item.key); try std.testing.expectEqualStrings(expectedVals[idx], item.value); idx += 1; } try std.testing.expectEqual(expectedKeys.len, idx); } test "scan honors range bounds" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 32 }); defer db.close(); var keyBuf: [8]u8 = undefined; var i: usize = 0; while (i < 26) : (i += 1) { const key = try std.fmt.bufPrint(&keyBuf, "{c}", .{@as(u8, 'a') + @as(u8, @intCast(i))}); try db.put(key, "x"); } var iter = try db.scan(allocator, "d", "h"); defer iter.deinit(); const expected = [_][]const u8{ "d", "e", "f", "g" }; var idx: usize = 0; while (try iter.next()) |item| { try std.testing.expect(idx < expected.len); try std.testing.expectEqualStrings(expected[idx], item.key); idx += 1; } try std.testing.expectEqual(expected.len, idx); } test "snapshot reads pre-overwrite value" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{}); defer db.close(); try db.put("color", "red"); try db.put("shape", "circle"); var snap = try db.snapshot(); try db.put("color", "green"); try db.delete("shape"); try db.put("color", "blue"); const snapColor = (try snap.get(allocator, "color")).?; defer allocator.free(snapColor); try std.testing.expectEqualStrings("red", snapColor); const snapShape = (try snap.get(allocator, "shape")).?; defer allocator.free(snapShape); try std.testing.expectEqualStrings("circle", snapShape); const liveColor = (try db.get(allocator, "color")).?; defer allocator.free(liveColor); try std.testing.expectEqualStrings("blue", liveColor); try std.testing.expectEqual(@as(?[]u8, null), try db.get(allocator, "shape")); var snapIter = try snap.scan(allocator, null, null); defer snapIter.deinit(); var snapCount: usize = 0; while (try snapIter.next()) |entry| { if (std.mem.eql(u8, entry.key, "color")) try std.testing.expectEqualStrings("red", entry.value); snapCount += 1; } try std.testing.expectEqual(@as(usize, 2), snapCount); } test "write batch applies all or nothing" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var db = try openTemp(&tmp, allocator, .{}); defer db.close(); try db.put("existing", "keep"); const seqBefore = db.nextSeq; var batch = WriteBatch.init(allocator); defer batch.deinit(); try batch.put("one", "1"); try batch.put("two", "2"); try batch.put("three", "3"); try batch.delete("existing"); try db.apply(&batch); try std.testing.expectEqual(seqBefore + 4, db.nextSeq); const one = (try db.get(allocator, "one")).?; defer allocator.free(one); try std.testing.expectEqualStrings("1", one); const three = (try db.get(allocator, "three")).?; defer allocator.free(three); try std.testing.expectEqualStrings("3", three); try std.testing.expectEqual(@as(?[]u8, null), try db.get(allocator, "existing")); } var db = try openTemp(&tmp, allocator, .{}); defer db.close(); const two = (try db.get(allocator, "two")).?; defer allocator.free(two); try std.testing.expectEqualStrings("2", two); try std.testing.expectEqual(@as(?[]u8, null), try db.get(allocator, "existing")); } test "block cache serves repeated reads" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{}); defer db.close(); try db.put("alpha", "one"); try db.put("bravo", "two"); try db.flush(); const first = (try db.get(allocator, "alpha")).?; allocator.free(first); try std.testing.expect(db.blockCache.stats.misses >= 1); const hitsBefore = db.blockCache.stats.hits; const second = (try db.get(allocator, "alpha")).?; allocator.free(second); try std.testing.expect(db.blockCache.stats.hits > hitsBefore); } test "leveled compaction keeps levels disjoint and drops bottom tombstones" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 512, .l0CompactionTrigger = 2, .l1BaseBytes = 4 * 1024 * 1024, .sstableTargetBytes = 2048, }); defer db.close(); var keyBuf: [16]u8 = undefined; const payload = "x" ** 120; var i: usize = 0; while (i < 60) : (i += 1) { const key = try std.fmt.bufPrint(&keyBuf, "key:{d:0>4}", .{i}); try db.put(key, payload); } try db.delete("key:0005"); try db.delete("key:0025"); try db.flush(); try db.maybeCompact(); try std.testing.expectEqual(@as(usize, 0), db.countLevel(0)); try std.testing.expect(db.sstables.items.len >= 2); var live: usize = 0; var totalEntries: u64 = 0; for (db.sstables.items) |reader| { try std.testing.expect(reader.level >= 1); totalEntries += reader.numEntries; } var levelOneReaders = std.ArrayList(*Reader).init(allocator); defer levelOneReaders.deinit(); for (db.sstables.items) |reader| { if (reader.level == 1) try levelOneReaders.append(reader); } std.mem.sort(*Reader, levelOneReaders.items, {}, lessByMinKey); var r: usize = 1; while (r < levelOneReaders.items.len) : (r += 1) { try std.testing.expect(order(levelOneReaders.items[r - 1].maxKey, levelOneReaders.items[r].minKey) == .lt); } i = 0; while (i < 60) : (i += 1) { const key = try std.fmt.bufPrint(&keyBuf, "key:{d:0>4}", .{i}); if (i == 5 or i == 25) { try std.testing.expectEqual(@as(?[]u8, null), try db.get(allocator, key)); } else { const got = (try db.get(allocator, key)).?; defer allocator.free(got); try std.testing.expectEqualStrings(payload, got); live += 1; } } try std.testing.expectEqual(@as(usize, 58), live); try std.testing.expectEqual(@as(u64, 58), totalEntries); } test "seq and fileId counters survive a compaction that garbage-collects every key" { // Reproduces a real recovery gap: when a bottom-level compaction merges // inputs that are entirely tombstones, runMerge produces zero output // files (see the `builder == null` guard around minting a fresh // fileId). If every file that ever carried a high seq/fileId gets // deleted in that round and nothing new gets flushed before a restart, // a directory-scan-only recovery would compute too low a floor and // risk reusing already-consumed seq numbers. The manifest's persisted // counters (VersionEdit.nextFileId/nextSeq, stamped unconditionally in // runMerge) must close that gap. const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var beforeNextSeq: u64 = 0; var beforeNextFileId: u64 = 0; { var db = try openTemp(&tmp, allocator, .{ .l0CompactionTrigger = 2, .l1BaseBytes = 4 * 1024 * 1024, }); defer db.close(); var keyBuf: [16]u8 = undefined; var round: usize = 0; while (round < 2) : (round += 1) { const key = try std.fmt.bufPrint(&keyBuf, "gone:{d}", .{round}); try db.put(key, "value"); try db.delete(key); try db.flush(); } try std.testing.expectEqual(@as(usize, 2), db.countLevel(0)); try db.maybeCompact(); // The whole point: every input was a dropped tombstone, so the // compaction round produced no output file at all, and no new // fileId was ever minted for it. try std.testing.expectEqual(@as(usize, 0), db.sstables.items.len); beforeNextSeq = db.nextSeq; beforeNextFileId = db.nextFileId; try std.testing.expect(beforeNextSeq > 1); } // On disk right now: no sstable files, an empty (reset) WAL. A // scan-only recovery would compute nextSeq=1/nextFileId=1 here. var reopened = try openTemp(&tmp, allocator, .{ .l0CompactionTrigger = 2, .l1BaseBytes = 4 * 1024 * 1024, }); defer reopened.close(); try std.testing.expectEqual(beforeNextSeq, reopened.nextSeq); try std.testing.expectEqual(beforeNextFileId, reopened.nextFileId); // Functional proof, not just counter equality: a fresh write after // reopen must not reuse a seq number already spent on the GC'd keys. try reopened.put("fresh", "value"); try std.testing.expect(reopened.nextSeq > beforeNextSeq); } fn lessByMinKey(_: void, a: *Reader, b: *Reader) bool { return order(a.minKey, b.minKey) == .lt; } fn lessString(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } test "reverse scan yields descending order merging sources" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 48 }); defer db.close(); var keyBuf: [8]u8 = undefined; var i: usize = 0; while (i < 26) : (i += 1) { const key = try std.fmt.bufPrint(&keyBuf, "{c}", .{@as(u8, 'a') + @as(u8, @intCast(i))}); try db.put(key, "old"); } try db.put("m", "fresh"); try db.delete("d"); var iter = try db.scanReverse(allocator, .{}); defer iter.deinit(); var prev: [8]u8 = undefined; var prevLen: usize = 0; var count: usize = 0; var sawFreshM = false; var sawD = false; while (try iter.next()) |entry| { if (prevLen > 0) try std.testing.expect(order(entry.key, prev[0..prevLen]) == .lt); @memcpy(prev[0..entry.key.len], entry.key); prevLen = entry.key.len; if (std.mem.eql(u8, entry.key, "m")) { try std.testing.expectEqualStrings("fresh", entry.value); sawFreshM = true; } if (std.mem.eql(u8, entry.key, "d")) sawD = true; count += 1; } try std.testing.expectEqual(@as(usize, 25), count); try std.testing.expect(sawFreshM); try std.testing.expect(!sawD); } test "prefix scan isolates a namespace" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 64 }); defer db.close(); try db.put("user:alice", "1"); try db.put("user:bob", "2"); try db.put("user:carol", "3"); try db.put("post:1", "x"); try db.put("userx", "y"); try db.put("zzz", "z"); var iter = try db.scanPrefix(allocator, "user:"); defer iter.deinit(); const expected = [_][]const u8{ "user:alice", "user:bob", "user:carol" }; var idx: usize = 0; while (try iter.next()) |entry| { try std.testing.expect(idx < expected.len); try std.testing.expectEqualStrings(expected[idx], entry.key); idx += 1; } try std.testing.expectEqual(expected.len, idx); } test "scan merges a wide fan-in of uncompacted sstables with overlapping keys" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 1 }); defer db.close(); const sstableCount = 14; var round: usize = 0; while (round < sstableCount) : (round += 1) { var keyBuf: [16]u8 = undefined; var valBuf: [24]u8 = undefined; var i: usize = 0; while (i < 5) : (i += 1) { const key = try std.fmt.bufPrint(&keyBuf, "shared:{d:0>2}", .{i}); const value = try std.fmt.bufPrint(&valBuf, "round{d}", .{round}); try db.put(key, value); } try db.flush(); } try db.delete("shared:02"); try db.flush(); try std.testing.expect(db.sstables.items.len >= sstableCount + 1); var forward = try db.scan(allocator, null, null); defer forward.deinit(); var seen: usize = 0; while (try forward.next()) |entry| { if (std.mem.eql(u8, entry.key, "shared:02")) { try std.testing.expect(false); } try std.testing.expectEqualStrings("round13", entry.value); seen += 1; } try std.testing.expectEqual(@as(usize, 4), seen); var reverse = try db.scanReverse(allocator, .{}); defer reverse.deinit(); var revSeen: usize = 0; var lastKey: ?[]const u8 = null; while (try reverse.next()) |entry| { if (lastKey) |prev| try std.testing.expect(order(entry.key, prev) == .lt); lastKey = entry.key; try std.testing.expectEqualStrings("round13", entry.value); revSeen += 1; } try std.testing.expectEqual(@as(usize, 4), revSeen); } test "inclusive and exclusive bounds" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var db = try openTemp(&tmp, allocator, .{}); defer db.close(); var keyBuf: [8]u8 = undefined; var i: usize = 0; while (i < 10) : (i += 1) { const key = try std.fmt.bufPrint(&keyBuf, "k{d}", .{i}); try db.put(key, "v"); } { var iter = try db.scanRange(allocator, .{ .start = "k2", .end = "k5", .startInclusive = true, .endInclusive = true }); defer iter.deinit(); const expected = [_][]const u8{ "k2", "k3", "k4", "k5" }; var idx: usize = 0; while (try iter.next()) |entry| { try std.testing.expectEqualStrings(expected[idx], entry.key); idx += 1; } try std.testing.expectEqual(expected.len, idx); } { var iter = try db.scanRange(allocator, .{ .start = "k2", .end = "k5", .startInclusive = false, .endInclusive = false }); defer iter.deinit(); const expected = [_][]const u8{ "k3", "k4" }; var idx: usize = 0; while (try iter.next()) |entry| { try std.testing.expectEqualStrings(expected[idx], entry.key); idx += 1; } try std.testing.expectEqual(expected.len, idx); } } fn setLive(map: *std.StringHashMap([]const u8), arena: Allocator, key: []const u8, value: []const u8) !void { const gop = try map.getOrPut(key); if (!gop.found_existing) gop.key_ptr.* = try arena.dupe(u8, key); gop.value_ptr.* = try arena.dupe(u8, value); } test "scan matches sorted reference across flush restart and compaction" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var refArena = std.heap.ArenaAllocator.init(allocator); defer refArena.deinit(); const arena = refArena.allocator(); var live = std.StringHashMap([]const u8).init(allocator); defer live.deinit(); const keyspace = 300; const ops = 4000; var prng = std.Random.DefaultPrng.init(0xC0BA17); const random = prng.random(); { var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 2048, .sstableTargetBytes = 1024 }); defer db.close(); var keyBuf: [16]u8 = undefined; var valBuf: [24]u8 = undefined; var op: usize = 0; while (op < ops) : (op += 1) { const id = random.intRangeLessThan(usize, 0, keyspace); const key = try std.fmt.bufPrint(&keyBuf, "key:{d:0>6}", .{id}); if (random.intRangeLessThan(usize, 0, 10) < 3) { try db.delete(key); _ = live.remove(key); } else { const value = try std.fmt.bufPrint(&valBuf, "val-{d}-{d}", .{ id, random.int(u16) }); try db.put(key, value); try setLive(&live, arena, key, value); } if (random.intRangeLessThan(usize, 0, 200) == 0) try db.flush(); } try db.maybeCompact(); } var db = try openTemp(&tmp, allocator, .{ .flushThreshold = 2048, .sstableTargetBytes = 1024 }); defer db.close(); var sortedKeys = std.ArrayList([]const u8).init(allocator); defer sortedKeys.deinit(); var keyIt = live.keyIterator(); while (keyIt.next()) |key| try sortedKeys.append(key.*); std.mem.sort([]const u8, sortedKeys.items, {}, lessString); var forward = try db.scan(allocator, null, null); defer forward.deinit(); var idx: usize = 0; while (try forward.next()) |entry| { try std.testing.expect(idx < sortedKeys.items.len); try std.testing.expectEqualStrings(sortedKeys.items[idx], entry.key); try std.testing.expectEqualStrings(live.get(entry.key).?, entry.value); idx += 1; } try std.testing.expectEqual(sortedKeys.items.len, idx); var reverse = try db.scanReverse(allocator, .{}); defer reverse.deinit(); idx = sortedKeys.items.len; while (try reverse.next()) |entry| { try std.testing.expect(idx > 0); idx -= 1; try std.testing.expectEqualStrings(sortedKeys.items[idx], entry.key); try std.testing.expectEqualStrings(live.get(entry.key).?, entry.value); } try std.testing.expectEqual(@as(usize, 0), idx); for (sortedKeys.items) |key| { const got = (try db.get(allocator, key)).?; defer allocator.free(got); try std.testing.expectEqualStrings(live.get(key).?, got); } } test "crash recovery honors wal durability boundary" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const total = 8; { var db = try openTemp(&tmp, allocator, .{}); defer db.close(); var keyBuf: [8]u8 = undefined; var i: usize = 0; while (i < total) : (i += 1) { const key = try std.fmt.bufPrint(&keyBuf, "wkey{d}", .{i}); try db.put(key, "durable"); } try std.testing.expectEqual(@as(usize, 0), db.sstables.items.len); } { const walFile = try tmp.dir.openFile("store/wal.log", .{ .mode = .read_write }); defer walFile.close(); const size = try walFile.getEndPos(); try walFile.setEndPos(size - 1); try walFile.sync(); } var db = try openTemp(&tmp, allocator, .{}); defer db.close(); var keyBuf: [8]u8 = undefined; var i: usize = 0; while (i < total - 1) : (i += 1) { const key = try std.fmt.bufPrint(&keyBuf, "wkey{d}", .{i}); const got = (try db.get(allocator, key)).?; defer allocator.free(got); try std.testing.expectEqualStrings("durable", got); } const lastKey = try std.fmt.bufPrint(&keyBuf, "wkey{d}", .{total - 1}); try std.testing.expectEqual(@as(?[]u8, null), try db.get(allocator, lastKey)); }