Files
cobalt/src/db.zig
T
2026-09-02 19:42:01 +02:00

1633 lines
57 KiB
Zig

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));
}