File size: 21,250 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 | // src/wal.ts
//
// Write-ahead log: buffered, append-only, group-committed, with three fsync
// policies matching Redis AOF.
//
// 'always' β write + fsync for every flush (safest, slowest)
// 'everysec' β write every flush; fsync on a 1s timer, but only while there
// are writes not yet covered by a successful fsync (default;
// β€1s loss window; an idle WAL never fsyncs)
// 'no' β write only; let the OS flush (fastest, may lose seconds)
//
// Group commit: all append() calls within a tick are coalesced into a single
// writev(2) syscall on the next macrotask. Only one flush is ever in flight, so
// frames reach disk strictly in append order (single-writer, like SQLite WAL).
//
// Write-failure semantics (the commit point): a writev or write-path fsync
// failure in a flush POISONS the WAL. The poison records the failed batch's
// first predicted offset β every byte at/after it may have reached the file
// without being acknowledged. While poisoned, appends reject with
// 'WAL_POISONED', flush() throws, sync() is a no-op, and close() skips its
// final flush. The owner (MiniDb) recovers in place: truncate the file to the
// poison offset (removing exactly the un-acked bytes), refreshSize(), then
// clearPoison(). A background everysec sync failure never poisons: it rejects
// no write and is observable only in stats (stage-1 semantics).
import fs from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import { OpTracker } from './op-tracker.js';
export type FsyncPolicy = 'always' | 'everysec' | 'no';
const POLICIES = new Set<FsyncPolicy>(['always', 'everysec', 'no']);
interface PendingWrite {
buf: Buffer;
resolve: () => void;
reject: (err: unknown) => void;
}
/** The failure that poisoned the WAL plus the offset the owner must truncate
* the file to for in-place recovery: every acknowledged frame sits in
* earlier, successful batches, so truncating here removes exactly the
* un-acked bytes. */
export interface WalPoison {
failedAtOffset: number;
error: unknown;
}
/** Cumulative WAL counters, owned by MiniDb so they survive WAL rotation
* during compaction (which replaces the WAL). */
export interface WalStats {
walBytesWritten: number;
/** Successful fsyncs (write-path 'always', background 'everysec', close). */
walFsyncs: number;
/** Failed writev-class attempts on the write path. Each one poisons the WAL
* (see the header) and triggers the owner's in-place recovery. */
walWriteErrors: number;
/** Failed fsync attempts. A background everysec failure does not reject any
* write β it is observable only here and via lastWalFsyncError. A
* write-path ('always') failure rejects its batch and poisons the WAL. */
walFsyncErrors: number;
/** Sticky copy of the most recent fsync failure (never cleared on success). */
lastWalFsyncError: unknown;
/** Bytes currently sitting in the in-memory append queue. */
walQueuedBytes: number;
/** High-water mark of walQueuedBytes. */
walMaxQueuedBytes: number;
/** Number of group commits (one per flushed batch). */
walGroupCommits: number;
/** Total frames carried by those group commits. */
walGroupCommitFrames: number;
}
export interface WALOptions {
fsyncPolicy?: FsyncPolicy;
syncIntervalMs?: number;
/** Optional sink for cumulative write/fsync counters. Owned by MiniDb so the
* counts survive WAL rotation during compaction (which replaces the WAL). */
stats?: WalStats;
}
export class WAL {
readonly path: string;
private readonly policy: FsyncPolicy;
private readonly syncIntervalMs: number;
private fh: FileHandle | null = null;
size = 0; // bytes on disk (best-effort; updated after each write)
private nextOffset = 0; // logical next append offset, including queued/in-flight frames
private queue: PendingWrite[] = [];
private queuedBytes = 0;
private flushing = false;
private inflight: Promise<unknown> | null = null;
private scheduled = false;
/** When sealed, appendLoc rejects new frames (code 'WAL_SEALED') while the
* already-queued frames can still be flushed. Compaction rotation seals the
* old WAL so no append can slip between the final flush and close(): any
* frame that will ever land in the old file is durable after one flush. */
private sealed = false;
/** Set by the first failed flush (or poisonPending): the WAL stops accepting
* appends until the owner recovers it in place (see the header). Distinct
* from `sealed`: seal is a normal compaction rotation (rejections are
* retried against the new WAL), poison is a fault state (hard failures). */
private poisoned: WalPoison | null = null;
/** Id of the batch the next drain will carry. appendLoc stamps each frame
* with it so the owner can track per-flush-group pre-state; flushBatch
* increments it at drain time, so same-tick appends always share an id. */
private nextBatchId = 1;
private timer: ReturnType<typeof setInterval> | null = null;
private closed = false;
private readonly stats: WalStats | null;
/** Durability watermark, Redis-AOF style: writeGen counts the writev batches
* that landed in the OS page cache, syncedGen the watermark the last
* successful fsync is known to cover. The WAL is dirty while they differ.
* A generation (not a boolean) so a successful fsync never clears writes a
* concurrent flush landed while the fsync was in flight. */
private writeGen = 0;
private syncedGen = 0;
/** Set while a background (everysec) sync is in flight, so a slow fsync
* never stacks a second background fsync on top of itself. */
private bgSyncing = false;
/** Tracks every in-flight background sync so close() can drain them before
* the final flush/sync/fd-close (review #13): the timer only FIRES ticks,
* the tracker owns their lifetimes. */
private readonly bgSync = new OpTracker();
constructor(path: string, opts: WALOptions = {}) {
const policy = opts.fsyncPolicy ?? 'everysec';
if (!POLICIES.has(policy)) throw new RangeError(`unknown fsyncPolicy: ${policy}`);
this.path = path;
this.policy = policy;
this.syncIntervalMs = opts.syncIntervalMs ?? 1000;
this.stats = opts.stats ?? null;
}
async open(): Promise<void> {
if (this.fh) return;
this.fh = await fs.open(this.path, 'a'); // create + append at EOF
const st = await this.fh.stat();
this.size = st.size;
this.nextOffset = st.size;
if (this.policy === 'everysec') {
this.timer = setInterval(() => {
void this.backgroundTick();
}, this.syncIntervalMs);
this.timer.unref?.();
}
}
/** One everysec background-sync tick. Extracted from the timer callback so
* tests can drive it deterministically (the wal/stats suites open with a
* huge syncIntervalMs and call this directly instead of racing the wall
* clock). Returns the tracked sync's settle promise, or null when the tick
* was skipped: the WAL is clean (idle ticks must not fsync β the previous
* unconditional fsync cost one syscall + disk wake-up per second for the
* database's whole lifetime), a sync is already in flight, or close() shut
* the tracker's gate. Sync failures do not reject any write (the page-cache
* copy is the acknowledged one); they are recorded in stats.walFsyncErrors /
* lastWalFsyncError instead of being silently swallowed. */
private backgroundTick(): Promise<void> | null {
if (this.writeGen === this.syncedGen || this.bgSyncing) return null;
if (!this.bgSync.enter()) return null;
this.bgSyncing = true;
const run = this.sync()
.catch(() => {})
.finally(() => {
this.bgSyncing = false;
this.bgSync.leave();
});
return run;
}
/** Reject new appends from now on; already-queued frames stay flushable.
* Idempotent. */
seal(): void {
this.sealed = true;
}
/** Append one frame and return its predicted absolute file offset plus the
* id of the flush batch that will carry it. The offset is known
* synchronously because frames are flushed strictly in append order.
* NOTE: the frame's bytes are NOT in the file yet β they sit in the in-memory
* queue until a later writev lands β so the offset must not be published as a
* disk value pointer before `done` resolves: a synchronous positioned read in
* that window would hit a short read past the current end of the file.
* batchId is -1 for frames that never entered a group (immediate
* rejections: closed/poisoned/sealed/invalid). */
appendLoc(frame: Buffer): { offset: number; batchId: number; done: Promise<void> } {
if (this.closed) return { offset: -1, batchId: -1, done: Promise.reject(new Error('WAL is closed')) };
if (this.poisoned) return { offset: -1, batchId: -1, done: Promise.reject(this.poisonError()) };
if (this.sealed) {
const err = new Error('WAL is sealed by a compaction rotation; retry against the new WAL');
(err as { code?: string }).code = 'WAL_SEALED';
return { offset: -1, batchId: -1, done: Promise.reject(err) };
}
if (!Buffer.isBuffer(frame)) {
return { offset: -1, batchId: -1, done: Promise.reject(new TypeError('frame must be a Buffer')) };
}
const offset = this.nextOffset;
this.nextOffset += frame.length;
const batchId = this.nextBatchId;
const done = new Promise<void>((resolve, reject) => {
this.queue.push({ buf: frame, resolve, reject });
this.queuedBytes += frame.length;
if (this.stats) {
this.stats.walQueuedBytes += frame.length;
if (this.stats.walQueuedBytes > this.stats.walMaxQueuedBytes) {
this.stats.walMaxQueuedBytes = this.stats.walQueuedBytes;
}
}
if (!this.flushing && !this.scheduled) {
this.scheduled = true;
setImmediate(() => { void this.flushBatch(); });
}
});
return { offset, batchId, done };
}
/** Append one frame. Resolves once written to the OS page cache; for
* fsyncPolicy 'always' it additionally waits for fsync. */
append(frame: Buffer): Promise<void> {
return this.appendLoc(frame).done;
}
private async flushBatch(): Promise<unknown> {
this.scheduled = false;
if (this.flushing) return this.inflight;
if (this.queue.length === 0) return null;
if (this.poisoned) return null; // defensive: the queue stays empty while poisoned
this.flushing = true;
const run = async () => {
const batch = this.queue;
this.queue = [];
const batchBytes = this.queuedBytes;
this.queuedBytes = 0;
this.nextBatchId++;
if (this.stats) {
this.stats.walQueuedBytes -= batchBytes;
this.stats.walGroupCommits++;
this.stats.walGroupCommitFrames += batch.length;
}
// The predicted offset of the batch's first frame: the owner's recovery
// truncation point if this flush fails. Captured now β later appends
// move nextOffset forward.
const batchStartOffset = this.nextOffset - batchBytes;
// writev(2) may short-write (signal interruption, RLIMIT_FSIZE, β¦). Retry
// until the whole batch lands so a partial write never rejects frames
// whose in-memory side effects were already applied. Only a real I/O
// error (or zero progress) rejects the batch.
let bufs = batch.map((b) => b.buf);
let off = 0; // byte offset within bufs[0]
let failure: unknown = null;
try {
while (bufs.length > 0) {
const toWrite = off > 0 ? [bufs[0]!.subarray(off), ...bufs.slice(1)] : bufs;
const { bytesWritten } = await this.fh!.writev(toWrite);
if (bytesWritten === 0) throw new Error('WAL writev made no progress (short write)');
this.size += bytesWritten;
if (this.stats) this.stats.walBytesWritten += bytesWritten;
// The bytes are in the OS page cache but not necessarily on disk:
// the WAL is dirty until a successful fsync covers this generation.
this.writeGen++;
let rem = bytesWritten;
while (rem > 0 && bufs.length > 0) {
const left = bufs[0]!.length - off;
if (rem < left) {
off += rem;
rem = 0;
} else {
rem -= left;
bufs.shift();
off = 0;
}
}
}
} catch (err) {
failure = err;
if (this.stats) this.stats.walWriteErrors++;
}
if (!failure && this.policy === 'always') {
// sync() records walFsyncErrors itself. The batch's bytes are in the
// page cache but unacknowledged, so an fsync failure poisons exactly
// like a write failure.
try {
await this.sync();
} catch (err) {
failure = err;
}
}
if (failure) {
this.poisonWith(batchStartOffset, failure);
// Reject the still-queued frames first (reverse enqueue order: newest
// flush group first, so the owner's group rollback unwinds
// newest-first), then this batch's frames with the original error β
// the oldest group's rollback must run last.
const perr = this.poisonError();
this.rejectQueued(perr);
for (const b of batch) b.reject(failure);
} else {
for (const b of batch) b.resolve();
}
this.flushing = false;
this.inflight = null;
if (this.queue.length > 0 && !this.closed && !this.poisoned) {
this.scheduled = true;
setImmediate(() => { void this.flushBatch(); });
}
};
this.inflight = run();
return this.inflight;
}
/** Non-null while the WAL is poisoned by a failed flush (or poisonPending):
* the failure and the owner's in-place recovery truncation point. */
get poison(): WalPoison | null {
return this.poisoned;
}
/** The logical next append offset, including queued-but-unflushed frames:
* every frame already accepted sits strictly below it, and any later frame
* starts at/above it. Stage 5's generation build seals its checkpoint at
* this watermark (every op applied so far has its frame below it, because
* a commit body appends before it applies, in the same tick). */
get appendOffset(): number {
return this.nextOffset;
}
/** Clear the poison after the owner truncated the file to failedAtOffset
* and re-synced size bookkeeping via refreshSize(): the write path resumes. */
clearPoison(): void {
this.poisoned = null;
}
/** Poison the WAL from outside the flush path β used when a post-append
* in-memory apply fails (MiniDb's applyOp contract violation): every queued
* frame is un-acked and must never reach disk, exactly as if the flush
* carrying it had failed. The truncation point is the start of the queued
* region; an in-flight batch keeps its own fate. */
poisonPending(error: unknown): void {
if (this.closed) return;
this.poisonWith(this.nextOffset - this.queuedBytes, error);
this.rejectQueued(this.poisonError());
}
private poisonWith(failedAtOffset: number, error: unknown): void {
if (this.poisoned) {
// Already poisoned (poisonPending raced a flush failure, or a second
// batch failed): widen the truncation point to also cover the older
// un-acked bytes, never narrowing it.
this.poisoned.failedAtOffset = Math.min(this.poisoned.failedAtOffset, failedAtOffset);
return;
}
this.poisoned = { failedAtOffset, error };
}
/** Reject every queued frame with `perr` in reverse enqueue order (see the
* flushBatch failure path for why newest-first matters). */
private rejectQueued(perr: Error): void {
if (this.queue.length === 0) return;
for (let i = this.queue.length - 1; i >= 0; i--) this.queue[i]!.reject(perr);
if (this.stats) this.stats.walQueuedBytes -= this.queuedBytes;
this.queue = [];
this.queuedBytes = 0;
}
/** The rejection appends and flush() see while poisoned. 'WAL_POISONED' is
* deliberately distinct from 'WAL_SEALED': seal is a normal rotation
* (callers retry against the new WAL), poison is a hard failure the caller
* must treat as ambiguous. */
private poisonError(): Error {
const cause = this.poisoned?.error;
const err = new Error(
`WAL is poisoned by a previous write failure: ${cause instanceof Error ? cause.message : String(cause)}`,
);
(err as { code?: string }).code = 'WAL_POISONED';
(err as { cause?: unknown }).cause = cause;
return err;
}
/** Await the currently in-flight flush (if any) without scheduling new
* ones. Used by the owner's in-place recovery: a poisonPending truncation
* point is predicted against the in-flight batch fully landing, so the
* truncate must wait for that batch to settle β on success the point lies
* beyond its bytes, on failure poisonWith already widened the point to
* cover them. Never rejects (flushBatch settles its frames itself). */
async whenIdle(): Promise<void> {
await this.inflight;
}
/** Re-sync size/nextOffset with the file on disk. Required after recovery
* truncates a torn WAL tail: the truncate happens on the path behind this
* WAL's back, and stale bookkeeping would otherwise make later appends
* publish value pointers offset by the torn byte count (reads then hit the
* wrong frames). */
async refreshSize(): Promise<void> {
if (!this.fh) return;
const st = await this.fh.stat();
this.size = st.size;
this.nextOffset = st.size;
}
/** Force an fsync of the underlying file. On success the durability
* watermark advances to the write generation sampled when the fsync was
* issued; a failure is recorded (walFsyncErrors + sticky lastWalFsyncError)
* and rethrown, and the WAL stays dirty. A no-op while poisoned: the tail
* is about to be truncated by the owner's recovery, so syncing it reports
* nothing actionable. */
async sync(): Promise<void> {
if (!this.fh || this.poisoned) return;
const gen = this.writeGen;
try {
await this.fh.sync();
} catch (err) {
if (this.stats) {
this.stats.walFsyncErrors++;
this.stats.lastWalFsyncError = err;
}
throw err;
}
if (this.stats) this.stats.walFsyncs++;
// Only generations issued BEFORE this fsync may be marked synced: a flush
// that landed while the fsync was in flight is not covered by it, so the
// WAL stays dirty and the next tick syncs again.
if (this.syncedGen < gen) this.syncedGen = gen;
}
/** Flush buffered frames to the OS (without necessarily fsync'ing).
* Loops until everything queued up to now has been flushed: an earlier
* version only awaited the in-flight batch and could return while newer
* frames were still queued, which let compaction truncate un-flushed data.
* Throws the poison error when the WAL is (or becomes) poisoned: a caller
* that needs a durable fence (compaction, backup) must fail there instead
* of building on a tail the owner's recovery is about to truncate. */
async flush(): Promise<void> {
for (;;) {
if (this.poisoned) throw this.poisonError();
if (this.queue.length === 0 && !this.inflight) return;
if (this.inflight) await this.inflight;
if (this.queue.length > 0) await this.flushBatch();
}
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
// Drain any background sync already in flight before the final
// flush/sync/fd-close below (review #13): the timer is stopped, so no new
// tick fires, and the closed tracker gate rejects any tick racing in β
// what is already flying settles here instead of fsync'ing a dying fd.
await this.bgSync.close();
// Release the file handle even when the final flush/fsync fails: the error
// still propagates to the caller, but a half-closed WAL must not leak its
// fd (a compaction rotation recovering from a failed close swaps in a
// fresh WAL on the same path and abandons this handle). An fh.close()
// error itself is swallowed: with the fsync above already durable there
// is nothing actionable left to report. While poisoned the final flush is
// skipped entirely β the queue was drained with rejections and the tail
// belongs to the owner's recovery (or the write-disabled state). A flush
// that fails HERE (the final flush itself drives a queued failing batch)
// poisons the WAL the same way and is swallowed identically: the frames
// were rejected and the owner's recovery β which MiniDb.close() awaits
// right after this β owns the tail.
try {
if (!this.poisoned) {
try {
await this.flush();
} catch (err) {
if (!this.poisoned) throw err;
}
if (this.fh) await this.sync();
}
} finally {
const fh = this.fh;
this.fh = null;
if (fh) await fh.close().catch(() => {});
}
}
}
|