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(() => {});
    }
  }
}