diff --git a/src/db.zig b/src/db.zig index d734ab9..84fc18d 100644 --- a/src/db.zig +++ b/src/db.zig @@ -1148,3 +1148,327 @@ test "write batch applies all or nothing" { 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); +} + +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 "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)); +}