Wrap signatures and long expressions to the 100-column limit and make every file zig fmt clean. Semantics-preserving throughout: ignoring whitespace and the trailing commas that wrapping introduces, every file here is byte-identical to its predecessor, and the one apparent exception is a warning string split with `++`, which concatenates at comptime to the same bytes. src/index.zig and src/commands.zig are reformatted in the commits that follow, because their reformat is interleaved with in-flight changes to them and separating the two would need the reformat re-derived rather than moved.
965 lines
38 KiB
Zig
965 lines
38 KiB
Zig
//! Append-only record log, block-framed and compressed (roadmap item 3).
|
|
//!
|
|
//! File layout:
|
|
//! [0..4) u32 magic "MLOG"
|
|
//! [4] u8 version (1)
|
|
//! [5] u8 codec (0 raw, 1 lz4) — the preferred codec for new blocks
|
|
//! [6..8) reserved
|
|
//! [8..16) u64 block_target — soft fill target per block (256 KiB)
|
|
//! [16..) blocks...
|
|
//!
|
|
//! Block layout:
|
|
//! [0..4) u32 total — block bytes including this header
|
|
//! [4..12) u64 hash over bytes [12..total) — the stored (compressed)
|
|
//! payload exactly as it sits on disk, so the decompressor only
|
|
//! ever sees input already proven intact
|
|
//! [12] u8 codec (0 raw, 1 lz4); a block falls back to raw when
|
|
//! compression does not help
|
|
//! [13..16) reserved
|
|
//! [16..) payload — the block's records, raw or LZ4-compressed
|
|
//!
|
|
//! Each block holds the pre-existing record framing unchanged:
|
|
//! [0..4) u32 len — total record bytes
|
|
//! [4..12) u64 hash over bytes [12..len)
|
|
//! [12..20) u64 seq
|
|
//! [20] u8 type
|
|
//! [21..24) reserved
|
|
//! [24..) db\0 coll\0 bson doc
|
|
//! All integers little-endian. Records never straddle blocks: appends
|
|
//! accumulate into an in-memory block that is sealed (compressed, written,
|
|
//! and, when not group-committed, synced) once the next record would push
|
|
//! it past the target. Engine.apply_record is untouched — it still sees the
|
|
//! same records.
|
|
//!
|
|
//! Torn tail versus interior corruption stay distinguishable, exactly as
|
|
//! before: a short read, an impossible length, or a hash mismatch in the
|
|
//! final block means a crash mid-append — replay stops there and later
|
|
//! appends overwrite the garbage. A hash mismatch in any earlier block is
|
|
//! error.InvalidLog.
|
|
//!
|
|
//! The integrity hash is XxHash3, not CRC32. Both are checked for the same
|
|
//! thing — did these bytes survive intact — but std.hash.Crc32 is the
|
|
//! table-driven byte-at-a-time Crc32IsoHdlc, measured here at 408 MB/s
|
|
//! against XxHash3's 31 GB/s. On a 16 KiB document that is 38 us versus
|
|
//! 0.5 us, which was roughly two thirds of the entire bulk-insert cost.
|
|
|
|
const std = @import("std");
|
|
const bson = @import("bson.zig");
|
|
|
|
pub const record_type_upsert: u8 = 1;
|
|
pub const record_type_delete: u8 = 2;
|
|
pub const record_type_index_create: u8 = 3;
|
|
pub const record_type_index_drop: u8 = 4;
|
|
|
|
pub const header_len: usize = 24; // per-record: len + hash + seq + type + reserved
|
|
pub const block_header_len: usize = 16; // block: total + hash + codec + reserved
|
|
pub const file_header_len: usize = 16; // magic + version + codec + block_target
|
|
|
|
const file_magic: u32 = 0x4D4C4F47; // "MLOG"
|
|
const file_version: u8 = 1;
|
|
pub const codec_raw: u8 = 0;
|
|
pub const codec_lz4: u8 = 1;
|
|
|
|
/// Soft fill target for a block's record region. Sealed when the next
|
|
/// record would push the block past this; a single oversized record gets
|
|
/// its own block.
|
|
pub const block_target: usize = 256 * 1024;
|
|
|
|
/// Integrity hash over a record's bytes after the length and hash fields,
|
|
/// or over a block's payload bytes after its header fields.
|
|
fn record_hash(bytes: []const u8) u64 {
|
|
return std.hash.XxHash3.hash(0, bytes);
|
|
}
|
|
|
|
/// Largest record payload we will accept during replay. Matches the
|
|
/// announced maxBsonObjectSize with room for names and header.
|
|
pub const max_record_payload: usize = 16 * 1024 * 1024 + 64 * 1024;
|
|
|
|
/// Largest stored block payload: block_target plus one oversized record,
|
|
/// plus LZ4's worst-case expansion of it.
|
|
const max_block_payload: usize = max_record_payload + 1024 * 1024;
|
|
|
|
pub const Record = struct {
|
|
seq: u64,
|
|
type: u8,
|
|
db: []const u8, // transient: valid only during replay callback
|
|
coll: []const u8,
|
|
};
|
|
|
|
pub const Error = error{
|
|
InvalidLog, // corrupt interior block or record (bad hash or impossible length)
|
|
CorruptLz4, // the decoder saw structurally invalid input
|
|
};
|
|
|
|
/// Callback receives transient slices and a heap-allocated, freshly parsed
|
|
/// document. The callback takes ownership of the document (must deinit).
|
|
pub const ReplayFn = *const fn (ctx: *anyopaque, record: Record, doc: *bson.Document) anyerror!void;
|
|
|
|
pub const Log = struct {
|
|
gpa: std.mem.Allocator,
|
|
io: std.Io,
|
|
file: std.Io.File,
|
|
path: []const u8,
|
|
end_pos: u64,
|
|
log_bytes: u64, // bytes written since the log was last rewritten
|
|
/// Uncompressed record bytes appended since the log was last rewritten —
|
|
/// the data volume, which the compaction threshold is really about (the
|
|
/// on-disk size shrinks with compression and would under-trigger).
|
|
data_bytes: u64 = 0,
|
|
codec: u8,
|
|
// Reused record-framing buffer. Appends are single-writer (the engine's
|
|
// exclusive lock), so one buffer avoids a realloc cycle per record.
|
|
scratch: std.ArrayListUnmanaged(u8),
|
|
/// The current block's records, uncompressed, until the block is sealed.
|
|
block: std.ArrayListUnmanaged(u8),
|
|
/// LZ4 output buffer; also the sealed block's payload when raw.
|
|
compressed: std.ArrayListUnmanaged(u8),
|
|
/// LZ4 hash table (positions of recent 4-byte sequences).
|
|
lz4_table: []u32,
|
|
|
|
/// Whether an existing file's contents are kept or discarded.
|
|
///
|
|
/// `.keep` is the database's own log: its bytes are the database, and
|
|
/// `replay` reads them. `.truncate` is for a file being written from
|
|
/// scratch (compaction's tmp), where leftover bytes from an earlier,
|
|
/// longer file would survive past the new content as intact blocks and be
|
|
/// replayed as live records.
|
|
const OpenMode = enum { keep, truncate };
|
|
|
|
/// Open the log at `path`, keeping whatever is already there for `replay`.
|
|
pub fn open(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !Log {
|
|
return open_mode(gpa, io, path, .keep);
|
|
}
|
|
|
|
/// Open `path` as a brand-new empty log, discarding anything already there.
|
|
///
|
|
/// Compaction's tmp file must start empty. `open` keeps an existing file's
|
|
/// bytes and only rewinds end_pos to the header, so a longer previous tmp
|
|
/// (a retried or crashed compaction) would leave valid, hash-correct
|
|
/// blocks past the new content -- which `replay` applies as live records
|
|
/// once the rename publishes the file as the database, resurrecting
|
|
/// documents that were deleted.
|
|
pub fn create(gpa: std.mem.Allocator, io: std.Io, path: []const u8) !Log {
|
|
return open_mode(gpa, io, path, .truncate);
|
|
}
|
|
|
|
fn open_mode(gpa: std.mem.Allocator, io: std.Io, path: []const u8, mode: OpenMode) !Log {
|
|
// Resolve to an absolute path so compaction can rename the file
|
|
// without depending on the caller's working directory.
|
|
const abs_path = blk: {
|
|
if (path.len > 0 and path[0] == '/') break :blk try gpa.dupe(u8, path);
|
|
const cwd = try std.process.currentPathAlloc(io, gpa);
|
|
defer gpa.free(cwd);
|
|
break :blk try std.fmt.allocPrint(gpa, "{s}/{s}", .{ cwd, path });
|
|
};
|
|
errdefer gpa.free(abs_path);
|
|
|
|
const dir = std.Io.Dir.cwd();
|
|
const file: std.Io.File = switch (mode) {
|
|
// createFile truncates by default, so this both creates a missing
|
|
// file and empties an existing one -- the whole point of .truncate.
|
|
.truncate => try dir.createFile(io, abs_path, .{ .read = true, .truncate = true }),
|
|
.keep => dir.openFile(io, abs_path, .{ .mode = .read_write }) catch |err| switch (err) {
|
|
error.FileNotFound => try dir.createFile(io, abs_path, .{ .read = true }),
|
|
else => return err,
|
|
},
|
|
};
|
|
|
|
var self: Log = .{
|
|
.gpa = gpa,
|
|
.io = io,
|
|
.file = file,
|
|
.path = abs_path,
|
|
.end_pos = file_header_len,
|
|
.log_bytes = 0,
|
|
.codec = codec_lz4,
|
|
.scratch = .empty,
|
|
.block = .empty,
|
|
.compressed = .empty,
|
|
.lz4_table = undefined,
|
|
};
|
|
errdefer {
|
|
self.scratch.deinit(gpa);
|
|
self.block.deinit(gpa);
|
|
self.compressed.deinit(gpa);
|
|
file.close(io);
|
|
}
|
|
self.lz4_table = try gpa.alloc(u32, lz4_table_size);
|
|
errdefer gpa.free(self.lz4_table);
|
|
|
|
// Read or write the file header. A fresh file gets one; an existing
|
|
// file must carry a valid one (a torn header on a never-opened db is
|
|
// unrecoverable and reported as invalid).
|
|
var hdr: [file_header_len]u8 = undefined;
|
|
const n = file.readPositionalAll(io, &hdr, 0) catch return error.InvalidLog;
|
|
if (n == 0) {
|
|
write_file_header(&hdr, codec_lz4);
|
|
try file.writePositionalAll(io, &hdr, 0);
|
|
} else {
|
|
if (n < file_header_len) return error.InvalidLog;
|
|
if (std.mem.readInt(u32, hdr[0..4], .little) != file_magic) return error.InvalidLog;
|
|
if (hdr[4] != file_version) return error.InvalidLog;
|
|
self.codec = hdr[5];
|
|
if (self.codec != codec_raw and self.codec != codec_lz4) return error.InvalidLog;
|
|
}
|
|
return self;
|
|
}
|
|
|
|
pub fn close(self: *Log) void {
|
|
// Best-effort: flush an unsealed block so nothing acknowledged is
|
|
// left only in memory. The caller syncs before close on the paths
|
|
// that care about durability.
|
|
self.seal_block() catch {};
|
|
self.block.deinit(self.gpa);
|
|
self.compressed.deinit(self.gpa);
|
|
self.gpa.free(self.lz4_table);
|
|
self.scratch.deinit(self.gpa);
|
|
self.file.close(self.io);
|
|
self.gpa.free(self.path);
|
|
}
|
|
|
|
/// Replay all valid records from the beginning of the file.
|
|
pub fn replay(self: *Log, ctx: *anyopaque, callback: ReplayFn) !void {
|
|
var decomp: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer decomp.deinit(self.gpa);
|
|
var pos: u64 = file_header_len;
|
|
|
|
while (true) {
|
|
var hdr: [block_header_len]u8 = undefined;
|
|
const n = self.file.readPositionalAll(self.io, &hdr, pos) catch |err| {
|
|
std.debug.print("multiforadb: log read error at {d}: {s}\n", .{
|
|
pos,
|
|
@errorName(err),
|
|
});
|
|
return error.InvalidLog;
|
|
};
|
|
if (n == 0) return; // clean end
|
|
if (n < block_header_len) return; // torn tail: partial block header
|
|
const total: u32 = std.mem.readInt(u32, hdr[0..4], .little);
|
|
if (total < block_header_len or total - block_header_len > max_block_payload) {
|
|
std.debug.print("multiforadb: corrupt block length {d} at {d}\n", .{ total, pos });
|
|
return; // torn tail: impossible length, nothing to validate
|
|
}
|
|
const payload_len: usize = total - block_header_len;
|
|
const payload = try self.gpa.alloc(u8, payload_len);
|
|
defer self.gpa.free(payload);
|
|
const payload_read = self.file.readPositionalAll(self.io, payload, pos + block_header_len) catch return error.InvalidLog;
|
|
if (payload_read < payload_len) return; // torn tail: crash mid-append
|
|
|
|
const hash_stored: u64 = std.mem.readInt(u64, hdr[4..12], .little);
|
|
const hash_actual = record_hash(payload);
|
|
if (hash_stored != hash_actual) {
|
|
// A mismatch in the final block is a torn append (the block
|
|
// was partially flushed); a mismatch before more bytes
|
|
// follow is interior corruption. The block header hash
|
|
// covers the stored bytes, so the decompressor below only
|
|
// runs on input already proven intact.
|
|
const file_len = self.file.length(self.io) catch return error.InvalidLog;
|
|
if (pos + total >= file_len) return;
|
|
return error.InvalidLog;
|
|
}
|
|
|
|
const codec = hdr[12];
|
|
decomp.clearRetainingCapacity();
|
|
switch (codec) {
|
|
codec_raw => try decomp.appendSlice(self.gpa, payload),
|
|
codec_lz4 => try lz4_decompress(self.gpa, payload, &decomp),
|
|
else => {
|
|
std.debug.print("multiforadb: unknown block codec {d} at {d}\n", .{
|
|
codec,
|
|
pos,
|
|
});
|
|
return error.InvalidLog;
|
|
},
|
|
}
|
|
|
|
var idx: usize = 0;
|
|
while (idx < decomp.items.len) {
|
|
idx += try self.parse_record(decomp.items[idx..], pos + idx, ctx, callback);
|
|
}
|
|
pos += total;
|
|
self.end_pos = pos;
|
|
}
|
|
}
|
|
|
|
/// Parse one record from `bytes` (a block's decompressed record region)
|
|
/// and deliver it. Returns the record's byte length. Any framing failure
|
|
/// here is interior corruption: a block was sealed only with complete
|
|
/// records, and its hash proved the stored bytes intact.
|
|
fn parse_record(
|
|
self: *Log,
|
|
bytes: []const u8,
|
|
pos: u64,
|
|
ctx: *anyopaque,
|
|
callback: ReplayFn,
|
|
) !usize {
|
|
if (bytes.len < 4) return error.InvalidLog;
|
|
const total: u32 = std.mem.readInt(u32, bytes[0..4], .little);
|
|
if (total < header_len) {
|
|
std.debug.print("multiforadb: corrupt record length {d} at {d}\n", .{ total, pos });
|
|
return error.InvalidLog;
|
|
}
|
|
const payload_len: usize = total - 4;
|
|
if (payload_len > max_record_payload) {
|
|
std.debug.print("multiforadb: record too large at {d}\n", .{pos});
|
|
return error.InvalidLog;
|
|
}
|
|
if (bytes.len < total) return error.InvalidLog; // record crosses the block end
|
|
const payload = bytes[4..total];
|
|
const hash_stored: u64 = std.mem.readInt(u64, payload[0..8], .little);
|
|
const hash_actual = record_hash(payload[8..]);
|
|
if (hash_stored != hash_actual) return error.InvalidLog;
|
|
|
|
var idx: usize = header_len - 4;
|
|
const seq: u64 = std.mem.readInt(u64, payload[8..16], .little);
|
|
const rtype = payload[16];
|
|
const db = read_cstring(payload, &idx) orelse return error.InvalidLog;
|
|
const coll = read_cstring(payload, &idx) orelse return error.InvalidLog;
|
|
if (idx > payload_len) return error.InvalidLog;
|
|
const doc_bytes = payload[idx..payload_len];
|
|
|
|
const doc = try self.gpa.create(bson.Document);
|
|
doc.* = bson.Document.parse(self.gpa, doc_bytes) catch {
|
|
self.gpa.destroy(doc);
|
|
std.debug.print("multiforadb: unparseable doc in log at {d}\n", .{pos});
|
|
return error.InvalidLog;
|
|
};
|
|
try callback(ctx, .{
|
|
.seq = seq,
|
|
.type = rtype,
|
|
.db = db,
|
|
.coll = coll,
|
|
}, doc);
|
|
return total;
|
|
}
|
|
|
|
pub fn append_upsert(
|
|
self: *Log,
|
|
db: []const u8,
|
|
coll: []const u8,
|
|
doc: []const u8,
|
|
seq: u64,
|
|
) !void {
|
|
try self.append(record_type_upsert, db, coll, doc, seq);
|
|
}
|
|
|
|
pub fn append_delete(
|
|
self: *Log,
|
|
db: []const u8,
|
|
coll: []const u8,
|
|
doc: []const u8,
|
|
seq: u64,
|
|
) !void {
|
|
try self.append(record_type_delete, db, coll, doc, seq);
|
|
}
|
|
|
|
/// The payload is the canonical index spec document ({v, key, name,
|
|
/// unique?, sparse?}); only apply_record interprets it.
|
|
pub fn append_index_create(
|
|
self: *Log,
|
|
db: []const u8,
|
|
coll: []const u8,
|
|
doc: []const u8,
|
|
seq: u64,
|
|
) !void {
|
|
try self.append(record_type_index_create, db, coll, doc, seq);
|
|
}
|
|
|
|
/// The payload is {name: "..."}; only apply_record interprets it.
|
|
pub fn append_index_drop(
|
|
self: *Log,
|
|
db: []const u8,
|
|
coll: []const u8,
|
|
doc: []const u8,
|
|
seq: u64,
|
|
) !void {
|
|
try self.append(record_type_index_drop, db, coll, doc, seq);
|
|
}
|
|
|
|
fn append(
|
|
self: *Log,
|
|
rtype: u8,
|
|
db: []const u8,
|
|
coll: []const u8,
|
|
doc: []const u8,
|
|
seq: u64,
|
|
) !void {
|
|
if (std.mem.indexOfScalar(u8, db, 0) != null or std.mem.indexOfScalar(u8, coll, 0) != null) {
|
|
return error.NulInName;
|
|
}
|
|
const buf = &self.scratch;
|
|
buf.clearRetainingCapacity();
|
|
try buf.appendNTimes(self.gpa, 0, header_len);
|
|
std.mem.writeInt(u64, buf.items[12..20], seq, .little);
|
|
buf.items[20] = rtype;
|
|
try buf.appendSlice(self.gpa, db);
|
|
try buf.append(self.gpa, 0);
|
|
try buf.appendSlice(self.gpa, coll);
|
|
try buf.append(self.gpa, 0);
|
|
try buf.appendSlice(self.gpa, doc);
|
|
if (buf.items.len > std.math.maxInt(u32)) return error.LogTooLarge;
|
|
const total: u32 = @intCast(buf.items.len);
|
|
std.mem.writeInt(u32, buf.items[0..4], total, .little);
|
|
std.mem.writeInt(u64, buf.items[4..12], record_hash(buf.items[12..]), .little);
|
|
|
|
self.data_bytes += buf.items.len;
|
|
// Seal the current block when the next record would push it past the
|
|
// target; a single oversized record keeps its own block.
|
|
if (self.block.items.len > 0 and self.block.items.len + buf.items.len > block_target) {
|
|
try self.seal_block();
|
|
}
|
|
try self.block.appendSlice(self.gpa, buf.items);
|
|
// No sync here: durability is the commit point (Log.sync), which
|
|
// runs once per write command and coalesces across connections.
|
|
}
|
|
|
|
/// Compress and write the current block, then reset it. No-op when it is
|
|
/// empty. Blocks fall back to raw when compression does not help.
|
|
fn seal_block(self: *Log) !void {
|
|
if (self.block.items.len == 0) return;
|
|
defer self.block.clearRetainingCapacity();
|
|
|
|
var codec: u8 = codec_raw;
|
|
var payload: []const u8 = self.block.items;
|
|
if (self.codec == codec_lz4) {
|
|
const worst = self.block.items.len + self.block.items.len / 255 + 16;
|
|
try self.compressed.ensureTotalCapacity(self.gpa, worst);
|
|
const out = self.compressed.allocatedSlice()[0..worst];
|
|
const n = lz4_compress(self.block.items, out, self.lz4_table);
|
|
if (n < self.block.items.len) {
|
|
payload = out[0..n];
|
|
codec = codec_lz4;
|
|
}
|
|
}
|
|
|
|
var hdr: [block_header_len]u8 = undefined;
|
|
const total: u32 = @intCast(block_header_len + payload.len);
|
|
std.mem.writeInt(u32, hdr[0..4], total, .little);
|
|
std.mem.writeInt(u64, hdr[4..12], record_hash(payload), .little);
|
|
hdr[12] = codec;
|
|
hdr[13..block_header_len].* = [_]u8{ 0, 0, 0 };
|
|
try self.file.writePositionalAll(self.io, &hdr, self.end_pos);
|
|
try self.file.writePositionalAll(self.io, payload, self.end_pos + block_header_len);
|
|
self.end_pos += total;
|
|
self.log_bytes += total;
|
|
}
|
|
|
|
/// The log's only commit point: seal the open block, then one fsync.
|
|
///
|
|
/// Appends never sync (see `append_record`), so nothing is durable until
|
|
/// this returns -- which is why Engine.commit calls it exactly once per
|
|
/// write command, coalescing every writer in flight into a single fsync.
|
|
pub fn sync(self: *Log) !void {
|
|
try self.seal_block();
|
|
try self.file.sync(self.io);
|
|
}
|
|
|
|
fn read_cstring(bytes: []const u8, idx: *usize) ?[]const u8 {
|
|
const start = idx.*;
|
|
while (idx.* < bytes.len and bytes[idx.*] != 0) idx.* += 1;
|
|
if (idx.* >= bytes.len) return null;
|
|
idx.* += 1;
|
|
return bytes[start .. idx.* - 1];
|
|
}
|
|
};
|
|
|
|
fn write_file_header(hdr: *[file_header_len]u8, codec: u8) void {
|
|
std.mem.writeInt(u32, hdr[0..4], file_magic, .little);
|
|
hdr[4] = file_version;
|
|
hdr[5] = codec;
|
|
hdr[6] = 0;
|
|
hdr[7] = 0;
|
|
std.mem.writeInt(u64, hdr[8..16], block_target, .little);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LZ4 block codec
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The standard LZ4 block format: a sequence of (token, literals, offset,
|
|
// match-length) items. The token's high nibble is the literal length
|
|
// (15 = extended by bytes), the low nibble is the match length minus 4
|
|
// (15 = extended). Matches reference earlier output by a 16-bit offset and
|
|
// may overlap it. Self-contained: only this decoder reads this log.
|
|
|
|
const lz4_table_size: usize = 4096;
|
|
|
|
fn lz4_hash(v: u32) u32 {
|
|
return (v *% 2654435761) >> 20;
|
|
}
|
|
|
|
/// Compress `src` into `dst`, which must hold at least
|
|
/// src.len + src.len/255 + 16 bytes. Returns the compressed length.
|
|
pub fn lz4_compress(src: []const u8, dst: []u8, table: []u32) usize {
|
|
if (src.len == 0) return 0;
|
|
@memset(table, 0);
|
|
var out: usize = 0;
|
|
var anchor: usize = 0; // start of the pending literal run
|
|
var ip: usize = 0;
|
|
// Matches may not start in the final 5 bytes (they end there), keeping
|
|
// the stream standard-compliant: the last 5 bytes are always literals.
|
|
const limit = if (src.len >= 5) src.len - 5 else 0;
|
|
|
|
while (ip < limit) {
|
|
// Scan for a 4-byte match via the hash table.
|
|
var match_pos: ?usize = null;
|
|
while (ip + 4 <= src.len) : (ip += 1) {
|
|
const h = lz4_hash(std.mem.readInt(u32, src[ip..][0..4], .little));
|
|
const cand = table[h];
|
|
table[h] = @intCast(ip + 1); // 0 means empty; positions are stored +1
|
|
if (cand != 0) {
|
|
const cp = cand - 1;
|
|
const back = ip - cp;
|
|
if (back >= lz4_min_match and back <= 65535 and
|
|
std.mem.readInt(u32, src[cp..][0..4], .little) == std.mem.readInt(u32, src[ip..][0..4], .little))
|
|
{
|
|
match_pos = cp;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const mp = match_pos orelse break;
|
|
// Extend the match, stopping at the end or the 5-byte literal tail.
|
|
var match_len: usize = lz4_min_match;
|
|
const match_limit = src.len - 5;
|
|
while (ip + match_len < match_limit and src[ip + match_len] == src[mp + match_len]) : (match_len += 1) {}
|
|
out += emit_sequence(dst[out..], src[anchor..ip], @intCast(ip - mp), match_len);
|
|
ip += match_len;
|
|
anchor = ip;
|
|
}
|
|
out += emit_literals(dst[out..], src[anchor..]);
|
|
return out;
|
|
}
|
|
|
|
const lz4_min_match: usize = 4;
|
|
|
|
/// Emit one (token, literals, offset, match length) item.
|
|
fn emit_sequence(dst: []u8, literals: []const u8, offset: u16, match_len: usize) usize {
|
|
var out: usize = 0;
|
|
const lit_len = literals.len;
|
|
var token: u8 = if (lit_len >= 15) 0xF0 else @intCast(lit_len << 4);
|
|
const mlen = match_len - lz4_min_match;
|
|
token |= if (mlen >= 15) 0x0F else @intCast(mlen);
|
|
dst[out] = token;
|
|
out += 1;
|
|
if (lit_len >= 15) {
|
|
var rem = lit_len - 15;
|
|
while (rem >= 255) : (rem -= 255) {
|
|
dst[out] = 255;
|
|
out += 1;
|
|
}
|
|
dst[out] = @intCast(rem);
|
|
out += 1;
|
|
}
|
|
@memcpy(dst[out .. out + lit_len], literals);
|
|
out += lit_len;
|
|
std.mem.writeInt(u16, dst[out..][0..2], offset, .little);
|
|
out += 2;
|
|
if (mlen >= 15) {
|
|
var rem = mlen - 15;
|
|
while (rem >= 255) : (rem -= 255) {
|
|
dst[out] = 255;
|
|
out += 1;
|
|
}
|
|
dst[out] = @intCast(rem);
|
|
out += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// Emit a trailing literal run (a final item with no match).
|
|
fn emit_literals(dst: []u8, literals: []const u8) usize {
|
|
if (literals.len == 0) return 0;
|
|
var out: usize = 0;
|
|
dst[out] = if (literals.len >= 15) 0xF0 else @intCast(literals.len << 4);
|
|
out += 1;
|
|
if (literals.len >= 15) {
|
|
var rem = literals.len - 15;
|
|
while (rem >= 255) : (rem -= 255) {
|
|
dst[out] = 255;
|
|
out += 1;
|
|
}
|
|
dst[out] = @intCast(rem);
|
|
out += 1;
|
|
}
|
|
@memcpy(dst[out .. out + literals.len], literals);
|
|
out += literals.len;
|
|
return out;
|
|
}
|
|
|
|
/// Decompress an LZ4 block into `out` (appended). The block hash has
|
|
/// already proven the input intact when this runs during replay, so
|
|
/// structural failures here mean a bug or a raw-codec mismatch.
|
|
fn lz4_decompress(
|
|
gpa: std.mem.Allocator,
|
|
src: []const u8,
|
|
out: *std.ArrayListUnmanaged(u8),
|
|
) error{ CorruptLz4, OutOfMemory }!void {
|
|
var ip: usize = 0;
|
|
while (ip < src.len) {
|
|
const token = src[ip];
|
|
ip += 1;
|
|
var lit_len: usize = token >> 4;
|
|
if (lit_len == 15) {
|
|
while (true) {
|
|
if (ip >= src.len) return error.CorruptLz4;
|
|
const b = src[ip];
|
|
ip += 1;
|
|
lit_len += b;
|
|
if (b != 255) break;
|
|
}
|
|
}
|
|
if (ip + lit_len > src.len) return error.CorruptLz4;
|
|
try out.appendSlice(gpa, src[ip .. ip + lit_len]);
|
|
ip += lit_len;
|
|
if (ip >= src.len) break; // trailing literals only
|
|
if (ip + 2 > src.len) return error.CorruptLz4;
|
|
const offset = std.mem.readInt(u16, src[ip..][0..2], .little);
|
|
ip += 2;
|
|
if (offset == 0 or offset > out.items.len) return error.CorruptLz4;
|
|
var match_len: usize = token & 0x0F;
|
|
if (match_len == 15) {
|
|
while (true) {
|
|
if (ip >= src.len) return error.CorruptLz4;
|
|
const b = src[ip];
|
|
ip += 1;
|
|
match_len += b;
|
|
if (b != 255) break;
|
|
}
|
|
}
|
|
match_len += lz4_min_match;
|
|
const base = out.items.len - offset;
|
|
// Grow once, then copy in place: the source cannot alias the
|
|
// destination's old buffer across a realloc. The non-overlapping
|
|
// prefix copies in one shot; the overlapping tail (offset < match
|
|
// length) copies itself forward byte by byte.
|
|
const region = try out.addManyAsSlice(gpa, match_len);
|
|
const non_overlap = @min(match_len, offset);
|
|
@memcpy(region[0..non_overlap], out.items[base .. base + non_overlap]);
|
|
var k = non_overlap;
|
|
while (k < match_len) : (k += 1) {
|
|
region[k] = out.items[base + k];
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
/// Throwaway log file in the test temp dir, shared by the storage and
|
|
/// engine test suites.
|
|
pub const TmpLog = struct {
|
|
tmp: std.testing.TmpDir,
|
|
path: []u8,
|
|
|
|
pub fn init(gpa: std.mem.Allocator) !TmpLog {
|
|
const tmp = std.testing.tmpDir(.{});
|
|
const path = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/test.log", .{tmp.sub_path});
|
|
return .{ .tmp = tmp, .path = path };
|
|
}
|
|
|
|
pub fn deinit(self: *TmpLog, gpa: std.mem.Allocator) void {
|
|
self.tmp.cleanup();
|
|
gpa.free(self.path);
|
|
}
|
|
};
|
|
|
|
test "lz4 round-trips, including long runs and incompressible input" {
|
|
const gpa = testing.allocator;
|
|
var prng = std.Random.DefaultPrng.init(0x1A2B3C4D);
|
|
const table = try gpa.alloc(u32, lz4_table_size);
|
|
defer gpa.free(table);
|
|
|
|
var src: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer src.deinit(gpa);
|
|
// Highly compressible: a repeating pattern with some noise.
|
|
for (0..100_000) |i| {
|
|
try src.append(gpa, if (i % 37 == 0) @as(u8, @intCast(prng.next() & 0xFF)) else 'a' + @as(u8, @intCast(i % 26)));
|
|
}
|
|
const worst = src.items.len + src.items.len / 255 + 16;
|
|
const dst = try gpa.alloc(u8, worst);
|
|
defer gpa.free(dst);
|
|
const n = lz4_compress(src.items, dst, table);
|
|
try testing.expect(n < src.items.len / 3); // actually compressed
|
|
var out: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer out.deinit(gpa);
|
|
try lz4_decompress(gpa, dst[0..n], &out);
|
|
try testing.expectEqualSlices(u8, src.items, out.items);
|
|
|
|
// Incompressible input still round-trips (and expands).
|
|
out.clearRetainingCapacity();
|
|
src.clearRetainingCapacity();
|
|
for (0..10_000) |_| try src.append(gpa, @intCast(prng.next() & 0xFF));
|
|
const n2 = lz4_compress(src.items, dst, table);
|
|
try testing.expect(n2 >= src.items.len);
|
|
try lz4_decompress(gpa, dst[0..n2], &out);
|
|
try testing.expectEqualSlices(u8, src.items, out.items);
|
|
|
|
// Truncated input is rejected, not read out of bounds: a token
|
|
// promising four literals with only two bytes left, and a match
|
|
// sequence with no offset.
|
|
try testing.expectError(error.CorruptLz4, lz4_decompress(gpa, &[_]u8{ 0x40, 1, 2 }, &out));
|
|
try testing.expectError(error.CorruptLz4, lz4_decompress(gpa, &[_]u8{ 0x00, 0x00 }, &out));
|
|
}
|
|
|
|
test "append, replay, torn tail" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = testing.allocator;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
var log = try Log.open(gpa, io, tmp.path);
|
|
defer log.close();
|
|
|
|
const doc_bytes = [_]u8{
|
|
0x0E, 0x00, 0x00, 0x00, // len 14
|
|
0x10, '_', 'i', 'd', 0, 0x2A, 0x00, 0x00, 0x00, // _id: 42
|
|
0x00,
|
|
};
|
|
try log.append_upsert("db1", "coll1", &doc_bytes, 1);
|
|
try log.append_delete("db1", "coll1", &doc_bytes, 2);
|
|
try log.sync();
|
|
|
|
var seen: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer seen.deinit(gpa);
|
|
const Ctx = struct {
|
|
seen: *std.ArrayListUnmanaged(u8),
|
|
gpa: std.mem.Allocator,
|
|
fn apply(ctx: *anyopaque, record: Record, doc: *bson.Document) anyerror!void {
|
|
const self: *@This() = @ptrCast(@alignCast(ctx));
|
|
try self.seen.append(self.gpa, record.type);
|
|
try self.seen.append(self.gpa, @intCast(record.seq));
|
|
doc.deinit();
|
|
self.gpa.destroy(doc);
|
|
}
|
|
};
|
|
var ctx = Ctx{ .seen = &seen, .gpa = gpa };
|
|
try log.replay(@ptrCast(&ctx), Ctx.apply);
|
|
|
|
try testing.expectEqualSlices(u8, &[_]u8{ record_type_upsert, 1, record_type_delete, 2 }, seen.items);
|
|
}
|
|
|
|
test "record larger than the read chunk replays" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = testing.allocator;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
var log = try Log.open(gpa, io, tmp.path);
|
|
defer log.close();
|
|
|
|
// Build a doc whose payload pushes the record past the block target,
|
|
// so it gets its own oversized block.
|
|
const big = try gpa.alloc(u8, 80 * 1024);
|
|
defer gpa.free(big);
|
|
@memset(big, 'x');
|
|
const pairs = [_]bson.Pair{
|
|
.{ .key = "_id", .value = .{ .int32 = 1 } },
|
|
.{ .key = "blob", .value = .{ .binary = .{ .subtype = 0, .data = big } } },
|
|
};
|
|
var out: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer out.deinit(gpa);
|
|
try bson.write_doc(&pairs, gpa, &out);
|
|
try log.append_upsert("db", "big", out.items, 1);
|
|
try log.sync();
|
|
|
|
var count: usize = 0;
|
|
const Ctx = struct {
|
|
count: *usize,
|
|
gpa: std.mem.Allocator,
|
|
fn apply(ctx: *anyopaque, _: Record, doc: *bson.Document) anyerror!void {
|
|
const self: *@This() = @ptrCast(@alignCast(ctx));
|
|
try testing.expectEqual(@as(usize, 80 * 1024), doc.get("blob").?.binary.data.len);
|
|
self.count.* += 1;
|
|
doc.deinit();
|
|
self.gpa.destroy(doc);
|
|
}
|
|
};
|
|
var ctx = Ctx{ .count = &count, .gpa = gpa };
|
|
try log.replay(@ptrCast(&ctx), Ctx.apply);
|
|
try testing.expectEqual(@as(usize, 1), count);
|
|
}
|
|
|
|
test "reject corrupt interior block" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = testing.allocator;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
const path = tmp.path;
|
|
|
|
var log = try Log.open(gpa, io, path);
|
|
const doc_bytes = [_]u8{ 0x0E, 0, 0, 0, 0x10, '_', 'i', 'd', 0, 42, 0, 0, 0, 0 };
|
|
try log.append_upsert("db", "c", &doc_bytes, 1);
|
|
try log.append_upsert("db", "c", &doc_bytes, 2); // a second block
|
|
try log.sync();
|
|
log.close();
|
|
|
|
// Corrupt the FIRST block's first record: flip a byte in the hashed
|
|
// region. The first block's payload starts at the file header plus the
|
|
// block header; the record framing puts db\0coll\0 before the doc.
|
|
const corrupt_at = file_header_len + block_header_len + header_len + 5;
|
|
const dir = std.Io.Dir.cwd();
|
|
var f = try dir.openFile(io, path, .{ .mode = .read_write });
|
|
var buf: [128]u8 = undefined;
|
|
const n = try f.readPositionalAll(io, &buf, 0);
|
|
_ = n;
|
|
buf[corrupt_at] ^= 0xFF;
|
|
try f.writePositionalAll(io, buf[0..128], 0);
|
|
f.close(io);
|
|
|
|
var log2 = try Log.open(gpa, io, path);
|
|
defer log2.close();
|
|
defer dir.deleteFile(io, path) catch {};
|
|
var count: usize = 0;
|
|
const Ctx = struct {
|
|
count: *usize,
|
|
gpa: std.mem.Allocator,
|
|
fn apply(ctx: *anyopaque, _: Record, doc: *bson.Document) anyerror!void {
|
|
const self: *@This() = @ptrCast(@alignCast(ctx));
|
|
self.count.* += 1;
|
|
doc.deinit();
|
|
self.gpa.destroy(doc);
|
|
}
|
|
};
|
|
var ctx = Ctx{ .count = &count, .gpa = gpa };
|
|
try testing.expectError(error.InvalidLog, log2.replay(@ptrCast(&ctx), Ctx.apply));
|
|
}
|
|
|
|
test "torn tail truncates cleanly and appends overwrite it" {
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = testing.allocator;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
const path = tmp.path;
|
|
|
|
var log = try Log.open(gpa, io, path);
|
|
const doc_bytes = [_]u8{ 0x0E, 0, 0, 0, 0x10, '_', 'i', 'd', 0, 42, 0, 0, 0, 0 };
|
|
// One sync per record gives two blocks: the first is complete, the
|
|
// second is the one torn by the truncation.
|
|
try log.append_upsert("db", "c", &doc_bytes, 1);
|
|
try log.sync();
|
|
try log.append_upsert("db", "c", &doc_bytes, 2);
|
|
try log.sync();
|
|
log.close();
|
|
|
|
// Cut the file in the middle of the second block: a crash mid-append.
|
|
const dir = std.Io.Dir.cwd();
|
|
var f = try dir.openFile(io, path, .{ .mode = .read_write });
|
|
const full_len = try f.length(io);
|
|
try f.setLength(io, full_len - 10);
|
|
f.close(io);
|
|
|
|
var log2 = try Log.open(gpa, io, path);
|
|
defer log2.close();
|
|
defer dir.deleteFile(io, path) catch {};
|
|
var seen: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer seen.deinit(gpa);
|
|
const Ctx = struct {
|
|
seen: *std.ArrayListUnmanaged(u8),
|
|
gpa: std.mem.Allocator,
|
|
fn apply(ctx: *anyopaque, record: Record, doc: *bson.Document) anyerror!void {
|
|
const self: *@This() = @ptrCast(@alignCast(ctx));
|
|
try self.seen.append(self.gpa, @intCast(record.seq));
|
|
doc.deinit();
|
|
self.gpa.destroy(doc);
|
|
}
|
|
};
|
|
var ctx = Ctx{ .seen = &seen, .gpa = gpa };
|
|
try log2.replay(@ptrCast(&ctx), Ctx.apply);
|
|
try testing.expectEqualSlices(u8, &[_]u8{1}, seen.items);
|
|
|
|
// A new append overwrites from the replay end and replays cleanly.
|
|
try log2.append_upsert("db", "c", &doc_bytes, 3);
|
|
try log2.sync();
|
|
seen.clearRetainingCapacity();
|
|
var log3 = try Log.open(gpa, io, path);
|
|
defer log3.close();
|
|
ctx.seen = &seen;
|
|
try log3.replay(@ptrCast(&ctx), Ctx.apply);
|
|
try testing.expectEqualSlices(u8, &[_]u8{ 1, 3 }, seen.items);
|
|
}
|
|
|
|
test "Log.create discards a leftover file; Log.open keeps it" {
|
|
// Compaction reuses one tmp path, so a crashed or retried rewrite can leave
|
|
// a *longer* file there. `open` only rewinds end_pos to the header, so the
|
|
// predecessor's trailing blocks would survive past the new content -- and
|
|
// they are intact and hash-correct, so replay applies them as live records
|
|
// once the rename publishes the file. `create` is what prevents that.
|
|
var threaded: std.Io.Threaded = .init_single_threaded;
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = testing.allocator;
|
|
|
|
var tmp = try TmpLog.init(gpa);
|
|
defer tmp.deinit(gpa);
|
|
const path = tmp.path;
|
|
const doc_bytes = [_]u8{ 0x0E, 0, 0, 0, 0x10, '_', 'i', 'd', 0, 42, 0, 0, 0, 0 };
|
|
|
|
// Stand in for the abandoned rewrite: three records, synced, then closed.
|
|
{
|
|
var old = try Log.open(gpa, io, path);
|
|
defer old.close();
|
|
try old.append_upsert("db", "c", &doc_bytes, 1);
|
|
try old.append_upsert("db", "c", &doc_bytes, 2);
|
|
try old.append_upsert("db", "c", &doc_bytes, 3);
|
|
try old.sync();
|
|
try testing.expect(try old.file.length(io) > file_header_len);
|
|
}
|
|
|
|
const Ctx = struct {
|
|
seen: *std.ArrayListUnmanaged(u8),
|
|
gpa: std.mem.Allocator,
|
|
fn apply(ctx: *anyopaque, record: Record, doc: *bson.Document) anyerror!void {
|
|
const self: *@This() = @ptrCast(@alignCast(ctx));
|
|
try self.seen.append(self.gpa, @intCast(record.seq));
|
|
doc.deinit();
|
|
self.gpa.destroy(doc);
|
|
}
|
|
};
|
|
var seen: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer seen.deinit(gpa);
|
|
var ctx = Ctx{ .seen = &seen, .gpa = gpa };
|
|
|
|
// `open` keeps the leftover bytes: this is the hazard being guarded against.
|
|
{
|
|
var kept = try Log.open(gpa, io, path);
|
|
defer kept.close();
|
|
try testing.expect(try kept.file.length(io) > file_header_len);
|
|
try kept.replay(@ptrCast(&ctx), Ctx.apply);
|
|
try testing.expectEqualSlices(u8, &[_]u8{ 1, 2, 3 }, seen.items);
|
|
}
|
|
|
|
// `create` leaves an empty log: nothing past the header, on disk or in the
|
|
// append position, so no stale record can be replayed.
|
|
{
|
|
var fresh = try Log.create(gpa, io, path);
|
|
defer fresh.close();
|
|
try testing.expectEqual(@as(u64, file_header_len), try fresh.file.length(io));
|
|
try testing.expectEqual(@as(u64, file_header_len), fresh.end_pos);
|
|
|
|
// One short record where three used to be: replay must see only it,
|
|
// proving the old tail is gone rather than merely skipped.
|
|
try fresh.append_upsert("db", "c", &doc_bytes, 9);
|
|
try fresh.sync();
|
|
try testing.expectEqual(try fresh.file.length(io), fresh.end_pos);
|
|
}
|
|
seen.clearRetainingCapacity();
|
|
var reopened = try Log.open(gpa, io, path);
|
|
defer reopened.close();
|
|
try reopened.replay(@ptrCast(&ctx), Ctx.apply);
|
|
try testing.expectEqualSlices(u8, &[_]u8{9}, seen.items);
|
|
}
|