feat: extend sstable with commonPrefixLen and seekInBlock

This commit is contained in:
dualfroz committed 2026-09-12 18:50:17 +02:00
1 parent 3292bc54a5
commit 22110b24eb
1 file changed
+102 -9
+102 -9
View File
@@ -24,11 +24,26 @@ fn order(a: []const u8, b: []const u8) std.math.Order {
} }
const magic: u32 = 0xC0BA1701; const magic: u32 = 0xC0BA1701;
const version: u32 = 1; const version: u32 = 2;
const footerLen = 4 + 4 + 8 + 4 + 8 + 4 + 8 + 8; const footerLen = 4 + 4 + 8 + 4 + 8 + 4 + 8 + 8;
const recordHeader = 1 + 8 + 4 + 4; const recordHeader = 1 + 8 + 4 + 4;
const defaultBlockTarget = 4096; const defaultBlockTarget = 4096;
/// Every Nth record in a data block is a "restart point": stored with its
/// full key (shared == 0) instead of a shared-prefix delta against the
/// previous key. This bounds how many deltas must be re-applied to recover
/// any one key (never more than `restartInterval - 1`) and, more
/// importantly here, gives block expansion a cheap, structural corruption
/// check: a real restart offset must always land on a shared == 0 record.
const restartInterval: usize = 16;
fn commonPrefixLen(a: []const u8, b: []const u8) usize {
const n = @min(a.len, b.len);
var i: usize = 0;
while (i < n and a[i] == b[i]) : (i += 1) {}
return i;
}
pub const Builder = struct { pub const Builder = struct {
allocator: Allocator, allocator: Allocator,
file: std.fs.File, file: std.fs.File,
@@ -36,6 +51,9 @@ pub const Builder = struct {
blockBuf: std.ArrayList(u8), blockBuf: std.ArrayList(u8),
blockFirstKey: std.ArrayList(u8), blockFirstKey: std.ArrayList(u8),
indexBuf: std.ArrayList(u8), indexBuf: std.ArrayList(u8),
lastKey: std.ArrayList(u8),
blockRestarts: std.ArrayList(u32),
blockRecordCount: usize,
numEntries: u64, numEntries: u64,
logicalBytes: u64, logicalBytes: u64,
maxSeq: u64, maxSeq: u64,
@@ -51,6 +69,9 @@ pub const Builder = struct {
.blockBuf = std.ArrayList(u8).init(allocator), .blockBuf = std.ArrayList(u8).init(allocator),
.blockFirstKey = std.ArrayList(u8).init(allocator), .blockFirstKey = std.ArrayList(u8).init(allocator),
.indexBuf = std.ArrayList(u8).init(allocator), .indexBuf = std.ArrayList(u8).init(allocator),
.lastKey = std.ArrayList(u8).init(allocator),
.blockRestarts = std.ArrayList(u32).init(allocator),
.blockRecordCount = 0,
.numEntries = 0, .numEntries = 0,
.logicalBytes = 0, .logicalBytes = 0,
.maxSeq = 0, .maxSeq = 0,
@@ -63,6 +84,8 @@ pub const Builder = struct {
self.blockBuf.deinit(); self.blockBuf.deinit();
self.blockFirstKey.deinit(); self.blockFirstKey.deinit();
self.indexBuf.deinit(); self.indexBuf.deinit();
self.lastKey.deinit();
self.blockRestarts.deinit();
self.bloom.deinit(); self.bloom.deinit();
self.file.close(); self.file.close();
} }
@@ -72,14 +95,25 @@ pub const Builder = struct {
self.blockFirstKey.clearRetainingCapacity(); self.blockFirstKey.clearRetainingCapacity();
try self.blockFirstKey.appendSlice(record.key); try self.blockFirstKey.appendSlice(record.key);
} }
const isRestart = self.blockRecordCount % restartInterval == 0;
const shared: u32 = if (isRestart) 0 else @intCast(commonPrefixLen(self.lastKey.items, record.key));
const unshared = record.key[shared..];
if (isRestart) try self.blockRestarts.append(@intCast(self.blockBuf.items.len));
const writer = encode.ByteWriter.init(&self.blockBuf); const writer = encode.ByteWriter.init(&self.blockBuf);
try writer.byte(@intFromEnum(record.kind)); try writer.byte(@intFromEnum(record.kind));
try writer.putU64(record.seq); try writer.putU64(record.seq);
try writer.putU32(@intCast(record.key.len)); try writer.putU32(shared);
try writer.putU32(@intCast(unshared.len));
try writer.putU32(@intCast(record.value.len)); try writer.putU32(@intCast(record.value.len));
try writer.slice(record.key); try writer.slice(unshared);
try writer.slice(record.value); try writer.slice(record.value);
self.lastKey.clearRetainingCapacity();
try self.lastKey.appendSlice(record.key);
self.blockRecordCount += 1;
self.bloom.add(record.key); self.bloom.add(record.key);
self.numEntries += 1; self.numEntries += 1;
self.logicalBytes += recordHeader + record.key.len + record.value.len; self.logicalBytes += recordHeader + record.key.len + record.value.len;
@@ -90,6 +124,15 @@ pub const Builder = struct {
fn flushBlock(self: *Builder) !void { fn flushBlock(self: *Builder) !void {
if (self.blockBuf.items.len == 0) return; if (self.blockBuf.items.len == 0) return;
// The restart-offset trailer rides inside the same logical block
// bytes as the records themselves, so it survives compression and
// is immediately available to `expandBlock` on the read side
// without a separate on-disk section or footer entry.
const trailerWriter = encode.ByteWriter.init(&self.blockBuf);
for (self.blockRestarts.items) |restartOffset| try trailerWriter.putU32(restartOffset);
try trailerWriter.putU32(@intCast(self.blockRestarts.items.len));
const originalLen: u32 = @intCast(self.blockBuf.items.len); const originalLen: u32 = @intCast(self.blockBuf.items.len);
const encoded = try compress.encode(self.allocator, self.blockBuf.items); const encoded = try compress.encode(self.allocator, self.blockBuf.items);
@@ -116,6 +159,9 @@ pub const Builder = struct {
self.offset += physLen + 4; self.offset += physLen + 4;
self.blockBuf.clearRetainingCapacity(); self.blockBuf.clearRetainingCapacity();
self.blockRestarts.clearRetainingCapacity();
self.blockRecordCount = 0;
self.lastKey.clearRetainingCapacity();
} }
pub fn finish(self: *Builder) !void { pub fn finish(self: *Builder) !void {
@@ -160,6 +206,13 @@ const IndexEntry = struct {
offset: u64, offset: u64,
blockLen: u32, blockLen: u32,
firstKey: []const u8, firstKey: []const u8,
// Offsets (relative to this block's own bytes, i.e. usable directly
// against the slice `blockBytes()` returns) of every restart record in
// the flattened block, in ascending order. restarts[0] is always 0.
// Lets point lookups binary-search straight to the <= restartInterval
// records that could hold the key instead of linear-scanning the whole
// block from position 0.
restarts: []u32,
}; };
const blockPrefix = 1 + 4; const blockPrefix = 1 + 4;
@@ -188,6 +241,7 @@ pub const Reader = struct {
if (buffer.len < footerLen) return error.CorruptSstable; if (buffer.len < footerLen) return error.CorruptSstable;
const footerStart = buffer.len - footerLen; const footerStart = buffer.len - footerLen;
if (readU32(buffer, footerStart) != magic) return error.CorruptSstable; if (readU32(buffer, footerStart) != magic) return error.CorruptSstable;
if (readU32(buffer, footerStart + 4) != version) return error.UnsupportedSstableVersion;
const indexOffset = readU64(buffer, footerStart + 8); const indexOffset = readU64(buffer, footerStart + 8);
const indexLen = readU32(buffer, footerStart + 16); const indexLen = readU32(buffer, footerStart + 16);
const bloomOffset = readU64(buffer, footerStart + 20); const bloomOffset = readU64(buffer, footerStart + 20);
@@ -204,7 +258,10 @@ pub const Reader = struct {
var decoded = std.ArrayList(u8).init(allocator); var decoded = std.ArrayList(u8).init(allocator);
errdefer decoded.deinit(); errdefer decoded.deinit();
var entries = std.ArrayList(IndexEntry).init(allocator); var entries = std.ArrayList(IndexEntry).init(allocator);
errdefer entries.deinit(); errdefer {
for (entries.items) |e| allocator.free(e.restarts);
entries.deinit();
}
var pos: usize = 0; var pos: usize = 0;
while (pos < indexData.len) { while (pos < indexData.len) {
@@ -221,11 +278,19 @@ pub const Reader = struct {
const payload = physical[blockPrefix..]; const payload = physical[blockPrefix..];
const plainBlock = try compress.decode(allocator, codec, payload, originalLen); const plainBlock = try compress.decode(allocator, codec, payload, originalLen);
defer allocator.free(plainBlock); defer allocator.free(plainBlock);
const expanded = try expandBlock(allocator, plainBlock);
defer allocator.free(expanded.bytes);
errdefer allocator.free(expanded.restarts);
const decOffset = decoded.items.len; const decOffset = decoded.items.len;
try decoded.appendSlice(plainBlock); try decoded.appendSlice(expanded.bytes);
try entries.append(.{ .offset = decOffset, .blockLen = @intCast(plainBlock.len), .firstKey = firstKey }); try entries.append(.{
.offset = decOffset,
.blockLen = @intCast(expanded.bytes.len),
.firstKey = firstKey,
.restarts = expanded.restarts,
});
pos = keyStart + firstKeyLen; pos = keyStart + firstKeyLen;
} }
@@ -265,6 +330,7 @@ pub const Reader = struct {
} }
pub fn deinit(self: *Reader) void { pub fn deinit(self: *Reader) void {
for (self.index) |e| self.allocator.free(e.restarts);
self.allocator.free(self.index); self.allocator.free(self.index);
self.bloom.deinit(); self.bloom.deinit();
self.allocator.free(self.dataBuf); self.allocator.free(self.dataBuf);
@@ -279,7 +345,7 @@ pub const Reader = struct {
if (!self.bloom.mayContain(key)) return null; if (!self.bloom.mayContain(key)) return null;
const blockIdx = self.findBlock(key) orelse return null; const blockIdx = self.findBlock(key) orelse return null;
const block = self.blockBytes(blockIdx); const block = self.blockBytes(blockIdx);
var pos: usize = 0; var pos: usize = Reader.seekInBlock(block, self.index[blockIdx].restarts, key);
while (pos < block.len) { while (pos < block.len) {
const decoded = decodeRecord(block, pos); const decoded = decodeRecord(block, pos);
switch (order(decoded.record.key, key)) { switch (order(decoded.record.key, key)) {
@@ -307,7 +373,7 @@ pub const Reader = struct {
const block = cached orelse self.dataBuf[entry.offset .. entry.offset + entry.blockLen]; const block = cached orelse self.dataBuf[entry.offset .. entry.offset + entry.blockLen];
if (cached == null) try blockCache.put(blockKey, block); if (cached == null) try blockCache.put(blockKey, block);
var pos: usize = 0; var pos: usize = Reader.seekInBlock(block, entry.restarts, key);
while (pos < block.len) { while (pos < block.len) {
const decoded = decodeRecord(block, pos); const decoded = decodeRecord(block, pos);
switch (order(decoded.record.key, key)) { switch (order(decoded.record.key, key)) {
@@ -329,7 +395,10 @@ pub const Reader = struct {
pub fn cursor(self: *const Reader, start: ?[]const u8) Cursor { pub fn cursor(self: *const Reader, start: ?[]const u8) Cursor {
var c = Cursor{ .reader = self, .blockIdx = 0, .pos = 0, .nextPos = 0, .cur = null }; var c = Cursor{ .reader = self, .blockIdx = 0, .pos = 0, .nextPos = 0, .cur = null };
if (self.index.len == 0) return c; if (self.index.len == 0) return c;
c.blockIdx = if (start) |s| (self.findBlock(s) orelse 0) else 0; if (start) |s| {
c.blockIdx = self.findBlock(s) orelse 0;
c.pos = Reader.seekInBlock(self.blockBytes(c.blockIdx), self.index[c.blockIdx].restarts, s);
}
c.refresh(); c.refresh();
if (start) |s| { if (start) |s| {
while (c.valid() and order(c.cur.?.key, s) == .lt) c.advance(); while (c.valid() and order(c.cur.?.key, s) == .lt) c.advance();
@@ -400,6 +469,30 @@ pub const Reader = struct {
return ans; return ans;
} }
/// Binary-searches `restarts` (each entry the flat-block offset of a
/// restart record, ascending by key since keys are sorted within a
/// block) for the rightmost restart whose key is <= `key`, and returns
/// its offset. Callers then linear-scan forward from there instead of
/// from position 0, bounding the scan to at most `restartInterval`
/// records regardless of how many records the block holds. Returns 0
/// (restarts[0], always the block's first record) when `key` sorts
/// before every restart.
fn seekInBlock(block: []const u8, restarts: []const u32, key: []const u8) usize {
if (restarts.len == 0) return 0;
var lo: usize = 0;
var hi: usize = restarts.len;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
const rec = decodeRecord(block, restarts[mid]);
if (order(rec.record.key, key) != .gt) {
lo = mid + 1;
} else {
hi = mid;
}
}
return if (lo == 0) 0 else restarts[lo - 1];
}
fn blockBytes(self: *const Reader, idx: usize) []const u8 { fn blockBytes(self: *const Reader, idx: usize) []const u8 {
const entry = self.index[idx]; const entry = self.index[idx];
return self.dataBuf[entry.offset .. entry.offset + entry.blockLen]; return self.dataBuf[entry.offset .. entry.offset + entry.blockLen];