db/pager: an append resumes inside its extent after a checkpoint
`slab_reserve` and `reserve_overflow` abandoned the rest of their extent whenever a checkpoint froze the page the tail pointed into, and took a fresh 8 MiB one. The comment called the waste "bounded by one extent per collection per checkpoint", which is true per checkpoint and says nothing about the sum: nothing reclaims it except a rebuild, and a rebuild only runs when there is garbage. A pure-insert workload produces none. Measured, 40 collections of inserts with incompressible payloads so the log actually reaches the checkpoint threshold: live data file log 29 MB 340 MB 29 MB 38 MB 542 MB 5 MB <- checkpoint 67 MB 681 MB 33 MB 76 MB 1076 MB 9 MB <- checkpoint 115 MB 1357 MB 14 MB <- checkpoint 11.8x the live data and climbing by ~335 MB per checkpoint (40 x 8 MiB), which would exhaust the 64 GB address-space reservation after roughly 6 GB of real data -- and after ~1.2 GB with 200 collections. `DatabaseTooLarge` on a database that is nowhere near too large. The fix is what the plan called for and never got: round the cursor up to the next *system* page and keep the extent. Only the page holding the live tail is in the published image; the rest of the extent holds nothing referenced by the image or by an index, so `Pager.mark_appendable` hands it back for appending (and unprotects it, since it may sit below the stable mark where `protect_image` made it read-only). System pages rather than 4 KiB ones because writeback tears at the granularity the kernel manages: a 4 KiB store dirties a whole 16 KiB page on Apple Silicon, and tearing there would take out the published bytes sharing it. Same 40 collections after: 340 MB -> 352 MB across three checkpoints, the ratio falling monotonically toward the 8 MiB-per-collection floor. 64,000 documents across 8 collections verified byte-for-byte and after a kill -9. The churn gate is unchanged at 1.65x, big.js at 4 GB unchanged (4.32 GB file, reopen 0.5 s, RSS after reopen 130 MB). Two mutations, verified red: dropping the resume branch (a fresh extent per checkpoint), and rounding to `page_size` instead of `map_align` (the resumed append then shares a system page with the published image).
This commit is contained in:
@@ -425,6 +425,44 @@ pub const Pager = struct {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user