Files
MultiforaDB/src/pager.zig
A.Shakhmatov a748a3d08c db/pager/tests: cleanup pass over the free list
No behaviour is meant to change and the gate confirms it: 1.94x / 679.2 MB
reclaimed / 9 rebuilds and 2.46x / 934.0 MB / 13 on the two 16 KiB lines,
0.0 MB on the 200-byte line, all identical to the numbers recorded for them.

Deduplication. `pages_for` was written in db.zig and again in pager.zig and
twice more inline; there is now one, public, and the two pre-existing copies
call it. `pages_per_map_align` replaces three hand-rolled `map_align /
page_size`. `SlabRun.window_first` was a stored field that could never legally
disagree with `first` and was maintained by hand at two sites -- now a method.
`keep_piece` re-derived `SlabRun.window_count` character for character; it
calls it. `insert_run` scanned linearly for a position `run_of` binary-searches
for, which made loading a fragmented catalog quadratic; both now go through one
`run_lower_bound`. Freeing a run's window map was written four times; one
helper. The 20% rebuild share was stated in `note_compact` and again in
`wants_rebuild`, with a comment arguing at length that they must be the same
number -- `worth_rewriting` makes that structural.

Efficiency. The identity assert in `reclaim_windows` called `dead_located()`,
an O(every window) walk, and `assert_msg` is live in ReleaseFast -- so it
doubled the scan the reclamation was about to make (2.75 MB streamed twice per
reclaiming checkpoint at the 21 GB the gate targets). `dead_located` is now a
maintained counter, the check is O(1) in every build, and the scan cross-checks
it while it is there. `SlabRun.full` lets a run with nothing to give be copied
without its counters being read at all, so the common case is O(runs) rather
than O(windows).

The pager's two allocation policies were hand-copying the claim step, and the
copy had already lost two of the three preconditions -- `alloc_slab_run` never
checked `pages <= reserved_pages`. Both now go through `claim_locked`.

`reclaimed_bytes` moves from Collection to Engine, beside `compactions`, which
is how it is read and the only place it can be honest: a life-of-the-process
total must not lose a dropped collection's share. Both join `Counters`, so
`slab_stats` stops opening `counter_lock` by hand.

The ownership assertion in `write_catalog` was gated on `is_test or Debug`, a
predicate nothing else in the codebase uses, which left the one silent failure
this design can produce unchecked in ReleaseSafe. It is now `!= ReleaseFast`,
the line `protect_stable` already draws. Measured: no change to the suite's
runtime.

Altitude. `note_checkpoint` was called from exactly one place, the tail of
`upsert` -- so a delete armed no checkpoint by any route, which is why
reclamation only ever ran when the *rebuild* trigger fired and the rebuild then
reset the window map it would have used. `remove` and the TTL sweep arm one
now, next to the `note_compact` calls that were added for the same omission a
milestone ago. `compact`'s leading checkpoint stays, demoted in its comment
from the mechanism to the local ordering it actually guarantees.

serverStatus reports `allocTailBytes`/`freeReadyBytes` instead of page counts,
so the harness stops hard-coding 4096 -- the kind of constant this milestone
was blindsided by once already.

tests/e2e/churn.js: `deleteMany({_id: {$in: [5000 ids]}})` exceeded
`index.max_combos`, so the planner refused the index and every delete became a
full collection scan re-filtering each document against 5000 members. That was
the entire runtime of the harness. One delete spec per id instead: the 40k x
16 KiB gate goes 57 s -> 6 s, and the 150k x 200 B line 483 s -> 3 s, with
identical output. Also: the per-round `countDocuments` is gone (the harness
knows the count), and the server log is a bounded ring rather than a rope that
grows with everything the server ever said.

Reverted from the review: reusing one MongoClient across the startup poll. A
client whose first connect fails tears its topology down and every later
command on it fails identically, so it turns "not up yet" into "never comes
up" -- it broke the first run. The reason is now a comment.

187/187 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, e2e 49, e2e2
concurrent 2 and the crash pair, e2e3 16, e2e4 17, e2e6 72, e2e7 86,
crash-fuzz 60 cycles.
2026-08-09 19:08:15 +03:00

2373 lines
109 KiB
Zig

//! The data file: a checkpoint of the engine's structures, mapped rather than
//! parsed. The log (storage.zig) remains the WAL and the source of truth; this
//! file is a snapshot at some sequence number, and on open the log replays
//! whatever happened after it (PLAN D4).
//!
//! File layout — an array of 4 KiB pages:
//! page 0 header. Written at create, never rewritten.
//! [0..4) u32 magic "MFDB" (0x4D464442)
//! [4] u8 format version (1)
//! [5] u8 page_shift (12)
//! [6..8) u16 reserved (0)
//! [8..24) u128 database uuid — must match the log's, so a data file
//! can never be paired with a foreign log
//! [24..32) u64 xxhash3 over [0..24)
//! [32..4096) zero
//! pages 1,2 watermark slots A and B. Generation N writes slot (N & 1),
//! so a torn write can never leave zero valid slots -- writing
//! a new generation over the only copy of the old one could.
//! The newer slot whose hash validates is authoritative.
//! [0..8) u64 generation (monotonic, >= 1)
//! [8..16) u64 seq -- the log sequence this image covers
//! [16..24) u64 alloc_tail -- pages [0, alloc_tail) are allocated
//! [24..32) u64 file_pages
//! [32..40) u64 catalog_page -- first page of the catalog stream
//! [40..48) u64 catalog_len -- bytes of it
//! [48..56) u64 freelist_page
//! [56..64) u64 freelist_len
//! [64..72) u64 prev_generation -- kept intact for fallback
//! [72..80) u64 live_docs -- cached hint
//! [80..88) u64 dead_bytes -- cached hint; the engine recomputes it
//! from the catalog on open
//! [88..4088) reserved (zero)
//! [4088..4096) u64 xxhash3 over [0..4088)
//! pages 3.. data, handed out by a tail-bump extent allocator
//!
//! The catalog stream and the free list are written *wholesale into freshly
//! allocated pages* at every checkpoint, never mutated in place. That is what
//! makes them untearable: the previous copy stays intact and referenced by the
//! previous watermark until the new watermark switches over.
//!
//! Every persisted reference is a page number (u32) or an absolute file byte
//! offset (u64) — never a pointer — so where the file happens to be mapped is
//! irrelevant. Node pages are raw host memory (D4: on-disk format == in-memory
//! format, no serialization on page-in), which makes the data file
//! little-endian-only; the log, framed field by field, stays portable.
//!
//! PLAN D6.1 called for a region table. It cannot be that: there is one node
//! arena per index and one document slab per collection, so the region count is
//! dynamic and unbounded, and N contiguous regions cannot all grow at the tail.
//! With one page array and extent allocation there is exactly one growth path,
//! therefore exactly one place where the write-then-extend discipline lives,
//! and `ls`/`du` stay honest for the backup story (D6.6).
//!
//! Why this reaches for std.posix instead of std.Io, against the house style:
//! `std.Io.File.MemoryMap` prefaults by default (`populate = true`), which is
//! the opposite of "RSS = working set"; it exposes no NORESERVE, FIXED or
//! address hint, so it cannot express an address-space reservation; and its
//! `setLength` is mremap on Linux and unsupported on darwin, which this project
//! develops on. Everything else here goes through std.Io.File.
const std = @import("std");
const builtin = @import("builtin");
const assert = @import("assert.zig").assert;
const assert_msg = @import("assert.zig").assert_msg;
/// Test-only: record which pages have been written since the last sync, so a
/// test can simulate the one failure a `kill -9` cannot produce -- writeback
/// that never happened.
///
/// This exists because the ordering it guards is the whole crash-recovery
/// argument and is otherwise untestable. Killing a process does not lose
/// page-cache writes, so removing the msync before the watermark leaves every
/// test green while making a real power loss unrecoverable. Compiled out
/// entirely outside tests, so `page_mut` keeps no branch in a real build.
const track_dirty = builtin.is_test;
/// Also make the published image *hardware* read-only, in builds that can
/// afford it.
///
/// The assert in `page_mut` catches a caller that asks for a writable page below
/// the stable mark. It cannot catch one that holds a pointer obtained *before*
/// the mark moved and writes through it afterwards -- and that is the realistic
/// mistake, because tree code holds `*Node` across calls. mprotect catches it,
/// as an immediate segfault at the offending store rather than as a corrupt
/// database discovered after a power loss.
///
/// Off in ReleaseFast so the hot path keeps no extra syscall, on everywhere
/// else. One mprotect per checkpoint over one range is the whole cost.
const protect_stable = builtin.mode != .ReleaseFast;
pub const page_shift: u6 = 12;
pub const page_size: usize = 1 << page_shift;
/// Page numbers of the fixed-position pages.
pub const page_header: u32 = 0;
pub const page_watermark_a: u32 = 1;
pub const page_watermark_b: u32 = 2;
/// The first page the allocator may hand out.
pub const page_first_data: u32 = 3;
const magic: u32 = 0x4D464442; // "MFDB"
const format_version: u8 = 1;
const header_hashed_len: usize = 24;
/// The alignment mmap requires, which is the *system* page size and not ours.
/// 16 KiB on Apple Silicon against a 4 KiB logical page, so four logical pages
/// share one system page there — which is why the checkpoint rounds its append
/// cursors up to this rather than to page_size.
pub const map_align = std.heap.page_size_min;
/// Logical pages per system page. At least one: the comptime block below only
/// requires one of the two sizes to divide the other.
pub const pages_per_map_align: u32 = @max(1, map_align / page_size);
/// Growth granularity. Large enough that growth is rare and each `setLength`
/// covers many allocations, and a multiple of every supported system page size.
const grow_chunk_pages: u32 = 2048; // 8 MiB
/// Address space reserved by default. Reserved, not committed: a PROT_NONE
/// anonymous NORESERVE mapping is one VMA and zero pages. Sized for the
/// tens-of-GB target (D3) with room to spare.
pub const default_reserve_bytes: usize = 64 << 30;
comptime {
assert(page_size == 4096);
assert(page_first_data == 3);
assert(grow_chunk_pages * page_size % map_align == 0);
assert(map_align % page_size == 0 or page_size % map_align == 0);
// Node pages are raw host memory in this file (see the header comment).
assert(@import("builtin").cpu.arch.endian() == .little);
}
/// A run of contiguous pages.
pub const Extent = struct {
first: u32,
pages: u32,
};
/// What a checkpoint publishes. Everything here is authoritative except the
/// two cached counters, which are hints the engine recomputes if they look
/// wrong.
/// Pages a run of `len` bytes occupies, rounded up. The one place the
/// partial-page rule is written.
pub fn pages_for(len: u64) u32 {
return @intCast((len + page_size - 1) / page_size);
}
/// One generation's two streams, as page runs. Page counts rather than byte
/// lengths because the free list speaks pages and because the free-list stream
/// is allocated from an upper bound that its final length can fall short of.
pub const Streams = struct {
catalog_page: u32 = 0,
catalog_pages: u32 = 0,
freelist_page: u32 = 0,
freelist_pages: u32 = 0,
};
pub const Watermark = struct {
generation: u64 = 0,
/// The log sequence this image covers. The crash-recovery invariant is that
/// this never exceeds the durable log tail (PLAN D6), so replay after open
/// is exactly the records above it.
seq: u64 = 0,
alloc_tail: u32 = page_first_data,
file_pages: u32 = page_first_data,
catalog_page: u32 = 0,
catalog_len: u64 = 0,
freelist_page: u32 = 0,
freelist_len: u64 = 0,
prev_generation: u64 = 0,
live_docs: u64 = 0,
dead_bytes: u64 = 0,
};
const wm_hashed_len: usize = page_size - 8;
pub const Error = error{
/// Not a data file, or a version this build cannot read.
InvalidDataFile,
/// The file belongs to a different database than the log beside it.
DatabaseMismatch,
/// Growth would exceed the reserved address space.
DatabaseTooLarge,
};
pub const OpenOptions = struct {
/// Must match the log's uuid. Zero means "adopt whatever the file has",
/// which is only for tests that do not care.
uuid: u128 = 0,
reserve_bytes: usize = default_reserve_bytes,
};
/// One consumer's outstanding promise, in pages.
///
/// The promise used to be a single counter on the pager, and that is not
/// something concurrent writers can share. Two upserts on different
/// collections run at the same time (they hold different collection locks),
/// and each one ends by dropping "whatever is still promised" -- so the first
/// to finish zeroed the second's promise, and the second's supposedly
/// infallible allocation then tripped its own tripwire:
///
/// assertion failed: page allocation overran reserve_pages' promise
/// src/index.zig:955 in alloc_node
///
/// Reliably, at four concurrent clients, on the first benchmark run after the
/// data file landed (PLAN risk 3). Each consumer -- every index, every
/// collection's slab, the checkpoint -- now holds its own, and only ever
/// releases its own. The pager keeps the sum, which is all `grow_to` needs.
pub const Reservation = struct {
pages: u32 = 0,
};
pub const Pager = struct {
gpa: std.mem.Allocator,
io: std.Io,
file: std.Io.File,
path: []u8,
uuid: u128,
/// The address-space reservation. Never moves for the life of the process,
/// so a pointer obtained from `page`/`page_mut` stays valid across growth —
/// which the old ArrayList-backed arena could not promise, and which is the
/// reason a whole class of dangling-pointer bugs disappears here.
reserve: []align(map_align) u8,
/// Pages actually mapped to the file. Always >= alloc_tail.
mapped_pages: u32,
/// Pages the file is long enough to hold.
file_pages: u32,
/// Pages [0, alloc_tail) have been handed out.
alloc_tail: u32,
/// Pages promised by `reserve_pages` and not yet handed out.
///
/// A count rather than a tail mark, because there is more than one consumer:
/// an upsert reserves tree pages for every index *and* slab room for the
/// document, all before the log append. A tail mark cannot express that --
/// the second reserver overwrites the first one's promise, and then the
/// first one's allocation asserts. Which is exactly what happened, on a
/// 512 MB load, with the tripwire in alloc_node catching it.
reserved_pages: u32,
/// Serialises the allocator's bookkeeping: the tail, the reservation total,
/// the free lists, the unpublished set and file growth. Writers on different
/// collections hold different collection locks and allocate from this one
/// pager, so none of that can be a plain field (PLAN risk 3).
///
/// Taken uncancelable: the critical section is bookkeeping that leaves the
/// allocator inconsistent if abandoned half way, and it is never held across
/// the log append -- that is what per-consumer reservations buy.
alloc_lock: std.Io.Mutex,
/// Separates a publish from the appends that are mid-flight.
///
/// An appender asks `is_unpublished_at` whether its cursor is still
/// writable, and copies bytes there afterwards. `publish` clears the whole
/// unpublished set and mprotects the image between those two steps, so the
/// answer was stale by the time it was used and the copy landed in the
/// published image: SIGBUS where the protection is compiled in, a silent
/// store into the durable image in ReleaseFast, where it is not.
///
/// Shared by appenders so writers on different collections still run
/// concurrently -- the decomposition ROADMAP item 5 measured is not given
/// back. Exclusive only for the tail of a publish, which happens once per
/// checkpoint.
append_lock: std.Io.RwLock,
/// True when this file was created by this open (no checkpoint to load).
fresh: bool,
/// The watermark this pager opened from, or a zero one when fresh. The
/// engine reads `seq` to decide where replay starts.
loaded: Watermark,
/// The generation the next checkpoint will publish.
generation: u64,
/// Where the catalog and free-list streams of the two most recent
/// generations live, so the one that falls out of reference can be freed.
///
/// Both streams are written into *fresh* pages at every publish, so that a
/// crash leaves the previous copy intact. Nothing ever gave those pages
/// back: a database checkpointing every 32 MiB of log leaked two runs per
/// checkpoint forever, and the catalog carries a `u32` per index node page,
/// so at the tens-of-GB target that is hundreds of KB each time.
///
/// Process-local rather than in the watermark: only a running pager needs to
/// know, because an open reads the slot it is loading and the other slot is
/// the fallback. Two generations because a publish overwrites the slot of
/// the generation *two* back -- that is the one nothing can reach again.
streams_cur: Streams,
streams_prev: Streams,
/// Pages below this belong to the last published image and must never be
/// stored into (PLAN amendment A1). Zero until a checkpoint publishes one,
/// which is why copy-on-write is inert before then.
///
/// A *bound*, not the membership test: see `unpublished`.
stable_pages: u32,
/// Pages handed out since the last publish, and therefore not referenced by
/// any durable image -- free to be written in place however low their page
/// number is.
///
/// `p >= stable_pages` was used for this and is not the same question. It is
/// right for tail-bumped pages and wrong for recycled ones, which come off
/// the free list *below* the mark and are nonetheless writable. The
/// difference is not cosmetic: with the numeric test, every write to a
/// recycled node page copied it and freed it again, and every recycled slab
/// extent was abandoned after a single document -- so nothing was ever really
/// reused and the file grew without bound under churn. The churn gate
/// measured 7.2x live data over six rounds, still climbing linearly.
///
/// One bit per page: 32 KiB per GiB of database, one load on the COW path
/// against the 4 KiB copy it avoids. Cleared wholesale at each publish,
/// which is exactly when every allocated page becomes part of the image.
unpublished: std.DynamicBitSetUnmanaged,
/// Free pages, in three stages. `ready` may be handed out now; `hold` was
/// freed one generation ago; `pending` was freed during this generation.
///
/// The two-generation delay is not caution, it is what makes generation N-1
/// remain a usable image: its pages are still allocated while N is current,
/// so a torn watermark or a bad catalog can fall back to it instead of
/// discarding the database. Releasing one generation early would silently
/// take that away.
free_ready: std.ArrayListUnmanaged(Extent),
free_hold: std.ArrayListUnmanaged(Extent),
free_pending: std.ArrayListUnmanaged(Extent),
/// See `track_dirty`. Pages written since the last sync.
dirty: if (track_dirty) std.AutoHashMapUnmanaged(u32, void) else void,
/// Guards `dirty`, and only `dirty`. Writers to the file itself are kept
/// apart by the locks their *owners* hold -- a collection's, an index's --
/// and the pages two of them touch never overlap. This set is the one thing
/// they share: two appenders on different collections record into the same
/// hash map at the same time, which is a torn map rather than a torn page.
/// Test-only instrumentation, so it costs the server nothing.
dirty_lock: if (track_dirty) std.Io.Mutex else void,
/// Whether the last `protect_image` actually took effect. Checked by a test:
/// an mprotect that silently fails would leave the belt looking present and
/// doing nothing, which is worse than not having it.
protect_ok: bool,
pub fn open(
gpa: std.mem.Allocator,
io: std.Io,
path: []const u8,
opts: OpenOptions,
) !Pager {
// Absolute, for the same reason storage.Log resolves its own path: the
// rebuild renames this file and must not depend on the caller's working
// directory.
const owned_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(owned_path);
const dir = std.Io.Dir.cwd();
var created = false;
var file = dir.openFile(io, owned_path, .{ .mode = .read_write }) catch |err| switch (err) {
error.FileNotFound => blk: {
created = true;
break :blk try dir.createFile(io, owned_path, .{ .read = true });
},
else => return err,
};
errdefer file.close(io);
// A zero-length file is as good as absent: a create that died before
// its header landed must not look like a corrupt database.
const existing_len = try file.length(io);
if (existing_len == 0) created = true;
const reserve = try reserve_address_space(opts.reserve_bytes);
errdefer std.posix.munmap(reserve);
var self: Pager = .{
.gpa = gpa,
.io = io,
.file = file,
.path = owned_path,
.uuid = opts.uuid,
.reserve = reserve,
.mapped_pages = 0,
.file_pages = @intCast(existing_len / page_size),
.alloc_tail = page_first_data,
.reserved_pages = 0,
.alloc_lock = .init,
.append_lock = .init,
.fresh = created,
.loaded = .{},
.generation = 0,
.streams_cur = .{},
.streams_prev = .{},
.stable_pages = 0,
.unpublished = .{},
.free_ready = .empty,
.free_hold = .empty,
.free_pending = .empty,
.dirty = if (track_dirty) .empty else {},
.dirty_lock = if (track_dirty) .init else {},
.protect_ok = false,
};
// The bit set is the one heap allocation `self` owns before `deinit` can
// be reached: a rejected header returns from the middle of this function.
errdefer self.unpublished.deinit(gpa);
if (created) {
try self.grow_to(page_first_data);
try self.write_header();
} else {
try self.grow_to(@max(page_first_data, self.file_pages));
try self.read_header();
// Everything already in the file is allocated until a watermark
// narrows it down.
self.alloc_tail = @max(page_first_data, self.file_pages);
try self.load_watermark();
if (self.loaded.generation == 0) {
// No usable checkpoint: either none was ever published, or both
// watermark slots were unreadable. Then *nothing* in this file is
// referenced -- the log is the whole truth and replay is about to
// rebuild the slab, the trees and the overflow from it -- so the
// file is free space, not allocated space.
//
// Leaving `alloc_tail` at the file end instead made every reopen
// append a fresh copy above the last one, with no watermark and
// therefore no free list to ever give the old copy back. Linear
// growth per open, unbounded, and it does not need a crash: a
// database small enough never to reach the checkpoint threshold
// never publishes a watermark at all, so *every* clean reopen
// took this path. Measured at 20 documents per cycle: +17 MB per
// reopen, 269 MB after twelve, on course for DatabaseTooLarge.
//
// The file is not truncated here on purpose. The mapping is
// already established over these pages, and `grow_to` extends the
// file only when the mapping is too small -- so shortening the
// file behind a mapping that still covers it would turn a later
// write into SIGBUS. Reusing from the front bounds the file at
// its high-water mark, which is what the unbounded growth needed.
self.alloc_tail = page_first_data;
}
}
return self;
}
pub fn deinit(self: *Pager) void {
self.unpublished.deinit(self.gpa);
self.free_ready.deinit(self.gpa);
self.free_hold.deinit(self.gpa);
self.free_pending.deinit(self.gpa);
if (track_dirty) self.dirty.deinit(self.gpa);
std.posix.munmap(self.reserve);
self.file.close(self.io);
self.gpa.free(self.path);
}
// -- page access --------------------------------------------------------
/// The page's bytes, for reading.
pub inline fn page(self: *const Pager, p: u32) *align(page_size) const [page_size]u8 {
assert_msg(p < self.mapped_pages, "read of a page past the mapped end of the data file");
return @ptrCast(@alignCast(self.reserve.ptr + (@as(usize, p) << page_shift)));
}
/// The page's bytes, for writing. Callers holding a page number that can be
/// *updated* use `page_mut_cow`; append-only consumers pass a page they
/// allocated themselves.
///
/// There is deliberately no assert on the page number here. A page recycled
/// off the free list is below the stable mark and is legitimately writable --
/// it was freed two generations ago and no live image references it any more
/// -- so the page number alone cannot distinguish a violation from a reuse.
/// `unpublished` can, but asserting on it here would only restate what
/// `page_mut_cow` has already decided one frame up.
///
/// The invariant is enforced the two ways PLAN amendment A1 describes
/// instead: structurally, because every write to a tree node goes through
/// `page_mut_cow`, and mechanically, because `protect_stable` builds make the
/// image hardware read-only, which `unprotect` lifts for exactly the pages
/// recycling hands back.
pub inline fn page_mut(self: *Pager, p: u32) *align(page_size) [page_size]u8 {
assert_msg(p < self.mapped_pages, "write to a page past the mapped end of the data file");
self.note_dirty(p, p);
return @ptrCast(@alignCast(self.reserve.ptr + (@as(usize, p) << page_shift)));
}
/// A writable page for an owner that can be told where the page moved.
///
/// `slot` must be the *single* owner of this page number. That is the
/// property the whole scheme rests on: copy-on-write relocates the page, and
/// if a second reference existed it would still point at the abandoned copy.
/// For the B+tree that owner is the id->page table entry, which is precisely
/// why node ids are not page numbers (PLAN amendment A1).
///
/// The victim goes on the free list, which withholds it for two generations,
/// so the image that still references it stays intact and usable.
pub fn page_mut_cow(self: *Pager, slot: *u32) !*align(page_size) [page_size]u8 {
if (self.is_unpublished(slot.*)) return self.page_mut(slot.*);
const copy = try self.alloc_pages(1);
@memcpy(
@as(*[page_size]u8, @ptrCast(self.page_mut(copy))),
@as(*const [page_size]u8, @ptrCast(self.page(slot.*))),
);
try self.free_pages(slot.*, 1);
slot.* = copy;
return self.page_mut(copy);
}
/// Whether this page was handed out since the last publish, and so may be
/// written in place. See the `unpublished` field.
pub inline fn is_unpublished(self: *const Pager, p: u32) bool {
return p < self.unpublished.bit_length and self.unpublished.isSet(p);
}
/// Reclaim the unwritten tail of an extent for appending again after a
/// checkpoint, from `off` (which must already be clear of every byte the
/// published image references) to the end of the extent.
///
/// The append cursors need this because `publish` clears `unpublished`
/// wholesale, which makes an extent the appender still owns read as part of
/// the image. Without it the only safe move was to abandon the rest of the
/// extent and take a fresh one -- ~8 MiB per collection at every checkpoint,
/// never reclaimed when the workload produces no garbage for compaction to
/// find. Measured: 40 collections of pure inserts put the data file at 11.8x
/// the live data and rising by ~335 MB per checkpoint, on course to exhaust
/// the address-space reservation after about 6 GB of real data.
///
/// The caller's contract, which is what makes this sound: `off` is rounded up
/// past the *live* append cursor to a system-page boundary, so no page in
/// `[off, end)` holds a byte referenced by the image or by the live indexes.
/// System pages rather than 4 KiB ones because writeback tears at the
/// granularity the kernel manages -- a 4 KiB store dirties the whole 16 KiB
/// page on Apple Silicon, and a torn writeback there would take out the image
/// bytes sharing it.
pub fn mark_appendable(self: *Pager, off: u64, end: u64) void {
assert(off <= end);
assert(off % map_align == 0);
const first: u32 = @intCast(off >> page_shift);
const last: u32 = @intCast(end >> page_shift); // exclusive
if (last <= first) return;
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
assert_msg(
last <= self.mapped_pages,
"marking pages appendable past the mapped end of the data file",
);
self.unpublished.setRangeValue(.{ .start = first, .end = last }, true);
// These pages may sit below the stable mark, where `protect_image` has
// made them hardware read-only.
self.unprotect(first, last - first);
}
/// The same question for the byte-offset consumers: may an append at `off`
/// land in place, or does its page belong to the durable image? Used by the
/// document slab and the overflow slab, which would otherwise have to guess
/// from the offset (and, using `stable_bytes`, guessed wrong for every
/// recycled extent).
pub inline fn is_unpublished_at(self: *const Pager, off: u64) bool {
return self.is_unpublished(@intCast(off >> page_shift));
}
/// Hold off the next publish while an append decides where to put its bytes
/// and puts them there. Uncancelable and infallible: the append runs after
/// the log record is durable, where there is nowhere to report a failure.
/// See `append_lock`.
pub fn lock_append(self: *Pager) void {
self.append_lock.lockSharedUncancelable(self.io);
}
pub fn unlock_append(self: *Pager) void {
self.append_lock.unlockShared(self.io);
}
/// The one page a write may legitimately land on below the stable mark: a
/// watermark slot. Overwriting the *inactive* slot is the whole mechanism --
/// alternating by generation parity is what makes it safe, where every other
/// page below the mark is part of the image a crash must find unchanged.
///
/// Deliberately not public and deliberately narrow: it takes the slot number
/// and asserts it is one, so it cannot become a general escape hatch from
/// the check in `page_mut`.
inline fn page_mut_slot(self: *Pager, p: u32) *align(page_size) [page_size]u8 {
assert(p == page_watermark_a or p == page_watermark_b);
assert_msg(p < self.mapped_pages, "write to a watermark slot past the mapped end");
self.note_dirty(p, p);
return @ptrCast(@alignCast(self.reserve.ptr + (@as(usize, p) << page_shift)));
}
/// Bytes above the stable mark, i.e. the first byte a write may touch.
pub inline fn stable_bytes(self: *const Pager) u64 {
return @as(u64, self.stable_pages) << page_shift;
}
/// Bytes at an absolute file offset, for the consumers whose references are
/// byte offsets rather than page numbers: the document slab and the
/// overflow slab.
pub inline fn bytes(self: *const Pager, off: u64, len: usize) []const u8 {
assert_msg(off + len <= @as(u64, self.mapped_pages) << page_shift, "read past the mapped end of the data file");
return self.reserve[@intCast(off)..][0..len];
}
pub inline fn bytes_mut(self: *Pager, off: u64, len: usize) []u8 {
assert_msg(off + len <= @as(u64, self.mapped_pages) << page_shift, "write past the mapped end of the data file");
if (track_dirty) {
self.note_dirty(@intCast(off >> page_shift), @intCast((off + len - 1) >> page_shift));
}
return self.reserve[@intCast(off)..][0..len];
}
/// Record pages `first..=last` as written since the last sync. See
/// `dirty_lock` for why this is the one shared structure on the write path.
inline fn note_dirty(self: *Pager, first: u32, last: u32) void {
if (!track_dirty) return;
self.dirty_lock.lockUncancelable(self.io);
defer self.dirty_lock.unlock(self.io);
var p = first;
while (p <= last) : (p += 1) self.dirty.put(self.gpa, p, {}) catch {};
}
// -- allocation ---------------------------------------------------------
/// Hand out `n` contiguous pages, growing the file if needed. For callers
/// with nothing to protect against failure -- copy-on-write, the catalog --
/// where reserving and claiming are one step.
pub fn alloc_pages(self: *Pager, n: u32) !u32 {
assert(n > 0);
var hold: Reservation = .{};
try self.reserve_pages(&hold, n);
return self.alloc_pages_assume_reserved(&hold, n);
}
/// Guarantee that `hold` can have `n` more pages handed out without failing.
/// Fallible, and meant to run *before* the log append on a write path, so
/// that publishing afterwards cannot fail — the invariant that keeps a
/// document from ever being live but unindexed.
///
/// Nothing is dirtied here, so nothing is allocated on disk: `setLength`
/// leaves a sparse file, `ls -l` grows and `du` does not. That is what makes
/// a generous reservation cheap.
pub fn reserve_pages(self: *Pager, hold: *Reservation, n: u32) !void {
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
return self.reserve_pages_locked(hold, n);
}
/// For callers already holding `alloc_lock`. The lock is not reentrant, so
/// the split is what lets `write_freelist` hold it across reading the lists
/// *and* allocating the pages it writes them into.
fn reserve_pages_locked(self: *Pager, hold: *Reservation, n: u32) !void {
// Additive: room for every promise outstanding anywhere *plus* this one.
// Two consumers reserving before the same log append must both be able to
// rely on their promise.
try self.grow_to(self.alloc_tail + self.reserved_pages + n);
self.reserved_pages += n;
hold.pages += n;
}
/// Drop whatever `hold` still promises but has not claimed.
///
/// A reservation is scoped to one write: it is taken before the log append
/// so the publish afterwards cannot fail, and once the publish is done
/// anything unclaimed is dead. Without this the promise accumulates -- a
/// tree reservation covers the worst case of several splits and a typical
/// insert causes none, so the total grew by a handful per write and dragged
/// the file up with it. It showed as a 1.89 GB data file for 512 MB of
/// documents.
pub fn release_reservation(self: *Pager, hold: *Reservation) void {
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
assert_msg(
self.reserved_pages >= hold.pages,
"a consumer released more pages than the pager had promised",
);
self.reserved_pages -= hold.pages;
hold.pages = 0;
}
/// Hand out `n` pages against a previous `reserve_pages`. Infallible.
/// Take a run from the free list if one fits, else bump the tail. A recycled
/// page was inside a published image once, so its protection has to be
/// lifted before it is handed out again.
/// Best fit, not first fit. First fit lets single-page requests cannibalise
/// the large runs: copy-on-write asks for one page thousands of times per
/// generation, each carving a page off the front of whatever run comes first,
/// and a 2048-page slab extent is shaved to nothing while never being usable
/// as an extent. That is what the churn gate saw -- the free list draining to
/// zero every generation with the file still growing by the full write volume.
/// Smallest-sufficient keeps the big runs whole for the consumers that need
/// them, and there is nothing else that wants the one-page holes.
fn take_free(self: *Pager, n: u32) ?u32 {
// The `pages == n` early exit is the exact-match shortcut only; removing
// it must leave behaviour identical, and turning the loop into a plain
// first-fit `break` is the mutation the extent test names.
var best: ?usize = null;
for (self.free_ready.items, 0..) |e, i| {
if (e.pages < n) continue;
if (best == null or e.pages < self.free_ready.items[best.?].pages) best = i;
if (e.pages == n) break; // cannot do better than exact
}
const i = best orelse return null;
const e = self.free_ready.items[i];
const first = e.first;
if (e.pages == n) {
_ = self.free_ready.swapRemove(i);
} else {
self.free_ready.items[i] = .{ .first = e.first + n, .pages = e.pages - n };
}
return first;
}
/// Take a run off the free list for a document slab: at least `min_pages`,
/// at most `max_pages`, starting and ending on a system-page boundary.
/// Returns null when nothing on the list qualifies, and the caller bumps the
/// tail instead.
///
/// `take_free` cannot serve this, and that is the whole reason this exists.
/// It is deliberately best fit -- smallest sufficient run -- so that the
/// thousands of single-page copy-on-write requests per generation cannot
/// dismantle the large runs. A slab extent asks for 2048 pages, and window
/// reclamation gives back runs a few pages at a time, so with an exact-size
/// rule the free list could fill up with reclaimed slab that no slab request
/// would ever take: the pages come back, the file keeps growing, the ratio
/// does not move. That is the failure this whole milestone is measured
/// against.
///
/// So this one takes a partial run when it cannot get a whole one, and is
/// allowed to trim a larger one. There is no cannibalisation to fear here:
/// the request is itself large (the caller's floor is 1 MiB), so what it
/// leaves behind is still a usable run rather than a hole. The one-page
/// requests still go through `take_free` unchanged, and its pinned mutation
/// test is untouched.
///
/// The alignment is not cosmetic. `map_align` is the granularity writeback
/// works at and the granularity reclamation gives back at, so a run that
/// starts mid-system-page both wastes its first window and shares a kernel
/// page with whatever occupies the rest of it -- which for a page still in
/// the published image is the tearing `mark_appendable` refuses to risk.
pub fn alloc_slab_run(self: *Pager, hold: *Reservation, min_pages: u32, max_pages: u32) ?Extent {
assert(min_pages > 0);
assert(min_pages <= max_pages);
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
// A split can leave a piece at each end, so one entry may become two.
// Out of memory before anything is disturbed: the caller falls back to
// bumping the tail, which is what it would have done anyway.
self.free_ready.ensureUnusedCapacity(self.gpa, 1) catch return null;
var best: ?usize = null;
var best_first: u32 = 0;
var best_take: u32 = 0;
var best_src: u32 = 0;
for (self.free_ready.items, 0..) |e, i| {
const from = std.mem.alignForward(u32, e.first, pages_per_map_align);
const to = std.mem.alignBackward(u32, e.first + e.pages, pages_per_map_align);
if (to <= from) continue;
const usable = to - from;
if (usable < min_pages) continue;
const take = @min(usable, max_pages);
// The longest run available, so the collection switches extents as
// rarely as possible -- every switch abandons what is left of the
// one before it. Ties go to the smallest source run, which leaves
// the big ones as whole as it can. `best_take` starts at zero and
// every candidate takes at least `min_pages`, so "nothing yet" is
// already encoded.
if (take > best_take or (take == best_take and e.pages < best_src)) {
best = i;
best_first = from;
best_take = take;
best_src = e.pages;
}
}
const i = best orelse return null;
const e = self.free_ready.items[i];
const head = best_first - e.first;
const tail_first = best_first + best_take;
const tail = (e.first + e.pages) - tail_first;
if (head > 0) {
self.free_ready.items[i] = .{ .first = e.first, .pages = head };
if (tail > 0) self.free_ready.appendAssumeCapacity(.{ .first = tail_first, .pages = tail });
} else if (tail > 0) {
self.free_ready.items[i] = .{ .first = tail_first, .pages = tail };
} else {
_ = self.free_ready.swapRemove(i);
}
self.claim_locked(hold, best_first, best_take);
return .{ .first = best_first, .pages = best_take };
}
/// Merge runs that touch, so the holes single-page frees leave behind can add
/// up to an extent again. Without it the free list only ever fragments: every
/// generation returns thousands of one-page copy-on-write victims, and an
/// 8 MiB slab extent request never finds a home however much space is free.
/// Once per publish, over a list whose length is the generation's free count.
fn coalesce_free_ready(self: *Pager) void {
const items = self.free_ready.items;
if (items.len < 2) return;
std.mem.sort(Extent, items, {}, struct {
fn less(_: void, a: Extent, b: Extent) bool {
return a.first < b.first;
}
}.less);
var w: usize = 0;
for (items[1..]) |e| {
const prev = &items[w];
if (prev.first + prev.pages == e.first) {
prev.pages += e.pages;
} else {
w += 1;
items[w] = e;
}
}
self.free_ready.items.len = w + 1;
}
pub fn alloc_pages_assume_reserved(self: *Pager, hold: *Reservation, n: u32) u32 {
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
return self.alloc_assume_reserved_locked(hold, n);
}
/// For callers already holding `alloc_lock`; see `reserve_pages_locked`.
fn alloc_assume_reserved_locked(self: *Pager, hold: *Reservation, n: u32) u32 {
assert(n > 0);
assert_msg(
self.alloc_tail + n <= self.mapped_pages,
"page allocation past the mapped end of the data file",
);
// Reuse before growing. Without this the free list is decorative and the
// file grows without bound under churn, because copy-on-write abandons
// every page it touches in every generation (PLAN amendment A2).
if (self.take_free(n)) |recycled| {
self.claim_locked(hold, recycled, n);
return recycled;
}
const first = self.alloc_tail;
self.alloc_tail += n;
self.claim_locked(hold, first, n);
return first;
}
/// Charge `[first, first+pages)` against the reservation and make it
/// writable. The one place a claim is booked, because there are two
/// allocation policies above it and hand-copying this is how they drift --
/// the copy in `alloc_slab_run` had already lost one of the preconditions.
fn claim_locked(self: *Pager, hold: *Reservation, first: u32, pages: u32) void {
assert_msg(pages <= hold.pages, "page allocation overran reserve_pages' promise");
assert_msg(pages <= self.reserved_pages, "page allocation overran the pager's total promise");
self.reserved_pages -= pages;
hold.pages -= pages;
// A recycled page was inside a published image once, so its protection
// has to be lifted before it is handed out again.
self.unprotect(first, pages);
self.mark_unpublished(first, pages);
}
fn mark_unpublished(self: *Pager, first: u32, n: u32) void {
// `grow_to` sizes the set to the mapping, and `reserve_pages` has already
// grown the mapping past this run, so the range is in bounds.
assert_msg(
@as(usize, first) + n <= self.unpublished.bit_length,
"allocated a page outside the unpublished set",
);
self.unpublished.setRangeValue(.{ .start = first, .end = first + n }, true);
}
/// Bytes currently allocated, i.e. the extent of the live image.
pub fn allocated_bytes(self: *const Pager) u64 {
return @as(u64, self.alloc_tail) << page_shift;
}
// -- durability ---------------------------------------------------------
/// Flush every allocated page and the inode. Only the checkpoint calls
/// this: between checkpoints dirty pages may sit in the page cache
/// indefinitely, because recovery is `image + replay(seq > watermark)` and
/// the image's pages are never written *differently*. That is what keeps
/// the write path at exactly one fsync — the WAL's (PLAN amendment A1).
pub fn sync(self: *Pager) !void {
const len = std.mem.alignForward(usize, @intCast(self.allocated_bytes()), map_align);
if (len > 0) {
try std.posix.msync(self.reserve[0..len], std.posix.MSF.SYNC);
}
try self.file.sync(self.io);
if (track_dirty) {
self.dirty_lock.lockUncancelable(self.io);
defer self.dirty_lock.unlock(self.io);
self.dirty.clearRetainingCapacity();
}
}
/// Make the published image read-only at the hardware level. See
/// `protect_stable`. The watermark slots stay writable: overwriting the
/// inactive one is the publication mechanism, not a violation.
fn protect_image(self: *Pager) void {
if (!protect_stable) return;
const first = std.mem.alignForward(usize, @as(usize, page_first_data) << page_shift, map_align);
const end = std.mem.alignBackward(usize, @as(usize, self.stable_pages) << page_shift, map_align);
if (end <= first) return;
// Best effort as far as the database is concerned -- losing the check
// leaves a weaker test build, not wrong data -- but recorded, so a test
// can tell the difference between a belt that is working and one that is
// silently doing nothing. std.posix has no mprotect wrapper in Zig 0.16,
// hence the libc call.
const rc = std.c.mprotect(@ptrCast(@alignCast(self.reserve.ptr + first)), end - first, .{ .READ = true });
self.protect_ok = rc == 0;
}
/// Lift the protection over a range that is about to leave the image --
/// which only copy-on-write does, when it recycles a freed page.
fn unprotect(self: *Pager, first_page: u32, pages: u32) void {
if (!protect_stable) return;
const first = std.mem.alignBackward(usize, @as(usize, first_page) << page_shift, map_align);
const end = std.mem.alignForward(usize, @as(usize, first_page + pages) << page_shift, map_align);
_ = std.c.mprotect(@ptrCast(@alignCast(self.reserve.ptr + first)), end - first, .{ .READ = true, .WRITE = true });
}
/// Test-only: overwrite, in the *file*, every page written since the last
/// sync -- the state a power loss can leave behind and a `kill -9` cannot.
/// Call it with the mapping already closed.
fn simulate_lost_writeback(gpa: std.mem.Allocator, io: std.Io, path: []const u8, lost: []const u32) !void {
var f = try std.Io.Dir.cwd().openFile(io, path, .{ .mode = .read_write });
defer f.close(io);
const junk = try gpa.alloc(u8, page_size);
defer gpa.free(junk);
// Not zeroes: zeroes are a plausible value, and a test that passes
// because the lost page happened to read as zero proves nothing.
@memset(junk, 0xDD);
for (lost) |p| try f.writePositionalAll(io, junk, @as(u64, p) << page_shift);
}
/// Test-only: the pages `simulate_lost_writeback` would discard.
fn dirty_pages(self: *const Pager, gpa: std.mem.Allocator) ![]u32 {
var out: std.ArrayListUnmanaged(u32) = .empty;
errdefer out.deinit(gpa);
var it = self.dirty.keyIterator();
while (it.next()) |k| try out.append(gpa, k.*);
return out.toOwnedSlice(gpa);
}
// -- growth -------------------------------------------------------------
/// Make at least `want_pages` pages mapped and file-backed.
///
/// The order is the whole point: extend the file, *then* map the new
/// suffix. A store into a mapped page past end-of-file raises SIGBUS, which
/// no Zig error path can catch, so the file must be long enough before any
/// page in the range is reachable. The accessors assert against
/// `mapped_pages` so a violation is a panic with a message rather than a
/// signal.
fn grow_to(self: *Pager, want_pages: u32) !void {
if (want_pages <= self.mapped_pages) return;
// Round up to a growth chunk, and to the system page size, so a
// 16 KiB-page host never gets a partial mapping request.
//
// `alignForwardAnyAlign`, not `alignForward`: the chunk is a *proportion*
// of the current size once the file passes 64 MiB, and `mapped_pages / 8`
// is not a power of two. `alignForward` asserts that it is -- so this
// panicked in safe builds and, worse, in ReleaseFast (where the assert is
// compiled out) computed `(addr + align - 1) & ~(align - 1)` with a
// non-power-of-two mask, which can round *down*. A mapping longer than the
// file is the one thing this function exists to prevent: a store into a
// mapped page past end-of-file raises SIGBUS, which no error path catches.
//
// Never noticed because no unit test grew a pager past 64 MiB, which is
// where the chunk stops being `grow_chunk_pages`.
const chunk = @max(grow_chunk_pages, self.mapped_pages / 8);
var new_pages = std.mem.alignForwardAnyAlign(u32, want_pages, chunk);
const sys_pages: u32 = pages_per_map_align;
if (sys_pages > 1) new_pages = std.mem.alignForward(u32, new_pages, sys_pages);
if (@as(u64, new_pages) << page_shift > self.reserve.len) {
// One more try at exactly what was asked for: a reservation the
// chunking would overshoot is still usable up to its end.
new_pages = want_pages;
if (sys_pages > 1) new_pages = std.mem.alignForward(u32, new_pages, sys_pages);
if (@as(u64, new_pages) << page_shift > self.reserve.len) return Error.DatabaseTooLarge;
}
const new_len: u64 = @as(u64, new_pages) << page_shift;
if (new_len > self.file_pages_bytes()) {
try self.file.setLength(self.io, new_len);
self.file_pages = new_pages;
}
// Before the mapping grows, so a failure here cannot leave pages
// reachable that the set has no bit for.
try self.unpublished.resize(self.gpa, new_pages, false);
const off: usize = @as(usize, self.mapped_pages) << page_shift;
const len: usize = @intCast(new_len - (@as(u64, self.mapped_pages) << page_shift));
_ = try std.posix.mmap(
@alignCast(self.reserve.ptr + off),
len,
.{ .READ = true, .WRITE = true },
.{ .TYPE = .SHARED, .FIXED = true },
self.file.handle,
off,
);
self.mapped_pages = new_pages;
}
fn file_pages_bytes(self: *const Pager) u64 {
return @as(u64, self.file_pages) << page_shift;
}
// -- watermark ----------------------------------------------------------
/// Adopt the newest valid watermark, or none. Never fails on a damaged
/// watermark: a data file whose slots are both unreadable is treated as
/// having no checkpoint, which means a full replay -- the database must
/// always open (ground rule 4). The caller learns which happened from
/// `loaded.generation == 0`.
fn load_watermark(self: *Pager) !void {
const a = self.read_slot(page_watermark_a);
const b = self.read_slot(page_watermark_b);
// Validate first, compare generations second. The other order picks a
// torn slot whenever its garbage generation happens to be larger.
const pick: ?Watermark = if (a != null and b != null)
(if (a.?.generation >= b.?.generation) a else b)
else if (a != null) a else b;
if (pick) |wm| {
self.loaded = wm;
self.generation = wm.generation;
self.alloc_tail = wm.alloc_tail;
self.reserved_pages = 0;
// Everything the published image references is off limits to
// writes from here on.
self.stable_pages = wm.alloc_tail;
// So the next publish but one gives this generation's streams back.
// Page counts are derived from the lengths here rather than
// remembered, which can be one page short for a free-list stream
// whose final length fell inside the page its bound reserved. That
// loses at most one page, once per open, against the unbounded leak
// this replaces.
self.streams_cur = .{
.catalog_page = wm.catalog_page,
.catalog_pages = pages_for(wm.catalog_len),
.freelist_page = wm.freelist_page,
.freelist_pages = pages_for(wm.freelist_len),
};
try self.read_freelist(wm);
} else if (self.watermark_attempted()) {
// Only worth saying when a watermark was *written* and cannot be
// read: a data file that simply never reached its first checkpoint
// is the normal state of a young database, and warning about it
// trains people to ignore the warning that matters.
std.debug.print(
"multiforadb: WARNING: data file '{s}' has a damaged watermark; " ++
"treating it as having no checkpoint and replaying the log in full\n",
.{self.path},
);
}
}
/// Whether either slot holds anything at all. A never-checkpointed file has
/// both slots as written by create: all zeroes.
fn watermark_attempted(self: *const Pager) bool {
for ([_]u32{ page_watermark_a, page_watermark_b }) |p| {
if (p >= self.mapped_pages) continue;
for (self.page(p)) |byte| if (byte != 0) return true;
}
return false;
}
fn read_slot(self: *const Pager, p: u32) ?Watermark {
if (p >= self.mapped_pages) return null;
const b = self.page(p);
const want = std.mem.readInt(u64, b[wm_hashed_len..][0..8], .little);
if (header_hash(b[0..wm_hashed_len]) != want) return null;
const wm: Watermark = .{
.generation = std.mem.readInt(u64, b[0..8], .little),
.seq = std.mem.readInt(u64, b[8..16], .little),
.alloc_tail = @intCast(std.mem.readInt(u64, b[16..24], .little)),
.file_pages = @intCast(std.mem.readInt(u64, b[24..32], .little)),
.catalog_page = @intCast(std.mem.readInt(u64, b[32..40], .little)),
.catalog_len = std.mem.readInt(u64, b[40..48], .little),
.freelist_page = @intCast(std.mem.readInt(u64, b[48..56], .little)),
.freelist_len = std.mem.readInt(u64, b[56..64], .little),
.prev_generation = std.mem.readInt(u64, b[64..72], .little),
.live_docs = std.mem.readInt(u64, b[72..80], .little),
.dead_bytes = std.mem.readInt(u64, b[80..88], .little),
};
// A slot whose hash matches but whose contents are impossible is worse
// than a torn one, because it would be trusted.
//
// The generation check is belt-and-braces: an all-zero slot is already
// rejected by the hash, so no test reddens when it is removed. Kept
// because it costs nothing and it is the field every comparison below
// depends on. The alloc_tail check is *not* redundant -- a slot can be
// rehashed to validate with nonsense in it, which is what the
// "hash-valid but impossible" test does.
if (wm.generation == 0) return null;
if (wm.alloc_tail < page_first_data) return null;
return wm;
}
/// Publish `wm` as the new durable image.
///
/// The ordering here is the entire crash-recovery argument, so it is worth
/// stating plainly: every page the watermark describes is made durable
/// *before* the watermark that describes them. Reverse the two and a crash
/// in between leaves a watermark pointing at pages that were never written,
/// with the log already truncated below it -- unrecoverable.
///
/// The slot alternates by generation parity, so the previous watermark
/// survives this write and a torn slot leaves the older one intact.
pub fn publish(self: *Pager, wm_in: Watermark) !void {
var wm = wm_in;
wm.generation = self.generation + 1;
wm.prev_generation = self.generation;
// 1. every data page durable.
try self.sync();
// 2. the free list, into fresh pages so the old one stays intact.
const fl = try self.write_freelist();
wm.freelist_page = fl.first;
wm.freelist_len = fl.len;
try self.sync();
// 3. only now record the extent, *after* the free list has taken its
// pages. Capturing alloc_tail before this left those pages outside
// the published image, so the next open would hand them out again
// while the watermark still pointed at them.
wm.alloc_tail = self.alloc_tail;
wm.file_pages = self.file_pages;
// 4. the watermark itself, last.
const slot = if (wm.generation & 1 == 1) page_watermark_a else page_watermark_b;
const b = self.page_mut_slot(slot);
@memset(b, 0);
std.mem.writeInt(u64, b[0..8], wm.generation, .little);
std.mem.writeInt(u64, b[8..16], wm.seq, .little);
std.mem.writeInt(u64, b[16..24], wm.alloc_tail, .little);
std.mem.writeInt(u64, b[24..32], wm.file_pages, .little);
std.mem.writeInt(u64, b[32..40], wm.catalog_page, .little);
std.mem.writeInt(u64, b[40..48], wm.catalog_len, .little);
std.mem.writeInt(u64, b[48..56], wm.freelist_page, .little);
std.mem.writeInt(u64, b[56..64], wm.freelist_len, .little);
std.mem.writeInt(u64, b[64..72], wm.prev_generation, .little);
std.mem.writeInt(u64, b[72..80], wm.live_docs, .little);
std.mem.writeInt(u64, b[80..88], wm.dead_bytes, .little);
std.mem.writeInt(u64, b[wm_hashed_len..][0..8], header_hash(b[0..wm_hashed_len]), .little);
const off = @as(usize, slot) << page_shift;
const end = std.mem.alignForward(usize, off + page_size, map_align);
const start = std.mem.alignBackward(usize, off, map_align);
try std.posix.msync(@alignCast(self.reserve[start..end]), std.posix.MSF.SYNC);
try self.file.sync(self.io);
// Deliberately does *not* clear the dirty record. After a correct
// publish the only page left in it is this slot, which this msync just
// made durable -- so a test can discard everything else and assert the
// image still loads. Clearing it here would make that check vacuous.
// 5. the streams of the generation two back are now unreachable: the
// slot that named them is the one this publish just overwrote, and
// the two slots hold the new generation and its predecessor. Give
// their pages back -- via the free list, so they are still withheld
// for two more generations like anything else.
//
// Before the lock below, which `free_pages` takes for itself.
try self.free_pages(self.streams_prev.catalog_page, self.streams_prev.catalog_pages);
try self.free_pages(self.streams_prev.freelist_page, self.streams_prev.freelist_pages);
self.streams_prev = self.streams_cur;
self.streams_cur = .{
.catalog_page = wm.catalog_page,
.catalog_pages = pages_for(wm.catalog_len),
.freelist_page = fl.first,
.freelist_pages = fl.pages,
};
// 6. only now is the new image current: advance the stable mark and
// rotate the free lists by one generation.
//
// Exclusive against the appenders for this step alone. Freezing the
// image while one of them is between "is my cursor still writable"
// and the copy that relies on the answer is what put documents into
// the durable image; holding them off here is what makes the answer
// still true when it is used. Taken before `alloc_lock`, the order
// `mark_appendable` uses on the appender's side.
self.append_lock.lockUncancelable(self.io);
defer self.append_lock.unlock(self.io);
self.generation = wm.generation;
self.loaded = wm;
// The free lists and the unpublished set are allocator state, so the
// rotation below takes the same lock every allocation does.
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
self.stable_pages = self.alloc_tail;
self.protect_image();
try self.free_ready.appendSlice(self.gpa, self.free_hold.items);
self.free_hold.clearRetainingCapacity();
try self.free_hold.appendSlice(self.gpa, self.free_pending.items);
self.free_pending.clearRetainingCapacity();
self.coalesce_free_ready();
// Every page handed out so far is now part of the published image, so
// nothing may be written in place any more until it is allocated afresh.
self.unpublished.setRangeValue(.{ .start = 0, .end = self.unpublished.bit_length }, false);
}
// -- free list ----------------------------------------------------------
/// Give back a run of pages. They become reusable two generations later --
/// see the field comment on `free_ready`.
///
/// Under the allocation lock, like every other mutation of the free lists.
/// It was not, and the hot caller is `page_mut_cow`, which runs under a
/// *collection* lock: two collections doing copy-on-write concurrently
/// appended to the same list, and `publish` rotated all three lists
/// underneath them. No caller holds the lock already -- `page_mut_cow` takes
/// it inside `alloc_pages` and has released it by here -- so this cannot
/// recurse.
pub fn free_pages(self: *Pager, first: u32, pages: u32) !void {
if (pages == 0) return;
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
try self.free_pending.append(self.gpa, .{ .first = first, .pages = pages });
}
/// Whether any page of `[first, first+pages)` has been handed to the free
/// list. A consumer that still claims one is claiming a page the pager is
/// about to give to somebody else, and the symptom is a document quietly
/// overwritten rather than anything failing -- so this is the detector the
/// enlarged free list deserves, and it is why the free lists are readable
/// from outside at all.
///
/// Walks three lists, so it is for assertions in test and Debug builds.
pub fn owns_freed(self: *Pager, first: u32, pages: u32) bool {
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
for ([_][]const Extent{
self.free_pending.items,
self.free_hold.items,
self.free_ready.items,
}) |list| {
for (list) |e| {
if (first < e.first + e.pages and e.first < first + pages) return true;
}
}
return false;
}
/// Pages available for immediate reuse.
pub fn free_ready_pages(self: *const Pager) u32 {
var n: u32 = 0;
for (self.free_ready.items) |e| n += e.pages;
return n;
}
fn write_freelist(self: *Pager) !struct { first: u32, len: u64, pages: u32 } {
// Size from an upper bound, then count the entries actually written.
//
// The allocation below takes its pages off this very list, and an exact
// fit removes the entry it took (`take_free`). A count captured
// beforehand therefore claims one entry more than the loop writes: the
// hash lands eight bytes short of where `read_freelist` looks for it and
// the whole list is dropped as corrupt on the next open. The stream is
// one page and a one-page run is the commonest thing on the list, so
// that is the ordinary case rather than a corner.
//
// `take_free` never *adds* an entry, so the bound holds and one
// allocation is enough.
//
// Under `alloc_lock` for the whole of it, allocation included. Reading
// the three lists is as much a use of them as appending is: a concurrent
// `free_pages` -- copy-on-write in some collection, which a checkpoint
// does not exclude -- grows `free_pending` while the loop below walks it,
// and a growth that reallocates leaves the loop on freed memory. A free
// that lands after this point simply waits for the next generation's
// list; the page stays allocated one generation longer, which is the
// safe direction.
self.alloc_lock.lockUncancelable(self.io);
defer self.alloc_lock.unlock(self.io);
const bound = self.free_ready.items.len + self.free_hold.items.len + self.free_pending.items.len;
const pages: u32 = pages_for(8 + bound * 8 + 8);
var hold: Reservation = .{};
try self.reserve_pages_locked(&hold, pages);
const first = self.alloc_assume_reserved_locked(&hold, pages);
const buf = self.bytes_mut(@as(u64, first) << page_shift, @as(usize, pages) << page_shift);
@memset(buf, 0);
var at: usize = 8;
for ([_][]const Extent{
self.free_ready.items,
self.free_hold.items,
self.free_pending.items,
}) |list| {
for (list) |e| {
std.mem.writeInt(u32, buf[at..][0..4], e.first, .little);
std.mem.writeInt(u32, buf[at + 4 ..][0..4], e.pages, .little);
at += 8;
}
}
const count = (at - 8) / 8;
assert_msg(
count == bound or count + 1 == bound,
"the free list changed size while it was being written",
);
std.mem.writeInt(u64, buf[0..8], count, .little);
std.mem.writeInt(u64, buf[at..][0..8], header_hash(buf[0..at]), .little);
// `pages` and not `pages_for(len)`: the allocation was sized from the
// bound, and the whole run has to go back when this generation falls
// out of reference.
return .{ .first = first, .len = @as(u64, at) + 8, .pages = pages };
}
/// Load the free list a watermark points at. A damaged one is dropped with a
/// warning rather than refused: losing it wastes space, refusing to open
/// loses the database.
fn read_freelist(self: *Pager, wm: Watermark) !void {
if (wm.freelist_len == 0 or wm.freelist_page == 0) return;
if (wm.freelist_page >= self.mapped_pages) return;
const buf = self.bytes(@as(u64, wm.freelist_page) << page_shift, @intCast(wm.freelist_len));
const count = std.mem.readInt(u64, buf[0..8], .little);
const want_len = 8 + count * 8 + 8;
if (want_len != wm.freelist_len) return;
const at: usize = @intCast(8 + count * 8);
if (header_hash(buf[0..at]) != std.mem.readInt(u64, buf[at..][0..8], .little)) {
std.debug.print("multiforadb: WARNING: data file free list is corrupt; its pages stay in use\n", .{});
return;
}
// Everything the previous generations freed is reusable now: this open
// *is* a new generation, and nothing from before it is referenced.
var i: usize = 0;
while (i < count) : (i += 1) {
const o = 8 + i * 8;
try self.free_ready.append(self.gpa, .{
.first = std.mem.readInt(u32, buf[o..][0..4], .little),
.pages = std.mem.readInt(u32, buf[o + 4 ..][0..4], .little),
});
}
}
// -- header -------------------------------------------------------------
fn write_header(self: *Pager) !void {
const h = self.page_mut(page_header);
@memset(h, 0);
std.mem.writeInt(u32, h[0..4], magic, .little);
h[4] = format_version;
h[5] = page_shift;
std.mem.writeInt(u128, h[8..24], self.uuid, .little);
std.mem.writeInt(u64, h[24..32], header_hash(h[0..header_hashed_len]), .little);
}
fn read_header(self: *Pager) !void {
const h = self.page(page_header);
if (std.mem.readInt(u32, h[0..4], .little) != magic) return Error.InvalidDataFile;
if (h[4] != format_version) return Error.InvalidDataFile;
if (h[5] != page_shift) return Error.InvalidDataFile;
const want = std.mem.readInt(u64, h[24..32], .little);
if (header_hash(h[0..header_hashed_len]) != want) return Error.InvalidDataFile;
const file_uuid = std.mem.readInt(u128, h[8..24], .little);
if (self.uuid == 0) {
self.uuid = file_uuid;
} else if (file_uuid != self.uuid) {
return Error.DatabaseMismatch;
}
}
};
fn header_hash(b: []const u8) u64 {
return std.hash.XxHash3.hash(0, b);
}
/// Reserve address space without committing memory: PROT_NONE, anonymous and
/// NORESERVE is one VMA and zero pages, and is not charged even under strict
/// overcommit. Nothing is mapped to the file yet, so nothing here can fault.
///
/// Halves on refusal rather than failing outright, so a container with a tight
/// address-space limit still opens — with a smaller ceiling, which `grow_to`
/// reports as DatabaseTooLarge if it is ever actually reached.
fn reserve_address_space(want: usize) ![]align(map_align) u8 {
var len = std.mem.alignForward(usize, want, map_align);
while (true) {
if (std.posix.mmap(
null,
len,
.{}, // PROT_NONE
.{ .TYPE = .PRIVATE, .ANONYMOUS = true, .NORESERVE = true },
-1,
0,
)) |m| {
return m;
} else |err| {
if (len <= 1 << 30) return err;
len /= 2;
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
/// A pager over a temp file, with a reservation small enough that the growth
/// ceiling is reachable in a test.
const TmpPager = struct {
tmp: std.testing.TmpDir,
path: []u8,
/// Optional so a test can close it early -- several deliberately break the
/// file and must not have to reopen one just to satisfy teardown.
pager: ?Pager,
fn init(io: std.Io, reserve_bytes: usize) !TmpPager {
const tmp = std.testing.tmpDir(.{});
const path = try std.fmt.allocPrint(
testing.allocator,
".zig-cache/tmp/{s}/data",
.{tmp.sub_path},
);
return .{
.tmp = tmp,
.path = path,
.pager = try Pager.open(testing.allocator, io, path, .{ .uuid = 7, .reserve_bytes = reserve_bytes }),
};
}
fn deinit(self: *TmpPager) void {
if (self.pager) |*p| p.deinit();
self.pager = null;
self.tmp.cleanup();
testing.allocator.free(self.path);
}
fn close(self: *TmpPager) void {
if (self.pager) |*p| p.deinit();
self.pager = null;
}
fn pg(self: *TmpPager) *Pager {
return &self.pager.?;
}
};
test "pages survive growth and the mapping base never moves" {
// The reason the fixed reservation exists: a pointer handed out before a
// growth must still be valid after it. The ArrayList-backed arena this
// replaces could not promise that, and the workarounds for it (copying
// promoted keys into a scratch buffer) exist only because of it.
//
// Mutation check: make grow_to munmap and re-map the whole prefix at a
// kernel-chosen address and the base assertion goes red.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 256 << 20);
defer tp.deinit();
const pg = tp.pg();
const base = @intFromPtr(pg.reserve.ptr);
const first = try pg.alloc_pages(1);
const first_ptr = pg.page_mut(first);
@memset(first_ptr, 0xAB);
// Grow well past several chunk boundaries.
var written: std.ArrayListUnmanaged(u32) = .empty;
defer written.deinit(testing.allocator);
for (0..6000) |i| {
const p = try pg.alloc_pages(1);
const bytes = pg.page_mut(p);
@memset(bytes, @truncate(i));
try written.append(testing.allocator, p);
}
try testing.expectEqual(base, @intFromPtr(pg.reserve.ptr));
// The pointer taken before all that growth still addresses its page.
for (first_ptr) |b| try testing.expectEqual(@as(u8, 0xAB), b);
for (written.items, 0..) |p, i| {
const want: u8 = @truncate(i);
try testing.expectEqual(want, pg.page(p)[0]);
try testing.expectEqual(want, pg.page(p)[page_size - 1]);
}
}
test "the file is extended before any page in the range is reachable" {
// Write-then-extend, asserted as an ordering rather than by provoking the
// failure: a store past end-of-file is SIGBUS, which cannot be caught
// in-process, so the check is that the file is always long enough for
// everything mapped, and everything allocated is always mapped.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 256 << 20);
defer tp.deinit();
const pg = tp.pg();
for (0..40) |_| {
_ = try pg.alloc_pages(97); // not a chunk divisor, so growth is ragged
try testing.expect(pg.alloc_tail <= pg.mapped_pages);
try testing.expect(pg.mapped_pages <= pg.file_pages);
const on_disk = try pg.file.length(io);
try testing.expect(on_disk >= @as(u64, pg.mapped_pages) << page_shift);
}
}
test "two consumers reserving before one commit both keep their promise" {
// The reservation is a count, not a tail mark, and this is why. An upsert
// reserves tree pages for every index *and* slab room for the document,
// all before the log append, and both allocations happen after it. With a
// tail mark the second reserver overwrote the first one's promise and the
// first one's allocation then asserted -- which is how this was found, on a
// 512 MB load, by the tripwire in alloc_node.
//
// Mutation check: make reserve_pages assign `alloc_tail + n` instead of
// accumulating, and this goes red.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 256 << 20);
defer tp.deinit();
const pg = tp.pg();
// Consumer A reserves a few pages, then consumer B reserves a large extent
// and takes it -- exactly the order upsert uses.
var a: Reservation = .{};
var b: Reservation = .{};
try pg.reserve_pages(&a, 8);
try pg.reserve_pages(&b, 2048);
const b_first = pg.alloc_pages_assume_reserved(&b, 2048);
// A's promise must have survived B's reservation *and* B's allocation.
const a_first = pg.alloc_pages_assume_reserved(&a, 8);
try testing.expect(a_first >= b_first + 2048);
try testing.expectEqual(@as(u32, 0), pg.reserved_pages);
}
test "one consumer's release leaves another's promise intact" {
// Mutation check: make `release_reservation` zero `self.reserved_pages`
// (what it did when the promise was a single counter on the pager) and the
// allocation below goes red on `overran the pager's total promise`.
//
// Not hypothetical: two upserts on different collections hold different
// collection locks and run at the same time, so the first to publish
// released the second's promise out from under it. It aborted the server
// reliably at four concurrent clients (PLAN risk 3).
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 256 << 20);
defer tp.deinit();
const pg = tp.pg();
var writer_a: Reservation = .{};
var writer_b: Reservation = .{};
try pg.reserve_pages(&writer_a, 16);
try pg.reserve_pages(&writer_b, 16);
// A finishes its write and drops what it did not use.
_ = pg.alloc_pages_assume_reserved(&writer_a, 4);
pg.release_reservation(&writer_a);
try testing.expectEqual(@as(u32, 0), writer_a.pages);
// B's promise is untouched, and still claimable in full.
try testing.expectEqual(@as(u32, 16), writer_b.pages);
try testing.expectEqual(@as(u32, 16), pg.reserved_pages);
for (0..16) |_| _ = pg.alloc_pages_assume_reserved(&writer_b, 1);
try testing.expectEqual(@as(u32, 0), pg.reserved_pages);
}
test "a reservation makes the following allocation infallible" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 256 << 20);
defer tp.deinit();
const pg = tp.pg();
var hold: Reservation = .{};
try pg.reserve_pages(&hold, 64);
const before = pg.alloc_tail;
// Exactly the promised amount, one page at a time.
for (0..64) |_| _ = pg.alloc_pages_assume_reserved(&hold, 1);
try testing.expectEqual(before + 64, pg.alloc_tail);
}
test "growth past the reservation is an error, not a crash" {
// A bounded reservation must fail cleanly at its ceiling: this is the path
// a container with a tight address-space limit takes.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 1 << 30);
defer tp.deinit();
const pg = tp.pg();
const capacity: u32 = @intCast(pg.reserve.len / page_size);
try testing.expectError(Error.DatabaseTooLarge, pg.alloc_pages(capacity + 1));
// And the pager is still usable afterwards.
const p = try pg.alloc_pages(1);
@memset(pg.page_mut(p), 1);
}
test "header round-trips and rejects a foreign database" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
try testing.expect(tp.pg().fresh);
const p = try tp.pg().alloc_pages(3);
@memset(tp.pg().page_mut(p), 0x5A);
try tp.pg().sync();
const path = try testing.allocator.dupe(u8, tp.path);
defer testing.allocator.free(path);
tp.close();
// Reopening finds the same header and is not fresh.
var again = try Pager.open(testing.allocator, io, path, .{ .uuid = 7, .reserve_bytes = 64 << 20 });
try testing.expect(!again.fresh);
try testing.expectEqual(@as(u128, 7), again.uuid);
try testing.expectEqual(@as(u8, 0x5A), again.page(p)[0]);
again.deinit();
// A different uuid is a different database; pairing them would silently
// replay one database's log onto another's checkpoint.
// Mutation check: drop the uuid comparison in read_header and this passes.
try testing.expectError(
Error.DatabaseMismatch,
Pager.open(testing.allocator, io, path, .{ .uuid = 8, .reserve_bytes = 64 << 20 }),
);
}
test "a corrupt header is rejected rather than misread" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const path = try testing.allocator.dupe(u8, tp.path);
defer testing.allocator.free(path);
try tp.pg().sync();
tp.close();
// Flip a byte inside the hashed region.
{
var f = try std.Io.Dir.cwd().openFile(io, path, .{ .mode = .read_write });
defer f.close(io);
var b: [1]u8 = undefined;
_ = try f.readPositionalAll(io, &b, 6);
b[0] ^= 0xFF;
try f.writePositionalAll(io, &b, 6);
}
// Mutation check: drop the hash comparison in read_header and this passes,
// which is worse than failing — a header believed on faith describes where
// every structure in the file lives.
try testing.expectError(
Error.InvalidDataFile,
Pager.open(testing.allocator, io, path, .{ .uuid = 7, .reserve_bytes = 64 << 20 }),
);
}
/// Reopen the same file, so a test can check what survived a publish.
fn reopen(io: std.Io, path: []const u8) !Pager {
return Pager.open(testing.allocator, io, path, .{ .uuid = 7, .reserve_bytes = 64 << 20 });
}
test "a published watermark is what the next open loads" {
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const p = try tp.pg().alloc_pages(4);
@memset(tp.pg().page_mut(p), 0xC3);
try tp.pg().publish(.{ .seq = 4242, .catalog_page = p, .catalog_len = 99, .live_docs = 7 });
// After publish, not before: publish allocates the free list's own pages,
// and the watermark has to cover them.
const tail_at_publish = tp.pg().alloc_tail;
tp.close();
var again = try reopen(io, tp.path);
defer again.deinit();
try testing.expect(!again.fresh);
try testing.expectEqual(@as(u64, 1), again.loaded.generation);
try testing.expectEqual(@as(u64, 4242), again.loaded.seq);
try testing.expectEqual(p, again.loaded.catalog_page);
try testing.expectEqual(@as(u64, 99), again.loaded.catalog_len);
try testing.expectEqual(@as(u64, 7), again.loaded.live_docs);
try testing.expectEqual(tail_at_publish, again.alloc_tail);
// Everything the image references is off limits to writes.
try testing.expectEqual(tail_at_publish, again.stable_pages);
try testing.expectEqual(@as(u8, 0xC3), again.page(p)[0]);
}
test "slots alternate by generation, so a torn write leaves the older one" {
// Writing each new generation over the only copy of the previous one risks
// having *neither* valid after a crash. Alternating is what makes a torn
// slot survivable, so both halves are asserted: the parity, and that
// corrupting the newer slot falls back rather than failing.
//
// Mutation checks: always write slot A, and the fallback finds nothing;
// compare generations before validating the hash, and the torn slot wins.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
// Three generations: A, B, A.
try tp.pg().publish(.{ .seq = 10 });
try tp.pg().publish(.{ .seq = 20 });
try tp.pg().publish(.{ .seq = 30 });
try testing.expectEqual(@as(u64, 3), tp.pg().generation);
// Generation 3 is odd, so slot A. Generation 2 must still be in slot B.
const a_gen = std.mem.readInt(u64, tp.pg().page(page_watermark_a)[0..8], .little);
const b_gen = std.mem.readInt(u64, tp.pg().page(page_watermark_b)[0..8], .little);
try testing.expectEqual(@as(u64, 3), a_gen);
try testing.expectEqual(@as(u64, 2), b_gen);
tp.close();
// Tear the newer slot: the open must fall back to generation 2, not fail
// and not believe the garbage.
{
var f = try std.Io.Dir.cwd().openFile(io, tp.path, .{ .mode = .read_write });
defer f.close(io);
var junk: [64]u8 = undefined;
@memset(&junk, 0xFF);
try f.writePositionalAll(io, &junk, page_size);
}
var again = try reopen(io, tp.path);
defer again.deinit();
try testing.expectEqual(@as(u64, 2), again.loaded.generation);
try testing.expectEqual(@as(u64, 20), again.loaded.seq);
}
test "both slots unreadable opens anyway, with no checkpoint" {
// Ground rule 4: the database must always open. Once the log is truncated
// this costs data, which is why it warns -- but refusing to start would
// cost all of it.
//
// Mutation check: return an error from load_watermark instead and this
// fails outright.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
try tp.pg().publish(.{ .seq = 5 });
try tp.pg().publish(.{ .seq = 6 });
tp.close();
{
var f = try std.Io.Dir.cwd().openFile(io, tp.path, .{ .mode = .read_write });
defer f.close(io);
var junk: [page_size * 2]u8 = undefined;
@memset(&junk, 0xA5);
try f.writePositionalAll(io, &junk, page_size);
}
var again = try reopen(io, tp.path);
defer again.deinit();
try testing.expectEqual(@as(u64, 0), again.loaded.generation);
try testing.expectEqual(@as(u64, 0), again.loaded.seq);
}
test "a hash-valid but impossible slot is refused" {
// A slot that validates is *trusted*, so it has to be checked for sense as
// well as integrity: a generation of 0 or an alloc_tail below the first data
// page would be believed and would describe a file that does not exist.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
try tp.pg().publish(.{ .seq = 11 });
tp.close();
{
var f = try std.Io.Dir.cwd().openFile(io, tp.path, .{ .mode = .read_write });
defer f.close(io);
var b: [page_size]u8 = undefined;
_ = try f.readPositionalAll(io, &b, page_size); // slot A holds gen 1
// alloc_tail = 1, below page_first_data, and re-hash so it validates.
std.mem.writeInt(u64, b[16..24], 1, .little);
std.mem.writeInt(u64, b[wm_hashed_len..][0..8], header_hash(b[0..wm_hashed_len]), .little);
try f.writePositionalAll(io, &b, page_size);
}
var again = try reopen(io, tp.path);
defer again.deinit();
// Refused, so no checkpoint rather than a nonsensical one.
try testing.expectEqual(@as(u64, 0), again.loaded.generation);
}
test "freed pages are withheld for two generations and survive a reopen" {
// The delay is what keeps generation N-1 usable as a fallback image: its
// pages must still be allocated while N is current.
//
// Mutation check: move free_pending straight into free_ready at publish and
// the withholding assertions go red.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const victim = try tp.pg().alloc_pages(2);
@memset(tp.pg().page_mut(victim), 0x11);
try tp.pg().publish(.{ .seq = 1 });
try tp.pg().free_pages(victim, 2);
try testing.expectEqual(@as(u32, 0), tp.pg().free_ready_pages());
try tp.pg().publish(.{ .seq = 2 }); // pending -> hold
try testing.expectEqual(@as(u32, 0), tp.pg().free_ready_pages());
// Still intact, which is the point: generation 1 references it.
try testing.expectEqual(@as(u8, 0x11), tp.pg().page(victim)[0]);
try tp.pg().publish(.{ .seq = 3 }); // hold -> ready
try testing.expectEqual(@as(u32, 2), tp.pg().free_ready_pages());
tp.close();
// And the list itself is durable.
var again = try reopen(io, tp.path);
defer again.deinit();
try testing.expectEqual(@as(u32, 2), again.free_ready_pages());
}
test "the persisted free list survives allocating its own pages" {
// `write_freelist` allocates the pages it is about to write into, and that
// allocation goes through `take_free` like any other. On an exact fit the
// entry is removed, so a count captured beforehand describes one entry more
// than the loop writes, the hash lands short of where the reader looks, and
// the whole list is dropped as corrupt on the next open.
//
// The stream is one page and a one-page run is the commonest thing on the
// list, so this is the normal case, not a corner. The two-generation test
// above misses it because its free run is two pages and the stream asks for
// one: shrinking an entry keeps the count right, only removing it does not.
//
// Mutation check: compute `count` before `alloc_pages` again and the reopen
// assertion goes red with "data file free list is corrupt".
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
// Five pages, of which three go back one at a time with a gap between each
// -- adjacent runs would be coalesced back into one and the list would be
// too short for a lost entry to show.
const base = try tp.pg().alloc_pages(5);
try tp.pg().publish(.{ .seq = 1 });
try tp.pg().free_pages(base, 1);
try tp.pg().free_pages(base + 2, 1);
try tp.pg().free_pages(base + 4, 1);
try tp.pg().publish(.{ .seq = 2 }); // pending -> hold
try tp.pg().publish(.{ .seq = 3 }); // hold -> ready
try testing.expectEqual(@as(u32, 3), tp.pg().free_ready_pages());
// This is the publish that trips it: the free list is now non-empty and
// holds a run of exactly the one page the stream needs.
try tp.pg().publish(.{ .seq = 4 });
// Not a fixed number: a publish also recycles the streams of the generation
// two back, so what is on the list is the three frees above minus whatever
// the stream allocations took plus whatever they gave back. The invariant
// under test is that a reopen agrees with it, whatever it is.
const ready_before = tp.pg().free_ready_pages();
try testing.expect(ready_before > 0);
tp.close();
var again = try reopen(io, tp.path);
defer again.deinit();
try testing.expectEqual(ready_before, again.free_ready_pages());
}
test "the watermark is never published before the pages it describes" {
// The load-bearing ordering of the whole design: every page a watermark
// describes is durable before the watermark that describes it. Reverse them
// and a crash in between leaves a watermark pointing at pages that were
// never written, with the log already truncated below it.
//
// A kill -9 cannot produce that state -- the page cache survives the
// process -- so this simulates the writeback that never happened, using the
// pager's own record of which pages were dirtied since the last sync. That
// is the only honest way to make the ordering mutation-checkable, and
// without it the mutation "delete the sync before the watermark" leaves
// every other test in this file green.
//
// Mutation checks, stated precisely because the obvious one is not a
// violation: publish syncs twice before the watermark (data pages, then the
// free list), so removing *either* call alone is harmless and leaves this
// green -- correctly. What it catches is removing both, or moving them after
// the watermark write. The invariant is "at least one full sync between the
// last data write and the watermark", not any particular call.
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
// A page the checkpoint will reference, written and then published.
const cat = try tp.pg().alloc_pages(2);
@memset(tp.pg().page_mut(cat), 0x77);
@memset(tp.pg().page_mut(cat + 1), 0x77);
try tp.pg().publish(.{ .seq = 900, .catalog_page = cat, .catalog_len = 2 * page_size });
// After a correct publish the only page still pending is the watermark slot
// itself, which its own msync made durable. Everything else the watermark
// references was flushed first, so discarding whatever is still pending --
// minus that slot -- must leave a loadable image.
const pending = try tp.pg().dirty_pages(gpa);
defer gpa.free(pending);
const slot: u32 = if (tp.pg().generation & 1 == 1) page_watermark_a else page_watermark_b;
var lost: std.ArrayListUnmanaged(u32) = .empty;
defer lost.deinit(gpa);
for (pending) |pg_no| if (pg_no != slot) try lost.append(gpa, pg_no);
tp.close();
try Pager.simulate_lost_writeback(gpa, io, tp.path, lost.items);
var again = try reopen(io, tp.path);
defer again.deinit();
try testing.expectEqual(@as(u64, 1), again.loaded.generation);
try testing.expectEqual(@as(u64, 900), again.loaded.seq);
try testing.expectEqual(cat, again.loaded.catalog_page);
// The referenced page survived, because publish synced it first.
for (again.page(cat)) |byte| try testing.expectEqual(@as(u8, 0x77), byte);
for (again.page(cat + 1)) |byte| try testing.expectEqual(@as(u8, 0x77), byte);
}
test "copy-on-write leaves the published image byte-identical" {
// The single most important property in this milestone. Recovery is
// `image + replay(seq > watermark)`, and that is only correct because no
// page the image references is ever written *differently* after it is
// published. Everything else about the crash story follows from this.
//
// Mutation checks: make page_mut_cow return the page without copying, or
// without updating the slot, and the snapshot comparison goes red. Remove
// the stable-mark assert in page_mut and this still passes -- that assert is
// the mechanical belt for callers that bypass COW entirely, and its own test
// is "a direct write inside the image is refused" below.
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 256 << 20);
defer tp.deinit();
const pg = tp.pg();
// Build an image of distinguishable pages, each owned by a slot, and
// publish it.
var slots: [64]u32 = undefined;
for (&slots, 0..) |*slot, i| {
slot.* = try pg.alloc_pages(1);
@memset(pg.page_mut(slot.*), @truncate(i + 1));
}
try pg.publish(.{ .seq = 1 });
// Snapshot every byte the image covers.
const snapshot = try gpa.alloc(u8, @intCast(pg.stable_bytes()));
defer gpa.free(snapshot);
@memcpy(snapshot, pg.reserve[0..snapshot.len]);
// Now rewrite every one of those pages through COW.
for (&slots, 0..) |*slot, i| {
const before = slot.*;
const p = try pg.page_mut_cow(slot);
@memset(p, @truncate(0x80 + i));
// The page moved out of the image, and the slot followed it.
try testing.expect(slot.* != before);
try testing.expect(slot.* >= pg.stable_pages);
}
// The published image is untouched, byte for byte.
try testing.expectEqualSlices(u8, snapshot, pg.reserve[0..snapshot.len]);
// And the new contents are readable through the updated slots.
for (slots, 0..) |slot, i| {
const want: u8 = @truncate(0x80 + i);
try testing.expectEqual(want, pg.page(slot)[0]);
}
}
test "a page already above the mark is written in place, not copied" {
// COW must not copy what it does not have to: a page allocated since the
// last publish is not part of any image, so writing it is free. Getting this
// wrong would double the file on every write.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const pg = tp.pg();
try pg.publish(.{ .seq = 1 });
var slot = try pg.alloc_pages(1); // fresh, above the mark
const before = slot;
for (0..10) |_| {
const p = try pg.page_mut_cow(&slot);
@memset(p, 0x42);
}
try testing.expectEqual(before, slot);
}
test "a recycled page is written in place rather than copied again" {
// Mutation check: put `slot.* >= self.stable_pages` back in `page_mut_cow`
// in place of `is_unpublished`. Red -- a recycled page has a low page number
// and would be copied and freed all over again, so nothing is ever really
// reused. That is not a hypothetical: it is what the churn gate measured as
// a data file growing linearly and without bound, at 7.2x live data and
// still climbing when the run was stopped.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const pg = tp.pg();
// Free a page and let it walk all the way to reusable.
const doomed = try pg.alloc_pages(1);
@memset(pg.page_mut(doomed), 0xAA);
try pg.publish(.{ .seq = 1 });
try pg.free_pages(doomed, 1);
try pg.publish(.{ .seq = 2 });
try pg.publish(.{ .seq = 3 });
try testing.expect(pg.free_ready_pages() >= 1);
// Recycling hands it back, below the stable mark.
var slot = try pg.alloc_pages(1);
try testing.expectEqual(doomed, slot);
try testing.expect(slot < pg.stable_pages);
@memset(pg.page_mut(slot), 0xBB);
// A write through the COW path must land in place: the page is not part of
// any published image, whatever its number.
const tail_before = pg.alloc_tail;
const before = slot;
const p = try pg.page_mut_cow(&slot);
@memset(p, 0xCC);
try testing.expectEqual(before, slot);
try testing.expectEqual(tail_before, pg.alloc_tail);
try testing.expectEqual(@as(u8, 0xCC), pg.page(slot)[0]);
}
test "one-page requests do not carve up the runs the extents need" {
// Mutation check: make `take_free` first-fit again (take the first extent
// with `pages >= n`). Red -- the single-page allocations below shave the
// large run down and the extent request has to grow the file.
//
// This is the shape the churn gate hit: copy-on-write asks for one page
// thousands of times per generation while the document slab asks for
// 2048-page extents, so first fit dismantled every run before an extent
// could use it. The free list drained to empty every generation and the file
// still grew by the whole write volume.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const pg = tp.pg();
// A large run, plus enough single-page holes to serve the small requests.
// The holes are kept apart from each other and from the run by pages that
// are never freed -- otherwise coalescing merges the lot into one run and the
// test stops being about fit at all.
const run = try pg.alloc_pages(64);
_ = try pg.alloc_pages(1); // separator, never freed
var holes: [8]u32 = undefined;
for (&holes) |*h| {
h.* = try pg.alloc_pages(1);
_ = try pg.alloc_pages(1); // separator, never freed
}
try pg.publish(.{ .seq = 1 });
try pg.free_pages(run, 64);
for (holes) |h| try pg.free_pages(h, 1);
try pg.publish(.{ .seq = 2 });
try pg.publish(.{ .seq = 3 });
// Eight one-page allocations must come out of the eight holes.
for (0..holes.len) |_| _ = try pg.alloc_pages(1);
const tail_before = pg.alloc_tail;
// So the run is still whole and the extent request is served from it.
const reused = try pg.alloc_pages(64);
try testing.expectEqual(run, reused);
try testing.expectEqual(tail_before, pg.alloc_tail);
}
test "a slab run comes off the free list aligned, or not at all" {
// `alloc_slab_run` is a second allocation policy in the same allocator, so
// what it must not do is as important as what it must:
//
// - what it hands out starts and ends on a system-page boundary, because
// that is the granularity writeback tears at and the granularity
// reclamation gives back at;
// - a run too short to be worth an extent is left alone;
// - the pieces it trims off stay on the free list rather than leaking.
//
// Mutation checks: drop the `alignForward`/`alignBackward` and the first
// assertion goes red on the odd-page run; drop the `usable < min_pages`
// test and the short run is handed out.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const pg = tp.pg();
const spp: u32 = pages_per_map_align;
// A long run deliberately starting one 4 KiB page past a boundary, and a
// short one, kept apart so coalescing cannot merge them.
_ = try pg.alloc_pages(std.mem.alignForward(u32, pg.alloc_tail, spp) - pg.alloc_tail + 1);
const long = try pg.alloc_pages(400);
_ = try pg.alloc_pages(1); // separator, never freed
const short = try pg.alloc_pages(8);
_ = try pg.alloc_pages(1); // separator, never freed
try testing.expect(long % spp != 0);
try pg.publish(.{ .seq = 1 });
try pg.free_pages(long, 400);
try pg.free_pages(short, 8);
try pg.publish(.{ .seq = 2 });
try pg.publish(.{ .seq = 3 });
const ready_before = pg.free_ready_pages();
var hold: Reservation = .{};
try pg.reserve_pages(&hold, 256);
const run = pg.alloc_slab_run(&hold, 64, 256) orelse return error.TestUnexpectedResult;
pg.release_reservation(&hold);
try testing.expectEqual(@as(u32, 0), run.first % spp);
try testing.expectEqual(@as(u32, 0), run.pages % spp);
try testing.expectEqual(@as(u32, 256), run.pages);
// It came out of the long run, not the short one, and past its unaligned
// first page.
try testing.expect(run.first > long);
try testing.expect(run.first < long + 400);
// Everything not handed out is still on the list.
try testing.expectEqual(ready_before - 256, pg.free_ready_pages());
// The short run is below the floor and stays where it is, whatever is asked
// of it; nothing else is left long enough either.
var hold2: Reservation = .{};
try pg.reserve_pages(&hold2, 256);
try testing.expect(pg.alloc_slab_run(&hold2, 200, 256) == null);
pg.release_reservation(&hold2);
try testing.expectEqual(ready_before - 256, pg.free_ready_pages());
}
test "a quiet checkpoint stops growing the file" {
// Both streams a publish writes are allocated fresh every time, so that a
// crash leaves the previous copy readable. Nothing gave them back, and a
// database checkpoints on log volume rather than on having anything to say
// -- so an idle server grew the data file forever.
//
// Steady state is not zero growth: a publish allocates this generation's
// streams and frees the ones from two generations back, and those take two
// more publishes to become reusable. So a few pages are always in flight,
// and the assertion is that the number does not track the publish count.
//
// Mutation check: drop the two `free_pages` calls from `publish` and this
// goes red at 40-something pages instead of a handful.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
// Let the rotation reach its steady state before measuring.
for (1..5) |i| try tp.pg().publish(.{ .seq = i });
const settled = tp.pg().alloc_tail;
for (5..45) |i| try tp.pg().publish(.{ .seq = i });
try testing.expect(tp.pg().alloc_tail - settled <= 8);
}
test "concurrent frees lose no pages while a publish rotates the lists" {
// `free_pages` mutates the same three lists `publish` rotates, and its hot
// caller is `page_mut_cow` under a *collection* lock -- so two collections
// copying nodes concurrently were appending to one `ArrayList` unserialized
// while a checkpoint moved it out from under them.
//
// Pages are conserved across the rotation and across coalescing, so the sum
// over all three lists is the invariant to assert. Probabilistic by nature,
// as any test of a data race is: it says nothing when green and is only
// evidence when red. Mutation check: drop the lock from `free_pages` and
// this fails or crashes within a few runs.
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const freers = 4;
const per_freer = 200;
// One page each, allocated up front so no fiber is also growing the file.
var pages: [freers * per_freer]u32 = undefined;
for (&pages) |*p| p.* = try tp.pg().alloc_pages(1);
try tp.pg().publish(.{ .seq = 1 });
const Worker = struct {
fn freer(p: *Pager, run: []const u32) error{Canceled}!void {
for (run) |page| p.free_pages(page, 1) catch return error.Canceled;
}
fn publisher(p: *Pager, seq: *std.atomic.Value(u64)) error{Canceled}!void {
for (0..8) |_| {
p.publish(.{ .seq = seq.fetchAdd(1, .monotonic) }) catch return error.Canceled;
}
}
};
var seq = std.atomic.Value(u64).init(2);
var group: std.Io.Group = .init;
defer group.cancel(io);
for (0..freers) |i| {
group.async(io, Worker.freer, .{ tp.pg(), pages[i * per_freer ..][0..per_freer] });
}
group.async(io, Worker.publisher, .{ tp.pg(), &seq });
try group.await(io);
// Page identity, not a total: the publisher's own free-list streams are
// allocated *off this list*, so a plain count would be short by however many
// publishes found a fit. Every page still on the list must therefore be one
// the freers put there, exactly once -- a lost or half-written append shows
// up as a duplicate or as a page nobody freed, neither of which recycling
// can produce.
const lo = pages[0];
var seen = try std.DynamicBitSetUnmanaged.initEmpty(gpa, pages.len);
defer seen.deinit(gpa);
var on_list: usize = 0;
for ([_][]const Extent{
tp.pg().free_ready.items,
tp.pg().free_hold.items,
tp.pg().free_pending.items,
}) |list| for (list) |e| {
for (0..e.pages) |i| {
const p = e.first + @as(u32, @intCast(i));
// Pages outside the set the freers own are the publisher's own
// stream runs coming back two generations later; they are not what
// this test is about.
if (p < lo or p - lo >= pages.len) continue;
try testing.expect(!seen.isSet(p - lo));
seen.set(p - lo);
on_list += 1;
}
};
// The only pages missing are the ones a publish recycled into its stream,
// and there were nine publishes at one page each.
try testing.expect(pages.len - on_list <= 9);
}
test "freed pages that touch merge back into a usable run" {
// Mutation check: drop the `coalesce_free_ready()` call from `publish`. Red
// -- the four one-page frees below stay four separate holes and the run of
// four has to come from the tail. Over a real workload the free list only
// ever fragments, so an 8 MiB extent request never finds a home however much
// free space has accumulated.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const pg = tp.pg();
var pages: [4]u32 = undefined;
for (&pages) |*x| x.* = try pg.alloc_pages(1);
try pg.publish(.{ .seq = 1 });
// Freed out of order, one page at a time, which is how copy-on-write frees.
try pg.free_pages(pages[2], 1);
try pg.free_pages(pages[0], 1);
try pg.free_pages(pages[3], 1);
try pg.free_pages(pages[1], 1);
try pg.publish(.{ .seq = 2 });
try pg.publish(.{ .seq = 3 });
const tail_before = pg.alloc_tail;
const run = try pg.alloc_pages(4);
try testing.expectEqual(pages[0], run);
try testing.expectEqual(tail_before, pg.alloc_tail);
}
test "freed pages are recycled rather than growing the file" {
// Copy-on-write abandons every page it touches, in every generation, so
// without reuse a write-heavy workload grows the file by
// `generations x touched_set` without bound. That is why PLAN amendment A2
// makes the free list a prerequisite rather than the defense-in-depth D6.2
// assumed.
//
// Mutation check: make alloc_pages_assume_reserved skip take_free and the
// tail assertion goes red.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const pg = tp.pg();
var slots: [32]u32 = undefined;
for (&slots, 0..) |*slot, i| {
slot.* = try pg.alloc_pages(1);
@memset(pg.page_mut(slot.*), @truncate(i + 1));
}
try pg.publish(.{ .seq = 1 });
// Three rounds of rewriting every page. The first COWs each one and frees
// the original; two publishes later those originals come back.
var tail_after_first: u32 = 0;
for (0..3) |round| {
for (&slots) |*slot| {
const p = try pg.page_mut_cow(slot);
@memset(p, @truncate(round));
}
try pg.publish(.{ .seq = round + 2 });
if (round == 0) tail_after_first = pg.alloc_tail;
}
// By round three the freed pages are being handed back, so the tail has
// stopped climbing by a full working set per round.
const grew = pg.alloc_tail - tail_after_first;
try testing.expect(grew < slots.len * 2);
// And the data is still correct through the current slots.
for (slots) |slot| try testing.expectEqual(@as(u8, 2), pg.page(slot)[0]);
}
test "the published image is made hardware read-only where the build allows it" {
// The belt described in `protect_stable`. It exists for the mistake the
// structural mechanism cannot catch: a caller writing through a `*Node`
// obtained *before* the stable mark moved.
//
// What is asserted here is that the protection is really applied, not that a
// violating write faults -- a SIGSEGV cannot be caught in-process, so
// verifying the fault would need a child process, and the fault itself is
// the OS's behaviour rather than this code's. An mprotect that silently
// failed would leave a belt that looks present and does nothing, which is
// the failure worth guarding against here.
if (!protect_stable) return; // ReleaseFast: deliberately off
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const pg = tp.pg();
// Enough pages that the protected range spans at least one system page even
// where those are 16 KiB.
var i: usize = 0;
while (i < 64) : (i += 1) {
const p = try pg.alloc_pages(1);
@memset(pg.page_mut(p), 0x31);
}
try pg.publish(.{ .seq = 1 });
try testing.expect(pg.protect_ok);
// Reads through the protection still work.
try testing.expectEqual(@as(u8, 0x31), pg.page(page_first_data)[0]);
}
test "an empty file is treated as absent, not as corruption" {
// A create that died before its header landed must not look like a corrupt
// database — the engine has to be able to open.
var threaded: std.Io.Threaded = .init_single_threaded;
defer threaded.deinit();
const io = threaded.io();
var tp = try TmpPager.init(io, 64 << 20);
defer tp.deinit();
const path = try testing.allocator.dupe(u8, tp.path);
defer testing.allocator.free(path);
tp.close();
{
var f = try std.Io.Dir.cwd().openFile(io, path, .{ .mode = .read_write });
defer f.close(io);
try f.setLength(io, 0);
}
tp.pager = try Pager.open(testing.allocator, io, path, .{ .uuid = 7, .reserve_bytes = 64 << 20 });
try testing.expect(tp.pg().fresh);
}