kimi-code / packages /minidb /src /compaction.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw History Blame Contribute Delete
19 kB
// src/compaction.ts
//
// WAL compaction (a.k.a. snapshot + rewrite).
//
// This is a NON-BLOCKING variant, modelled on Redis's BGREWRITEAOF and
// Bitcask's merge: while the (potentially large) snapshot is being written,
// writers keep appending to the live WAL — the WAL itself acts as the
// "rewrite buffer". Writes are blocked only for the *rotation* critical
// section at the very end (a flush + a bounded tail copy + two renames).
//
// Phases:
// 1. fence — flush the WAL, record baseOffset = wal.size. Every write
// durable at/before baseOffset is already reflected in the
// store (applyOp runs synchronously before the WAL write is
// awaited).
// 2. snapshot — writeSnapshot(store, tmp). NON-BLOCKING. Writers keep
// appending to the WAL and mutating the store while we
// iterate. The snapshot need NOT be point-in-time: the WAL
// tail copied below is replayed last-writer-wins on top of
// it, repairing any fuzziness.
// 2.5 pre-copy — stream WAL[baseOffset .. head] into db.wal.tmp, draining
// the bulk of the post-fence tail. NON-BLOCKING. Loops while
// the copy is CONVERGING (the remaining delta shrinks fast
// enough) and gives up after a few passes otherwise —
// chasing a tail under writes that append as fast as the
// copy drains would otherwise never terminate, stalling
// compaction for as long as the write storm lasts.
// 3. rotation — BLOCKING critical section: set _rotateLock so new writers
// park, seal the old WAL (post-seal appends fail fast and
// are retried by the op against the new WAL), flush, then
// copy the remaining tail. With the WAL sealed and writers
// parked the head no longer moves, so this copy provably
// finishes; the pause scales with the tail the pre-copy did
// not drain — the same bounded end-of-rewrite pause Redis
// accepts for its AOF diff flush.
// 4. bookkeeping — stats + awaiting onCompacted() (stage 5: build and
// publish the new index generation — store image, index
// images, text postings — as one transaction with the
// rotated snapshot/WAL; legacy mode rebuilds derived text
// postings instead).
//
// Crash safety: recovery is `load db.snapshot` + `replay db.wal`, last-writer
// wins. We rename the snapshot BEFORE the WAL. If a crash lands between the two
// renames, the new snapshot is paired with the old full WAL — replaying the
// whole old WAL on top of the new snapshot is idempotent for pre-fence frames
// and correct for post-fence frames, so the state is still consistent. The
// reverse order (WAL first) would pair an old snapshot with a truncated new WAL
// and lose pre-fence data. The argument only holds when each rename is durable
// before the next one lands, so the rotation's directory fsyncs are STRICT: a
// failed dir fsync aborts the rotation (rolling back through the catch in
// runCompaction) rather than silently weakening the invariant. Platforms that
// cannot fsync a directory degrade explicitly instead — a one-time warning and
// stats.dirFsyncUnsupported = true.
import fs from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import path from 'node:path';
import { WAL } from './wal.js';
import { renameReplace } from './rename-replace.js';
import { writeSnapshot } from './snapshot.js';
import type { Store, ValueLoc } from './store.js';
import type { FsyncPolicy, WalStats } from './wal.js';
/** Structural interface of the bits compaction needs from a MiniDb. */
export interface CompactionTarget {
dir: string;
walPath: string;
fsyncPolicy: FsyncPolicy;
/** Background-sync interval the replacement WALs inherit (see WALOptions). */
syncIntervalMs?: number;
store: Store;
wal: WAL;
compactThresholdBytes: number;
compacting: boolean;
_compactDone: Promise<void> | null;
/** Set only during the short rotation critical section; writers park on it.
* Null outside rotation, so the snapshot phase is fully non-blocking. */
_rotateLock: Promise<void> | null;
lastCompactError: unknown;
stats: WalStats & {
compactions: number;
snapshotBytesWritten: number;
compactErrors?: number;
/** Cumulative phase timings (wall-clock ms). Optional so structural test
* doubles need not carry them; MiniDb always provides them. */
compactionDurationMs?: number;
compactionSnapshotDurationMs?: number;
compactionRotationDurationMs?: number;
/** Set (once) when a directory fsync reported EINVAL/ENOTSUP: this
* platform cannot make renames durable via the directory, so rotation
* durability is knowingly degraded (warned once) rather than aborted. */
dirFsyncUnsupported?: boolean;
};
/** Reader for disk-backed values; reopened after snapshot/WAL rotation so
* remapped value pointers read from the new files. On Windows it is also
* closed before the rotation renames (see rotateReplace). The optional
* readAsync powers the stage-6 grouped async snapshot reads. */
valueReader?: {
reopenBoth(): void;
close?(): void;
readAsync?(loc: ValueLoc): Promise<Buffer>;
};
/** Optional hook invoked (and awaited) after the snapshot + WAL rotation
* succeeds, so the owner can publish derived on-disk state (stage 5's
* index generation; legacy mode: text postings) against the new live set. */
onCompacted?: () => void | Promise<void>;
/** Stage 6 maintenance integration: the rotation critical section is the
* compaction's "publishing" phase — the scheduler WAITS for it on
* shutdown instead of cancelling mid-rotation. Called with 'publishing'
* as the rotation starts and 'running' as it ends (both in the failure
* path and the success path). */
onMaintenancePhase?: (phase: 'running' | 'publishing') => void;
}
export function shouldCompact(db: CompactionTarget): boolean {
return Boolean(db.wal && db.wal.size >= db.compactThresholdBytes);
}
const COPY_CHUNK = 1 << 20; // 1 MiB read/write coalescing
// A post-fence WAL delta at or below this size is cheap enough to copy inside
// the rotation critical section, so the pre-copy loop stops draining.
const SMALL_DELTA = 64 * 1024; // 64 KiB
// Windows cannot rename over an open destination; rotation uses the shared
// retrying replace helper (see rename-replace.ts).
const rotateReplace = (src: string, dst: string): Promise<void> => renameReplace(src, dst);
// Pre-copy convergence bounds: each pass costs roughly `gap / copyRate` and
// appends `gap * (appendRate / copyRate)` new bytes during the copy. Give up
// when a pass fails to shrink the gap meaningfully (appendRate ≳ copyRate),
// or after this many passes regardless — the rotation critical section (with
// the WAL sealed and writers parked) then absorbs the remaining tail.
const MAX_PRECOPY_PASSES = 5;
const CONVERGE_RATIO = 0.7;
export function isUnsupportedDirectoryFsyncError(
code: string | undefined,
platform: NodeJS.Platform = process.platform,
): boolean {
return code === 'EINVAL' || code === 'ENOTSUP' || (platform === 'win32' && code === 'EPERM');
}
export async function fsyncDir(
dir: string,
opts: { strict?: boolean; stats?: { dirFsyncUnsupported?: boolean } } = {},
): Promise<void> {
let fh: FileHandle | null = null;
try {
fh = await fs.open(dir, 'r');
await fh.sync();
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
// Some platforms cannot fsync a directory at all. That is a permanent
// environment property, not a rotation fault: mark the degraded durability
// state and continue without directory fsync in both modes.
if (isUnsupportedDirectoryFsyncError(code)) {
if (opts.stats) opts.stats.dirFsyncUnsupported = true;
return;
}
// Strict mode (the rotation path): a failed directory fsync breaks the
// rename-durability invariant, so the caller must abort — never swallow.
if (opts.strict) throw e;
/* best-effort otherwise */
} finally {
if (fh) await fh.close().catch(() => {});
}
}
/** Stream src[start:end] into dst, fsync'ing dst before returning. With
* `append: true` the bytes are appended to an existing dst; otherwise dst is
* created/truncated. Uses its own file handles, independent of the WAL's
* append handle, so it is safe to read the live WAL while writers append. A
* zero-length range still creates/truncates dst (so the new WAL file exists
* even when there is no post-fence tail). */
export async function copyFileRange(
srcPath: string,
dstPath: string,
start: number,
end: number,
opts: { append?: boolean } = {},
): Promise<void> {
if (end < start) throw new RangeError(`copyFileRange: end (${end}) < start (${start})`);
const dst = await fs.open(dstPath, opts.append ? 'a' : 'w');
try {
if (end > start) {
const src = await fs.open(srcPath, 'r');
try {
const buf = Buffer.allocUnsafe(COPY_CHUNK);
let pos = start;
while (pos < end) {
const len = Math.min(buf.length, end - pos);
const { bytesRead } = await src.read(buf, 0, len, pos);
if (bytesRead === 0) break; // reached EOF earlier than expected
let written = 0;
while (written < bytesRead) {
const { bytesWritten } = await dst.write(buf, written, bytesRead - written);
if (bytesWritten === 0) throw new Error('copyFileRange: write made no progress (short write)');
written += bytesWritten;
}
pos += bytesRead;
}
} finally {
await src.close().catch(() => {});
}
}
await dst.sync();
} finally {
await dst.close().catch(() => {});
}
}
export async function compact(db: CompactionTarget): Promise<void> {
if (db.compacting) return db._compactDone ?? undefined;
db.compacting = true;
db._compactDone = (async () => {
const t0 = performance.now();
try {
await runCompaction(db);
// The onCompacted hook is part of the compaction: a run whose hook
// throws is counted as a compactError, not a successful compaction.
await db.onCompacted?.();
db.stats.compactions++;
db.stats.compactionDurationMs = (db.stats.compactionDurationMs ?? 0) + (performance.now() - t0);
db.lastCompactError = null;
} catch (err) {
db.stats.compactErrors = (db.stats.compactErrors ?? 0) + 1;
db.lastCompactError = err;
throw err;
} finally {
db.compacting = false;
// A failed rotation must not leave writers parked forever.
db._rotateLock = null;
}
})();
return db._compactDone;
}
async function runCompaction(db: CompactionTarget): Promise<void> {
const tmp = path.join(db.dir, 'db.snapshot.tmp');
const snap = path.join(db.dir, 'db.snapshot');
const walTmp = path.join(db.dir, 'db.wal.tmp');
// Phase 1: fence. Every write durable at/before baseOffset is already
// reflected in the store, because applyOp() runs synchronously in the same
// tick as wal.append(), before the op awaits the WAL write.
await db.wal.flush();
const baseOffset = db.wal.size;
// Phase 2: snapshot. NON-BLOCKING — writers keep appending to the WAL and
// mutating the store while we iterate. Fuzziness is repaired by the tail.
// Stage 6: in disk valueMode the snapshot's value reads run through the
// async grouped reader (bounded concurrency, slice budget) instead of one
// synchronous positioned read per record on the event loop.
const snapT0 = performance.now();
const snapRes = await writeSnapshot(db.store, tmp, {
readValueAsync: db.valueReader?.readAsync ? (loc) => db.valueReader!.readAsync!(loc) : undefined,
});
db.stats.snapshotBytesWritten += snapRes.bytes;
db.stats.compactionSnapshotDurationMs = (db.stats.compactionSnapshotDurationMs ?? 0) + (performance.now() - snapT0);
// Phase 2.5: pre-copy the post-fence WAL tail into db.wal.tmp. NON-BLOCKING.
// Each pass flushes to get a stable `head`, then copies the bytes that landed
// since the previous pass. The loop only continues while it is CONVERGING:
// under sustained writes whose append rate approaches the copy rate the gap
// stops shrinking, and looping until it was small enough would never
// terminate (stalling compaction for the whole write storm — observed in the
// field as compactions=0 forever while the WAL grew unboundedly). Give up to
// the rotation critical section instead, which finishes because the sealed
// WAL + parked writers freeze the head.
let copiedUpTo = baseOffset;
let appended = false;
let prevGap = Number.POSITIVE_INFINITY;
for (let pass = 0; pass < MAX_PRECOPY_PASSES; pass++) {
await db.wal.flush();
const head = db.wal.size;
const gap = head - copiedUpTo;
if (gap <= SMALL_DELTA) break;
if (pass > 0 && gap > prevGap * CONVERGE_RATIO) break; // not converging: rotate with a parked writer set
await copyFileRange(db.walPath, walTmp, copiedUpTo, head, { append: appended });
appended = true;
copiedUpTo = head;
prevGap = gap;
}
// Phase 3: rotation. BLOCKING critical section.
//
// Setting _rotateLock is synchronous and happens-before the seal below. New
// writers park on the lock; an in-flight writer that passed the gate check
// just before the lock landed cannot have its append slip between the final
// flush and close(), because seal() makes any post-seal append fail fast
// (the op retries against the new WAL once the rotation is done). With the
// old WAL sealed, its head no longer moves after this drain loop, so the
// loop provably terminates — at the cost of a write pause proportional to
// the tail the pre-copy could not drain.
//
// Recovery: the seal is one-way and the old WAL object is single-use, so a
// failure anywhere between the seal and the new WAL's open would leave every
// later write hitting WAL_SEALED/'WAL is closed' forever. The catch below
// rolls the db forward to a writable state by swapping in a FRESH WAL on
// db.walPath (it appends at the real EOF of whatever file the path now
// holds). `rotated` tracks the commit point: before the WAL rename the old
// full WAL is still at db.walPath and the old store pointers stay valid (a
// renamed-in new snapshot paired with the old WAL is consistent — see the
// crash-safety note above); past it, the new layout is on disk and recovery
// must additionally apply the store-pointer remap + reader reopen. If the
// rollback itself also fails (e.g. persistent EMFILE), the db stays
// unwritable but the on-disk snapshot/WAL pair is consistent either way, so
// the next process open still recovers.
let releaseRotation!: () => void;
db._rotateLock = new Promise<void>((resolve) => {
releaseRotation = resolve;
});
const rotateT0 = performance.now();
// Stage 6: from here to the remap/reader reopen, a shutdown must wait for
// the rotation rather than cancelling it mid-flight.
db.onMaintenancePhase?.('publishing');
let rotated = false;
let remapped = false;
// Remap disk-backed value pointers to the new snapshot/WAL files. Guarded
// against double application: the wal-offset shift is NOT idempotent.
const remap = (): void => {
if (remapped) return;
const snapLocs = snapRes.locs;
db.store.remapLocs((k: string, loc: ValueLoc) => {
if (loc.file === 'wal' && loc.off >= baseOffset) {
return { file: 'wal', off: loc.off - baseOffset, len: loc.len };
}
return snapLocs.get(k);
});
remapped = true;
};
try {
db.wal.seal();
for (;;) {
await db.wal.flush();
const endOffset = db.wal.size;
// `!appended` guarantees the (possibly empty) new WAL file is created
// even when there is no post-fence tail to copy.
if (endOffset === copiedUpTo && appended) break;
await copyFileRange(db.walPath, walTmp, copiedUpTo, endOffset, { append: appended });
appended = true;
copiedUpTo = endOffset;
}
await db.wal.close();
// Windows cannot rename over an open destination, so our own ValueReader
// must let go of the old snapshot/WAL before the renames below. POSIX
// keeps the handles across the rotation (old fd reads the unlinked old
// inode) — no close needed there; after the remap segment below,
// reopenBoth() re-attaches both handles on every platform.
if (process.platform === 'win32') db.valueReader?.close?.();
// Snapshot first, then WAL — see the crash-safety note in the file header.
// That argument assumes each rename is durable before the next one lands,
// so the directory fsyncs here are STRICT: a failure aborts the rotation
// (the catch below rolls back) instead of silently weakening the
// invariant. Platforms without directory fsync degrade via fsyncDir itself.
await rotateReplace(tmp, snap);
await fsyncDir(db.dir, { strict: true, stats: db.stats });
await rotateReplace(walTmp, db.walPath);
rotated = true;
await fsyncDir(db.dir, { strict: true, stats: db.stats });
const fresh = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, syncIntervalMs: db.syncIntervalMs, stats: db.stats });
db.wal = fresh;
await fresh.open();
// Commit the in-memory view to the new files. Do this in the same
// synchronous segment as the fd reopen, so synchronous readers can never
// observe a new pointer against an old fd or vice versa.
remap();
db.valueReader?.reopenBoth();
} catch (err) {
try {
// Swap the sealed/closed WAL for a fresh handle on db.walPath. The swap
// comes first: it both restores appendability and stops late in-flight
// writers from publishing old-file value pointers against the fresh WAL.
await db.wal.close().catch(() => {});
const fresh = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, syncIntervalMs: db.syncIntervalMs, stats: db.stats });
await fresh.open();
db.wal = fresh;
if (rotated) {
remap();
db.valueReader?.reopenBoth();
}
} catch {
// Best-effort recovery only — on-disk state is consistent regardless.
}
throw err;
} finally {
releaseRotation();
db._rotateLock = null;
db.onMaintenancePhase?.('running');
// Wall time of the rotation critical section — the window writers were
// parked (their per-op waits accumulate separately in MiniDb's
// compactionRotationPauseMs).
db.stats.compactionRotationDurationMs = (db.stats.compactionRotationDurationMs ?? 0) + (performance.now() - rotateT0);
}
}