storage: block-framed LZ4-compressed log (roadmap item 3)

The log is now a 16-byte file header (magic, version, codec, block
target) plus a sequence of blocks. Each block keeps the pre-existing
record framing unchanged, so Engine.apply_record does not change; records
never straddle blocks (appends accumulate in memory and the block seals
at ~256 KiB). The block header's integrity hash covers the stored payload
bytes exactly as they sit on disk, so the decompressor only ever sees
input already proven intact. Torn tails stay distinguishable from
interior corruption exactly as before: a short read, an impossible
length, or a hash mismatch in the final block truncates cleanly (later
appends overwrite the garbage); a hash mismatch anywhere else is
error.InvalidLog.

The codec is a hand-rolled LZ4 block compressor/decompressor (~1.7 GB/s
measured) with a per-block codec byte falling back to raw when
compression does not help; the header keeps raw legal so zstd can be
swapped in later. Zig 0.16 ships zstd decompression only, and deflate
would cap writes below the insert rate.

Engine.compact goes through the same Log API (deferred sync, one commit)
and compresses for free; sync() seals the pending block before fsyncing,
so the acknowledged-write durability semantics are unchanged (an
unsealed block holds only unacknowledged batch records).

Measured (tests/e2e/results/phase4.txt): db on disk 1025 -> 97 MB, now
smaller than MongoDB's own compressed files; bulk insert 816 -> 722 MB/s
(the accepted compression cost); reopen unchanged at 0.8 s.

Verified: unit suite in all three optimize modes (new LZ4 round-trip,
corrupt-block, and torn-tail truncation tests), the crash pair, e2e6
(kill -9 mid-write), and two full benchmark runs.
This commit is contained in:
2026-08-02 21:35:22 +03:00
parent 58914a69c3
commit b4585106f1
4 changed files with 629 additions and 117 deletions

View File

@@ -51,12 +51,14 @@ mongosh --port 27017
over-approximates is merely slow, never wrong. over-approximates is merely slow, never wrong.
- **Update operators**: `$set` `$unset` `$inc` `$push` (`$each`) `$pull` - **Update operators**: `$set` `$unset` `$inc` `$push` (`$each`) `$pull`
`$rename`, with dot-path creation (including array indices). `$rename`, with dot-path creation (including array indices).
- **Storage**: append-only record log (CRC32-checked, `fsync` per write, - **Storage**: append-only record log, LZ4-compressed in 256 KiB blocks
torn-tail tolerant) with in-memory indexes rebuilt on open and automatic (XxHash3-checked, `fsync` per write, torn-tail tolerant: a crash
compaction (rewrite + atomic rename when the log grows past mid-append truncates cleanly, interior corruption is rejected) with
`--compact-threshold`, default 16 MB). Killed mid-write (`kill -9`), the in-memory indexes rebuilt on open and automatic compaction (rewrite +
database recovers all committed writes; the log and compaction both work atomic rename when the log grows past `--compact-threshold`, default
with relative or absolute `--db` paths. Records up to the announced 16 MB 16 MB). Killed mid-write (`kill -9`), the database recovers all
committed writes; the log and compaction both work with relative or
absolute `--db` paths. Records up to the announced 16 MB
`maxBsonObjectSize` replay correctly. `maxBsonObjectSize` replay correctly.
- **Concurrency**: a writer-preferring read/write lock splits command - **Concurrency**: a writer-preferring read/write lock splits command
execution — reads (`find`, `count`, `aggregate`, `list*`) run concurrently execution — reads (`find`, `count`, `aggregate`, `list*`) run concurrently
@@ -185,49 +187,48 @@ With the ReleaseFast default build (MongoDB 8.3.7 on the same Mac):
| benchmark | mongo-lite | mongodb | winner | | benchmark | mongo-lite | mongodb | winner |
|---|---|---|---| |---|---|---|---|
| insertOne (sequential) | 0.19 ms | 4.1 ms | **mongo-lite ×22** | | insertOne (sequential) | 0.17 ms | 4.2 ms | **mongo-lite ×25** |
| bulk insert (insertMany) | 810 MB/s | 714 MB/s | **mongo-lite ×1.1** | | bulk insert (insertMany) | 722 MB/s | 824 MB/s | mongodb ×1.1 |
| createIndex({k: 1}) | 51 ms | 82 ms | **mongo-lite** | | createIndex({k: 1}) | 54 ms | 85 ms | **mongo-lite** |
| countDocuments({}) | 1.5 ms | 13.8 ms | **mongo-lite ×9** | | countDocuments({}) | 1.5 ms | 11.5 ms | **mongo-lite ×7** |
| findOne({_id}) | 0.57 ms | 0.67 ms | mongo-lite | | findOne({_id}) | 0.57 ms | 0.50 ms | mongodb |
| findOne indexed | 0.58 ms | 1.5 ms | **mongo-lite ×2.6** | | findOne indexed | 0.77 ms | 0.86 ms | mongo-lite |
| range-scan count | 22 ms | 13 ms | mongodb ×1.7 | | range-scan count | 22.5 ms | 14.5 ms | mongodb ×1.6 |
| sort + limit(20), on `_id` | 2.4 ms | 2.2 ms | mongodb ×1.1 | | sort + limit(20), on `_id` | 2.5 ms | 2.3 ms | mongodb ×1.1 |
| sort + limit(20), indexed field | 1.0 ms | — | — | | sort + limit(20), indexed field | 1.0 ms | — | — |
| aggregate $group | 11.5 ms | 15.5 ms | **mongo-lite** | | aggregate $group | 10.2 ms | 15.4 ms | **mongo-lite** |
| updateOne({_id}) | 0.17 ms | 0.19 ms | mongo-lite | | updateOne({_id}) | 0.13 ms | 0.22 ms | **mongo-lite** |
| updateMany (65 docs) | 1.6 ms | 6.4 ms | **mongo-lite ×4** | | updateMany (65 docs) | 2.4 ms | 5.8 ms | **mongo-lite ×2.4** |
| deleteOne + insert | 0.62 ms | 4.8 ms | **mongo-lite ×8** | | deleteOne + insert | 0.64 ms | 3.9 ms | **mongo-lite ×6** |
| server RSS | 2.0 GB | 1.5 GB | mongodb (×0.7) | | server RSS | 2.0 GB | 1.6 GB | mongodb (×0.8) |
| kill -9 → reopen | 0.8 s | 1.3 s | **mongo-lite** | | kill -9 → reopen | 0.8 s | 1.3 s | **mongo-lite** |
| db on disk | 1.0 GB | 96 MB | mongodb (compressed) | | db on disk | 97 MB | 104 MB | **mongo-lite** |
The remaining losses are structural rather than incidental. Disk size is The log is now LZ4-compressed in 256 KiB blocks, so the on-disk size is
the big one: payloads are stored raw, so the log is 11x MongoDB's on par with MongoDB's compressed files. The remaining losses are
compressed files. RSS trails because every document carries its own arena. structural rather than incidental. RSS trails because every document
The range-scan gap is not the matcher — it is walking 65,536 documents carries its own arena and a second full copy as a `Pair` tree; the
that each live in a separate allocation, one pointer chase apiece. range-scan gap is not the matcher — it is walking 65,536 documents that
each live in a separate allocation, one pointer chase apiece. And bulk
insert is compress-bound (the LZ4 codec runs at ~1.7 GB/s; deflate would
cap writes below the insert rate, which is why the roadmap chose LZ4).
Reproduce the table with `bash tests/e2e/compare-run.sh 1g 16k`; the Reproduce the table with `bash tests/e2e/compare-run.sh 1g 16k`; the
pre-tree baseline is recorded in `tests/e2e/results/phase1.txt`, and the pre-tree baseline is recorded in `tests/e2e/results/phase1.txt`, and the
runs with the B+tree and ordered `_id` index (roadmap items 1 and 2) in runs with the B+tree, ordered `_id` index and compressed log (roadmap
`tests/e2e/results/phase2.txt` and `tests/e2e/results/phase3.txt`. items 13) in `tests/e2e/results/phase2.txt`, `phase3.txt` and
`phase4.txt`.
### What is left (highest impact first) ### What is left (highest impact first)
Each is written up with its design decisions, ordering constraints and Each is written up with its design decisions, ordering constraints and
traps in [ROADMAP.md](ROADMAP.md). traps in [ROADMAP.md](ROADMAP.md).
1. **Compress the log** — the largest remaining gap (×11). Payloads are 1. **Stop giving every document its own arena** — the source of both the
stored raw. A block-framed format with an LZ4 block codec would shrink
highly compressible workloads massively; note Zig 0.16 ships zstd
decompression only, and deflate would cap writes below the current
insert rate.
2. **Stop giving every document its own arena** — the source of both the
RSS gap and the range-scan gap. Storing canonical BSON bytes in a RSS gap and the range-scan gap. Storing canonical BSON bytes in a
per-collection slab and matching against them (parsing only the fields a per-collection slab and matching against them (parsing only the fields a
filter names) makes scans contiguous instead of a pointer chase. filter names) makes scans contiguous instead of a pointer chase.
3. **Decompose the global lock** — one reader/writer lock covers the whole 2. **Decompose the global lock** — one reader/writer lock covers the whole
engine and is held across fsync, compaction and reply construction. engine and is held across fsync, compaction and reply construction.
Per-collection locks plus cross-connection group commit are the path to Per-collection locks plus cross-connection group commit are the path to
using more than one core on writes. using more than one core on writes.
@@ -260,6 +261,15 @@ Done so far, with the measurement that drove each:
`_id` point lookups, `$in` and ranges hit the tree instead of a full `_id` point lookups, `$in` and ranges hit the tree instead of a full
scan. `sort({_id: ...})` is now an index-ordered scan with an early stop: scan. `sort({_id: ...})` is now an index-ordered scan with an early stop:
`sort+limit(20)` 6.2 → 2.4 ms (parity with MongoDB). `sort+limit(20)` 6.2 → 2.4 ms (parity with MongoDB).
- **A block-framed, LZ4-compressed log** (roadmap item 3): a file header
plus ~256 KiB blocks, each holding the existing record framing with the
integrity hash covering the stored bytes (so the decompressor only ever
sees input already proven intact). Records never straddle blocks; a
short read, impossible length or hash mismatch in the final block is a
torn tail (truncate cleanly), anywhere else is corruption. The hand-rolled
LZ4 codec runs at ~1.7 GB/s and falls back to raw per block when
compression does not help. `db on disk` 1025 → 97 MB — now smaller than
MongoDB's own compressed files.
- **Entry removal is a binary search**, not a scan of the whole index. - **Entry removal is a binary search**, not a scan of the whole index.
`updateMany` 15.4 → 5.5 ms. `updateMany` 15.4 → 5.5 ms.
- **Top-k sort selection** and an allocation-free decorate pass, plus - **Top-k sort selection** and an allocation-free decorate pass, plus

View File

@@ -1,10 +1,12 @@
# Remaining performance work # Remaining performance work
Status: **items 1 (B+tree over the encoded keys) and 2 (ordered `_id` index) Status: **items 1 (B+tree), 2 (ordered `_id` index) and 3 (block-framed
are done** — landed and verified in `tests/e2e/results/phase2.txt` and compressed log) are done** — verified in `tests/e2e/results/phase2.txt`
`phase3.txt` (updateMany 17.3 → 1.6 ms, createIndex 62 → 51 ms, `_id` through `phase4.txt`: updateMany 17.3 → 1.6 ms, createIndex 62 → 51 ms,
sort+limit 6.2 → 2.4 ms). Their dependents (items 4) now stand on a tree `_id` sort+limit 6.2 → 2.4 ms, and `db on disk` 1025 → 97 MB (smaller
instead of a sorted array. Items below, in dependency order. than MongoDB's own compressed files; bulk insert 816 → 722 MB/s, the
accepted compression cost). Item 4's dependent (item 1) now stands on a
tree instead of a sorted array. Items below, in dependency order.
Each is sized to be landed and verified on Each is sized to be landed and verified on
its own; the ordering constraints between them are the load-bearing part, so its own; the ordering constraints between them are the load-bearing part, so
read those before picking one up. read those before picking one up.
@@ -154,7 +156,27 @@ log surfaces are untouched; verified with `e2e3.js` unchanged.)
--- ---
## 3. Block-framed compressed log ## 3. Block-framed compressed log — DONE
Landed in `src/storage.zig`: a 16-byte file header (magic, version, codec,
block target) plus a sequence of 16-byte-header blocks, each holding the
pre-existing record framing unchanged (`Engine.apply_record` untouched),
with the integrity hash covering the stored payload bytes so the
decompressor only ever sees input already proven intact. ~256 KiB target;
records never straddle blocks (appends accumulate in memory and the block
seals when the next record would push it past the target). A short read,
an impossible length, or a hash mismatch in the final block truncates
cleanly; a mismatch elsewhere is `error.InvalidLog`. A hand-rolled LZ4
block codec (~1.7 GB/s measured) with a per-block codec byte falling back
to raw when compression does not help. `Engine.compact` goes through the
same `Log` API (deferred sync, one commit) and compresses for free.
Recorded deltas vs `tests/e2e/results/phase3.txt`: `db on disk` 1025 →
97 MB (now smaller than MongoDB's own 104 MB); bulk insert 816 → 722 MB/s
(the compression cost, accepted per the codec note below); reopen 0.8 s
unchanged. Verified with `zig build test` in all three modes (new LZ4
round-trip, corrupt-block and torn-tail tests), the crash pair, e2e6
(kill -9 mid-write), and two full benchmark runs.
**Why.** The largest remaining gap: 1.0 GB on disk against MongoDB's 93 MB, **Why.** The largest remaining gap: 1.0 GB on disk against MongoDB's 93 MB,
because payloads are stored raw. Breaking the format is fine. because payloads are stored raw. Breaking the format is fine.

View File

@@ -1,13 +1,41 @@
//! Append-only record log. Each record is: //! 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 //! [0..4) u32 len — total record bytes
//! [4..12) u64 hash over bytes [12..len) //! [4..12) u64 hash over bytes [12..len)
//! [12..20) u64 seq //! [12..20) u64 seq
//! [20] u8 type //! [20] u8 type
//! [21..24) reserved //! [21..24) reserved
//! [24..) db\0 coll\0 bson doc //! [24..) db\0 coll\0 bson doc
//! All integers little-endian. Reads and writes are positional, so the fd //! All integers little-endian. Records never straddle blocks: appends
//! offset never matters. A torn tail record (crash mid-append) is detected //! accumulate into an in-memory block that is sealed (compressed, written,
//! during replay and skipped. //! 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 //! 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 //! thing — did these bytes survive intact — but std.hash.Crc32 is the
@@ -23,9 +51,22 @@ pub const record_type_delete: u8 = 2;
pub const record_type_index_create: u8 = 3; pub const record_type_index_create: u8 = 3;
pub const record_type_index_drop: u8 = 4; pub const record_type_index_drop: u8 = 4;
pub const header_len: usize = 24; // len + hash + seq + type + reserved 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
/// Integrity hash over a record's bytes after the length and hash fields. 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 { fn record_hash(bytes: []const u8) u64 {
return std.hash.XxHash3.hash(0, bytes); return std.hash.XxHash3.hash(0, bytes);
} }
@@ -34,6 +75,10 @@ fn record_hash(bytes: []const u8) u64 {
/// announced maxBsonObjectSize with room for names and header. /// announced maxBsonObjectSize with room for names and header.
pub const max_record_payload: usize = 16 * 1024 * 1024 + 64 * 1024; 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 { pub const Record = struct {
seq: u64, seq: u64,
type: u8, type: u8,
@@ -42,7 +87,8 @@ pub const Record = struct {
}; };
pub const Error = error{ pub const Error = error{
InvalidLog, // corrupt interior record (bad hash or impossible length) 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 /// Callback receives transient slices and a heap-allocated, freshly parsed
@@ -56,10 +102,17 @@ pub const Log = struct {
path: []const u8, path: []const u8,
end_pos: u64, end_pos: u64,
log_bytes: u64, // bytes written since the log was last rewritten log_bytes: u64, // bytes written since the log was last rewritten
codec: u8,
// Reused record-framing buffer. Appends are single-writer (the engine's // Reused record-framing buffer. Appends are single-writer (the engine's
// exclusive lock), so one buffer avoids a realloc cycle per record. // exclusive lock), so one buffer avoids a realloc cycle per record.
scratch: std.ArrayListUnmanaged(u8), scratch: std.ArrayListUnmanaged(u8),
/// Group commit: while set, appends skip the per-record fsync and the /// 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,
/// Group commit: while set, appends skip the per-block fsync and the
/// caller issues one sync for the whole batch (see Engine.begin_batch / /// caller issues one sync for the whole batch (see Engine.begin_batch /
/// end_batch). Every acknowledged write is still fsynced before the /// end_batch). Every acknowledged write is still fsynced before the
/// reply, so the crash guarantees are unchanged. /// reply, so the crash guarantees are unchanged.
@@ -81,19 +134,56 @@ pub const Log = struct {
error.FileNotFound => try dir.createFile(io, abs_path, .{ .read = true }), error.FileNotFound => try dir.createFile(io, abs_path, .{ .read = true }),
else => return err, else => return err,
}; };
return .{
var self: Log = .{
.gpa = gpa, .gpa = gpa,
.io = io, .io = io,
.file = file, .file = file,
.path = abs_path, .path = abs_path,
.end_pos = 0, .end_pos = file_header_len,
.log_bytes = 0, .log_bytes = 0,
.codec = codec_lz4,
.scratch = .empty, .scratch = .empty,
.block = .empty,
.compressed = .empty,
.lz4_table = undefined,
.defer_sync = false, .defer_sync = false,
}; };
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 { 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.scratch.deinit(self.gpa);
self.file.close(self.io); self.file.close(self.io);
self.gpa.free(self.path); self.gpa.free(self.path);
@@ -101,44 +191,83 @@ pub const Log = struct {
/// Replay all valid records from the beginning of the file. /// Replay all valid records from the beginning of the file.
pub fn replay(self: *Log, ctx: *anyopaque, callback: ReplayFn) !void { pub fn replay(self: *Log, ctx: *anyopaque, callback: ReplayFn) !void {
var chunk: [64 * 1024]u8 = undefined; var decomp: std.ArrayListUnmanaged(u8) = .empty;
var heap_buf: []u8 = &.{}; defer decomp.deinit(self.gpa);
defer if (heap_buf.len > 0) self.gpa.free(heap_buf); var pos: u64 = file_header_len;
var pos: u64 = 0;
while (true) { while (true) {
const len_read = self.file.readPositionalAll(self.io, chunk[0..4], pos) catch |err| { var hdr: [block_header_len]u8 = undefined;
const n = self.file.readPositionalAll(self.io, &hdr, pos) catch |err| {
std.debug.print("mongo-lite: log read error at {d}: {s}\n", .{ pos, @errorName(err) }); std.debug.print("mongo-lite: log read error at {d}: {s}\n", .{ pos, @errorName(err) });
return error.InvalidLog; return error.InvalidLog;
}; };
if (len_read == 0) return; // clean end if (n == 0) return; // clean end
if (len_read < 4) return; // torn tail if (n < block_header_len) return; // torn tail: partial block header
const total: u32 = std.mem.readInt(u32, chunk[0..4], .little); 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("mongo-lite: 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;
std.debug.print("mongo-lite: corrupt block hash at {d}\n", .{pos});
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("mongo-lite: 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) { if (total < header_len) {
std.debug.print("mongo-lite: corrupt record length {d} at {d}\n", .{ total, pos }); std.debug.print("mongo-lite: corrupt record length {d} at {d}\n", .{ total, pos });
return error.InvalidLog; return error.InvalidLog;
} }
const payload_len: usize = total - 4; const payload_len: usize = total - 4;
// Documents up to maxBsonObjectSize are legal; anything larger is
// corruption. Covers a hostile length prefix from a truncated file.
if (payload_len > max_record_payload) { if (payload_len > max_record_payload) {
std.debug.print("mongo-lite: record too large at {d}\n", .{pos}); std.debug.print("mongo-lite: record too large at {d}\n", .{pos});
return error.InvalidLog; return error.InvalidLog;
} }
const payload = if (payload_len <= chunk.len) chunk[0..payload_len] else blk: { if (bytes.len < total) return error.InvalidLog; // record crosses the block end
if (heap_buf.len < payload_len) { const payload = bytes[4..total];
if (heap_buf.len > 0) self.gpa.free(heap_buf);
heap_buf = try self.gpa.alloc(u8, payload_len);
}
break :blk heap_buf[0..payload_len];
};
const payload_read = self.file.readPositionalAll(self.io, payload, pos + 4) catch return error.InvalidLog;
if (payload_read < payload_len) return; // torn tail — crash during append
// Offsets here are relative to `payload`, which starts at the
// record's byte 4 — so payload[0..8) is the record's hash field,
// and header_len - 4 is where db\0coll\0 begins.
const hash_stored: u64 = std.mem.readInt(u64, payload[0..8], .little); const hash_stored: u64 = std.mem.readInt(u64, payload[0..8], .little);
const hash_actual = record_hash(payload[8..payload_len]); const hash_actual = record_hash(payload[8..]);
if (hash_stored != hash_actual) return error.InvalidLog; if (hash_stored != hash_actual) return error.InvalidLog;
var idx: usize = header_len - 4; var idx: usize = header_len - 4;
@@ -161,10 +290,7 @@ pub const Log = struct {
.db = db, .db = db,
.coll = coll, .coll = coll,
}, doc); }, doc);
return total;
pos += total;
self.end_pos = pos;
}
} }
pub fn append_upsert(self: *Log, db: []const u8, coll: []const u8, doc: []const u8, seq: u64) !void { pub fn append_upsert(self: *Log, db: []const u8, coll: []const u8, doc: []const u8, seq: u64) !void {
@@ -204,15 +330,57 @@ pub const Log = struct {
const total: u32 = @intCast(buf.items.len); const total: u32 = @intCast(buf.items.len);
std.mem.writeInt(u32, buf.items[0..4], total, .little); std.mem.writeInt(u32, buf.items[0..4], total, .little);
std.mem.writeInt(u64, buf.items[4..12], record_hash(buf.items[12..]), .little); std.mem.writeInt(u64, buf.items[4..12], record_hash(buf.items[12..]), .little);
try self.file.writePositionalAll(self.io, buf.items, self.end_pos);
self.end_pos += buf.items.len; // Seal the current block when the next record would push it past the
self.log_bytes += buf.items.len; // target; a single oversized record keeps its own block.
if (!self.defer_sync) try self.file.sync(self.io); 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);
if (!self.defer_sync) {
// Durable before the reply: seal this block and fsync.
try self.seal_block();
try self.file.sync(self.io);
}
} }
/// One fsync for the whole deferred batch. Callers must have set /// Compress and write the current block, then reset it. No-op when it is
/// defer_sync, appended, and cleared defer_sync again before the reply. /// 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;
}
/// One fsync for the whole deferred batch, sealing the current block
/// first. Callers must have set defer_sync, appended, and cleared
/// defer_sync again before the reply.
pub fn sync(self: *Log) !void { pub fn sync(self: *Log) !void {
try self.seal_block();
try self.file.sync(self.io); try self.file.sync(self.io);
} }
@@ -225,6 +393,182 @@ pub const Log = struct {
} }
}; };
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 // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -249,6 +593,44 @@ pub const TmpLog = struct {
} }
}; };
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" { test "append, replay, torn tail" {
var threaded: std.Io.Threaded = .init_single_threaded; var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit(); defer threaded.deinit();
@@ -285,7 +667,6 @@ test "append, replay, torn tail" {
try log.replay(@ptrCast(&ctx), Ctx.apply); try log.replay(@ptrCast(&ctx), Ctx.apply);
try testing.expectEqualSlices(u8, &[_]u8{ record_type_upsert, 1, record_type_delete, 2 }, seen.items); try testing.expectEqualSlices(u8, &[_]u8{ record_type_upsert, 1, record_type_delete, 2 }, seen.items);
try testing.expectEqual(@as(u64, log.end_pos), log.end_pos);
} }
test "record larger than the read chunk replays" { test "record larger than the read chunk replays" {
@@ -299,7 +680,8 @@ test "record larger than the read chunk replays" {
var log = try Log.open(gpa, io, tmp.path); var log = try Log.open(gpa, io, tmp.path);
defer log.close(); defer log.close();
// Build a doc whose payload pushes the record past the 64 KiB stack chunk. // 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); const big = try gpa.alloc(u8, 80 * 1024);
defer gpa.free(big); defer gpa.free(big);
@memset(big, 'x'); @memset(big, 'x');
@@ -329,7 +711,7 @@ test "record larger than the read chunk replays" {
try testing.expectEqual(@as(usize, 1), count); try testing.expectEqual(@as(usize, 1), count);
} }
test "reject corrupt interior record" { test "reject corrupt interior block" {
var threaded: std.Io.Threaded = .init_single_threaded; var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit(); defer threaded.deinit();
const io = threaded.io(); const io = threaded.io();
@@ -342,19 +724,20 @@ test "reject corrupt interior record" {
var log = try Log.open(gpa, io, 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 }; 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, 1);
try log.append_upsert("db", "c", &doc_bytes, 2); // a second block
log.close(); log.close();
// Corrupt the file: flip a byte in the middle of the record. The record // Corrupt the FIRST block's first record: flip a byte in the hashed
// is header_len ++ "db\0" ++ "c\0" ++ doc, so this lands on the first // region. The first block's payload starts at the file header plus the
// doc byte — inside the hashed range, and past every framing field. // block header; the record framing puts db\0coll\0 before the doc.
const corrupt_at = header_len + 5; const corrupt_at = file_header_len + block_header_len + header_len + 5;
const dir = std.Io.Dir.cwd(); const dir = std.Io.Dir.cwd();
var f = try dir.openFile(io, path, .{ .mode = .read_write }); var f = try dir.openFile(io, path, .{ .mode = .read_write });
var buf: [64]u8 = undefined; var buf: [128]u8 = undefined;
const n = try f.readPositionalAll(io, &buf, 0); const n = try f.readPositionalAll(io, &buf, 0);
_ = n; _ = n;
buf[corrupt_at] ^= 0xFF; buf[corrupt_at] ^= 0xFF;
try f.writePositionalAll(io, buf[0..64], 0); try f.writePositionalAll(io, buf[0..128], 0);
f.close(io); f.close(io);
var log2 = try Log.open(gpa, io, path); var log2 = try Log.open(gpa, io, path);
@@ -374,3 +757,55 @@ test "reject corrupt interior record" {
var ctx = Ctx{ .count = &count, .gpa = gpa }; var ctx = Ctx{ .count = &count, .gpa = gpa };
try testing.expectError(error.InvalidLog, log2.replay(@ptrCast(&ctx), Ctx.apply)); 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 };
try log.append_upsert("db", "c", &doc_bytes, 1);
try log.append_upsert("db", "c", &doc_bytes, 2);
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);
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);
}

View File

@@ -0,0 +1,45 @@
# Phase 4 gate — mongo-lite vs MongoDB 8.3.7, 1g dataset / ~16k docs
# Ratio < 1.0 = mongo-lite faster. Reproduce: bash tests/e2e/compare-run.sh 1g 16k
# This run includes roadmap items 1 (B+tree), 2 (ordered _id index) and
# 3 (block-framed LZ4-compressed log).
# Compare: tests/e2e/results/phase1.txt (pre-tree baseline).
benchmark mongo-lite mongodb ratio
insertOne (sequential) ×200 0.17 ms 4.2 ms 0.0x
bulk insert throughput 722.1 MB/s 824.1 MB/s 0.9x
docs loaded 65,536 65,536 1.0x
createIndex({k: 1}) 53.9 ms 84.8 ms 0.6x
countDocuments({}) 1.5 ms 11.5 ms 0.1x
findOne({_id: <ObjectId>}) 0.57 ms 0.50 ms 1.1x
findOne({k: 500}) (indexed) 0.77 ms 0.86 ms 0.9x
find({p: {$gte,$lt}}).count() (scan) 22.5 ms 14.5 ms 1.6x
find({}).sort({_id:-1}).limit(20) 2.5 ms 2.3 ms 1.1x
find({}, {proj}).limit(1000) 4.1 ms 4.4 ms 0.9x
aggregate $group by k 10.2 ms 15.4 ms 0.7x
updateOne({_id}) ×50 0.13 ms 0.22 ms 0.6x
updateMany({k: 7}, {$inc}) 2.4 ms 5.8 ms 0.4x
deleteOne({_id}) + insertOne 0.64 ms 3.9 ms 0.2x
node client RSS 160 MB 154 MB 1.0x
server RSS 1979 MB 1561 MB
kill -9 reopen 0.8s 1.3s
db on disk 97MB 104MB
# Item 3 (block-framed LZ4-compressed log) deltas vs phase3:
# db on disk 1025 -> 97 MB (11x smaller; now smaller than MongoDB's
# own 104 MB). The log is a file header plus ~256 KiB
# blocks; each block holds the existing record framing
# (Engine.apply_record unchanged) with the integrity hash
# covering the stored bytes, so the decompressor only sees
# input already proven intact. Torn tails (short read,
# impossible length, or hash mismatch in the final block)
# truncate cleanly; hash mismatches elsewhere are
# InvalidLog. Raw blocks remain legal (per-block codec
# byte) when compression does not help.
# bulk insert 816 -> 722 MB/s (0.9x of MongoDB): the compression cost,
# accepted per the roadmap (LZ4 chosen precisely because
# deflate would cap writes below the insert rate).
# reopen 0.8 s (unchanged; decompression of 97 MB is fast).
#
# Remaining gaps and where they are addressed:
# server RSS 1.3x -> Phase 4 (per-document arena -> byte storage)
# range-scan 1.6x -> Phase 4 (contiguous byte storage, not the matcher)