@optimystic/db-p2p-storage-fs 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +142 -33
  2. package/dist/src/file-batch-backend.d.ts +69 -0
  3. package/dist/src/file-batch-backend.d.ts.map +1 -0
  4. package/dist/src/file-batch-backend.js +400 -0
  5. package/dist/src/file-batch-backend.js.map +1 -0
  6. package/dist/src/file-kv-store.d.ts +37 -3
  7. package/dist/src/file-kv-store.d.ts.map +1 -1
  8. package/dist/src/file-kv-store.js +100 -34
  9. package/dist/src/file-kv-store.js.map +1 -1
  10. package/dist/src/file-storage.d.ts +20 -58
  11. package/dist/src/file-storage.d.ts.map +1 -1
  12. package/dist/src/file-storage.js +16 -430
  13. package/dist/src/file-storage.js.map +1 -1
  14. package/dist/src/file-write-log.d.ts +361 -0
  15. package/dist/src/file-write-log.d.ts.map +1 -0
  16. package/dist/src/file-write-log.js +848 -0
  17. package/dist/src/file-write-log.js.map +1 -0
  18. package/dist/src/index.d.ts +3 -0
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/index.js +3 -0
  21. package/dist/src/index.js.map +1 -1
  22. package/dist/src/store-lock.d.ts +42 -0
  23. package/dist/src/store-lock.d.ts.map +1 -0
  24. package/dist/src/store-lock.js +116 -0
  25. package/dist/src/store-lock.js.map +1 -0
  26. package/package.json +3 -3
  27. package/src/file-batch-backend.ts +430 -0
  28. package/src/file-kv-store.ts +112 -32
  29. package/src/file-storage.ts +22 -468
  30. package/src/file-write-log.ts +906 -0
  31. package/src/index.ts +3 -0
  32. package/src/store-lock.ts +123 -0
  33. package/dist/src/atomic-write.d.ts +0 -22
  34. package/dist/src/atomic-write.d.ts.map +0 -1
  35. package/dist/src/atomic-write.js +0 -90
  36. package/dist/src/atomic-write.js.map +0 -1
  37. package/src/atomic-write.ts +0 -89
@@ -0,0 +1,848 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ // NOTE: `zlib.crc32` exists from Node 22.2 (and 20.15); the repository declares no engine floor,
4
+ // and this import is what sets it for this package. On an older Node it is `undefined` and the
5
+ // first commit throws a TypeError. If an older Node must be supported, compute the CRC here.
6
+ import { crc32 } from 'zlib';
7
+ import { armUnrefTimer } from '@optimystic/db-core';
8
+ import { StoreFailedError, SyncDeadlines } from '@optimystic/db-p2p';
9
+ import { createLogger } from './logger.js';
10
+ import { acquireStoreLock, StoreLockedError } from './store-lock.js';
11
+ const log = createLogger('write-log');
12
+ export const WRITE_LOG_FILENAME = 'write-ahead.log';
13
+ /**
14
+ * The log a checkpoint has sealed and is syncing the tree under. Present on disk only between a
15
+ * checkpoint's seal and its truncate; an open replays it ahead of {@link WRITE_LOG_FILENAME}.
16
+ */
17
+ export const SEALED_WRITE_LOG_FILENAME = 'write-ahead.log.sealed';
18
+ /** {@link FileWriteLogOptions.checkpointBytes}'s default: 16 MiB. */
19
+ export const DEFAULT_CHECKPOINT_BYTES = 16 * 1024 * 1024;
20
+ /** {@link FileWriteLogOptions.checkpointIdleMs}'s default. */
21
+ export const DEFAULT_CHECKPOINT_IDLE_MS = 5000;
22
+ /** How many files, then directories, a checkpoint syncs at once. */
23
+ const CHECKPOINT_SYNC_CONCURRENCY = 16;
24
+ /**
25
+ * The log's fixed header: the magic bytes `OPTIMYSTIC-WAL`, then one format-version byte. A file
26
+ * that does not start with exactly this is not a log this build wrote, and replay applies nothing
27
+ * from it.
28
+ */
29
+ export const WRITE_LOG_FORMAT_VERSION = 1;
30
+ // `TextEncoder` is fine at module scope everywhere (see the Hermes note in eslint.config.js); a
31
+ // `TextDecoder` is built on first use, like every other one in library source.
32
+ const encoder = new TextEncoder();
33
+ let decoderInstance;
34
+ const decoder = () => decoderInstance ??= new TextDecoder();
35
+ export const WRITE_LOG_HEADER = concat([encoder.encode('OPTIMYSTIC-WAL'), Uint8Array.of(WRITE_LOG_FORMAT_VERSION)]);
36
+ const KIND_PUT = 1;
37
+ const KIND_DELETE = 2;
38
+ const RECORD_HEAD_BYTES = 8;
39
+ const ENTRY_HEAD_BYTES = 1 + 2 + 4;
40
+ const EMPTY = new Uint8Array(0);
41
+ // --- record codec ---
42
+ /**
43
+ * One record: `[u32 payload length][u32 CRC-32 of the payload][payload]`, little-endian, where the
44
+ * payload is the batch's entries in order, each `[u8 kind][u16 path length][path, UTF-8][u32 value
45
+ * length][value]`. A delete carries a zero-length value.
46
+ */
47
+ export function encodeWriteLogRecord(entries) {
48
+ const payload = encodePayload(entries);
49
+ const record = new Uint8Array(RECORD_HEAD_BYTES + payload.length);
50
+ const view = new DataView(record.buffer);
51
+ view.setUint32(0, payload.length, true);
52
+ view.setUint32(4, crc32(payload), true);
53
+ record.set(payload, RECORD_HEAD_BYTES);
54
+ return record;
55
+ }
56
+ function encodePayload(entries) {
57
+ const encoded = entries.map(entry => {
58
+ const pathBytes = encoder.encode(entry.path);
59
+ if (pathBytes.length > 0xffff) {
60
+ throw new Error(`write-ahead log path is ${pathBytes.length} bytes, over the 65535 the record format holds: ${entry.path}`);
61
+ }
62
+ return { kind: entry.kind === 'put' ? KIND_PUT : KIND_DELETE, pathBytes, value: entry.kind === 'put' ? entry.value : EMPTY };
63
+ });
64
+ const payload = new Uint8Array(encoded.reduce((total, { pathBytes, value }) => total + ENTRY_HEAD_BYTES + pathBytes.length + value.length, 0));
65
+ const view = new DataView(payload.buffer);
66
+ let offset = 0;
67
+ for (const { kind, pathBytes, value } of encoded) {
68
+ payload[offset] = kind;
69
+ view.setUint16(offset + 1, pathBytes.length, true);
70
+ payload.set(pathBytes, offset + 3);
71
+ view.setUint32(offset + 3 + pathBytes.length, value.length, true);
72
+ payload.set(value, offset + ENTRY_HEAD_BYTES + pathBytes.length);
73
+ offset += ENTRY_HEAD_BYTES + pathBytes.length + value.length;
74
+ }
75
+ return payload;
76
+ }
77
+ /**
78
+ * Read a log strictly: a header that is not ours, a record whose length runs past the end of the
79
+ * file, a CRC that does not match, or an entry that does not parse stops the read before that
80
+ * record. What precedes it is returned in order.
81
+ */
82
+ export function decodeWriteLog(bytes) {
83
+ if (!startsWith(bytes, WRITE_LOG_HEADER)) {
84
+ return { batches: [], stoppedAt: 0, stoppedBy: 'bad-header' };
85
+ }
86
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
87
+ const batches = [];
88
+ let offset = WRITE_LOG_HEADER.length;
89
+ const stop = (stoppedBy) => ({ batches, stoppedAt: offset, stoppedBy });
90
+ while (offset < bytes.length) {
91
+ if (bytes.length - offset < RECORD_HEAD_BYTES)
92
+ return stop('torn-record');
93
+ const length = view.getUint32(offset, true);
94
+ const crc = view.getUint32(offset + 4, true);
95
+ if (bytes.length - offset - RECORD_HEAD_BYTES < length)
96
+ return stop('torn-record');
97
+ const payload = bytes.subarray(offset + RECORD_HEAD_BYTES, offset + RECORD_HEAD_BYTES + length);
98
+ if (crc32(payload) !== crc)
99
+ return stop('crc-mismatch');
100
+ const entries = decodePayload(payload);
101
+ if (entries === undefined)
102
+ return stop('bad-entry');
103
+ batches.push(entries);
104
+ offset += RECORD_HEAD_BYTES + length;
105
+ }
106
+ return { batches, stoppedAt: offset };
107
+ }
108
+ /** The payload's entries, or `undefined` when they do not exactly fill it. */
109
+ function decodePayload(payload) {
110
+ const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
111
+ const entries = [];
112
+ let offset = 0;
113
+ while (offset < payload.length) {
114
+ if (payload.length - offset < ENTRY_HEAD_BYTES)
115
+ return undefined;
116
+ const kind = payload[offset];
117
+ const pathLength = view.getUint16(offset + 1, true);
118
+ if (payload.length - offset - 3 < pathLength + 4)
119
+ return undefined;
120
+ const entryPath = decoder().decode(payload.subarray(offset + 3, offset + 3 + pathLength));
121
+ const valueLength = view.getUint32(offset + 3 + pathLength, true);
122
+ const valueStart = offset + ENTRY_HEAD_BYTES + pathLength;
123
+ if (payload.length - valueStart < valueLength)
124
+ return undefined;
125
+ if (kind === KIND_PUT) {
126
+ entries.push({ kind: 'put', path: entryPath, value: payload.slice(valueStart, valueStart + valueLength) });
127
+ }
128
+ else if (kind === KIND_DELETE && valueLength === 0) {
129
+ entries.push({ kind: 'delete', path: entryPath });
130
+ }
131
+ else {
132
+ return undefined;
133
+ }
134
+ offset = valueStart + valueLength;
135
+ }
136
+ return entries;
137
+ }
138
+ function concat(parts) {
139
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
140
+ let offset = 0;
141
+ for (const part of parts) {
142
+ out.set(part, offset);
143
+ offset += part.length;
144
+ }
145
+ return out;
146
+ }
147
+ function startsWith(bytes, prefix) {
148
+ if (bytes.length < prefix.length)
149
+ return false;
150
+ for (let i = 0; i < prefix.length; i++) {
151
+ if (bytes[i] !== prefix[i])
152
+ return false;
153
+ }
154
+ return true;
155
+ }
156
+ // --- filesystem helpers shared with the backend ---
157
+ /** The file's bytes, or `undefined` when there is no such file. Any other error propagates. */
158
+ export async function readFileIfExists(filePath) {
159
+ try {
160
+ return await fs.readFile(filePath);
161
+ }
162
+ catch (err) {
163
+ if (err.code === 'ENOENT')
164
+ return undefined;
165
+ throw err;
166
+ }
167
+ }
168
+ /**
169
+ * Best-effort fsync of a directory, so an entry added to, removed from or renamed within it
170
+ * survives power loss. POSIX needs this; platforms that reject opening a directory for fsync
171
+ * throw, and the throw is ignored rather than failing the write.
172
+ *
173
+ * Skipped outright on win32: there `fs.open(dir, 'r')` SUCCEEDS and only the following
174
+ * `handle.sync()` fails with EPERM, so the open/close pair was guaranteed wasted work. Durability
175
+ * is unchanged on win32 (the sync never succeeded there).
176
+ *
177
+ * NOTE: every error is swallowed, an I/O error included, and a checkpoint discards its sealed log
178
+ * on the strength of these syncs. A directory sync that fails with EIO therefore lets the log go
179
+ * while a directory entry it covered may not be durable. If that is ever seen, swallow only the
180
+ * codes a filesystem uses to refuse a directory sync (EINVAL, ENOTSUP, EISDIR, EPERM, EBADF) and
181
+ * ENOENT, and let the rest fail the checkpoint.
182
+ */
183
+ export async function fsyncDir(dir) {
184
+ if (process.platform === 'win32')
185
+ return;
186
+ let handle;
187
+ try {
188
+ handle = await fs.open(dir, 'r');
189
+ await handle.sync();
190
+ }
191
+ catch {
192
+ // Directory fsync unsupported here — nothing to do.
193
+ }
194
+ finally {
195
+ if (handle)
196
+ await handle.close().catch(() => { });
197
+ }
198
+ }
199
+ /** fsync a file in place; a file that no longer exists is skipped. Opened `r+`: Windows refuses to flush a read-only handle. */
200
+ export async function fsyncFile(filePath) {
201
+ let handle;
202
+ try {
203
+ handle = await fs.open(filePath, 'r+');
204
+ }
205
+ catch (err) {
206
+ if (err.code === 'ENOENT')
207
+ return;
208
+ throw err;
209
+ }
210
+ try {
211
+ await handle.sync();
212
+ }
213
+ finally {
214
+ await handle.close();
215
+ }
216
+ }
217
+ /**
218
+ * The identity every store over `basePath` reports (`RawStoreDriver.storeIdentity`): `file:` and
219
+ * the directory resolved once, here. `path.resolve` is cwd-dependent, and resolving at construction
220
+ * is the correct reading — two stores built from the same RELATIVE path under different cwds
221
+ * genuinely address different directories and must not compare equal. An empty basePath resolves
222
+ * to the cwd; nothing special-cases it. (A whitespace-only basePath is NOT the cwd — `path.resolve`
223
+ * is pure string work, so `' '` names a child directory called `' '`.)
224
+ *
225
+ * Lowercased on win32 because Windows paths are case-insensitive: `C:\Foo` and `C:\foo` are one
226
+ * directory and must produce one identity.
227
+ *
228
+ * NOTE: aliases of one directory that this does NOT collapse, and so read as two identities:
229
+ * symlinks, junctions, UNC-versus-mapped-drive spellings, and case-differing spellings on a
230
+ * case-INSENSITIVE non-Windows volume (the default on macOS) — platform is the only signal
231
+ * available synchronously, and case-folding every darwin path would be wrong on a case-sensitive
232
+ * volume. `fs.realpath` resolves all of these, but it is async (a store's constructor is not) and
233
+ * requires the directory to already exist. Revisit if an alias ever bites. All of these fail in
234
+ * the SAFE direction for the read cache: a missed alias reports two identities for one directory,
235
+ * so an identity-keyed consumer declines to merge and gets today's behavior. For the write-ahead
236
+ * log they fail less safely: two stores over two spellings of one directory open two logs, and
237
+ * the second is refused by the lock.
238
+ * NOTE: the win32 fold is the one branch that can err in the UNSAFE direction — Windows 10+
239
+ * supports per-directory case sensitivity (`fsutil file setCaseSensitiveInfo`), and on such a
240
+ * directory `X:\p\Foo` and `X:\p\foo` are two directories folded onto ONE identity. The merging
241
+ * consumers — `withReadCache` (one read cache per identity) and the log registry below (one log
242
+ * per identity) — would then read and write two directories through one cache and one log. Not
243
+ * addressable synchronously either (the flag is a per-directory filesystem query). Revisit if
244
+ * Optimystic ever targets a case-sensitive-enabled Windows directory; until then the fold is
245
+ * right for every ordinary Windows volume.
246
+ */
247
+ export function fileStoreIdentity(basePath) {
248
+ const resolved = path.resolve(basePath);
249
+ return `file:${process.platform === 'win32' ? resolved.toLowerCase() : resolved}`;
250
+ }
251
+ /** Throws a `RangeError` for a checkpoint setting out of range; a store's constructor calls it, so a bad setting fails there rather than at the first write. */
252
+ export function assertFileWriteLogOptions(options) {
253
+ checkpointSettings(options);
254
+ }
255
+ function checkpointSettings(options) {
256
+ const checkpointBytes = options.checkpointBytes ?? DEFAULT_CHECKPOINT_BYTES;
257
+ const checkpointIdleMs = options.checkpointIdleMs ?? DEFAULT_CHECKPOINT_IDLE_MS;
258
+ if (!(checkpointBytes > 0))
259
+ throw new RangeError(`checkpointBytes must be positive, got ${checkpointBytes}`);
260
+ if (!(checkpointIdleMs >= 0) || !Number.isFinite(checkpointIdleMs)) {
261
+ throw new RangeError(`checkpointIdleMs must be a finite number of milliseconds, got ${checkpointIdleMs}`);
262
+ }
263
+ return { checkpointBytes, checkpointIdleMs, setTimer: options.setTimer ?? armUnrefTimer };
264
+ }
265
+ const noop = () => { };
266
+ const emptyDirty = () => ({ files: new Set(), directories: new Set() });
267
+ /**
268
+ * The write-ahead log of one store directory: `<basePath>/write-ahead.log`, every batch of writes
269
+ * appended as one record, synced by the earliest deadline any batch was given, and applied to the
270
+ * readable file tree once it is durable.
271
+ *
272
+ * The write-ahead rule: a tree file only ever holds content whose log record is durable. So what
273
+ * survives a crash is the log's durable prefix plus whatever the tree already held, and {@link open}
274
+ * reconstructs it by replaying the log in order (puts and deletes are idempotent) before anything
275
+ * is served. A replay of a put over a newer tree file cannot happen: every tree write has a log
276
+ * record, and the log is replayed in order, so the last record for a path is the newest content
277
+ * the tree was ever given. A store written by a build before the log has no log file and opens
278
+ * with nothing to replay.
279
+ *
280
+ * A record reaches the operating system in its commit, so it survives a crash of this process from
281
+ * then on: the next open's replay applies it. What the sync adds is surviving a power cut, and a
282
+ * {@link commit} allowed to wait for that (`syncWithinMs` above 0) resolves without it, so that one
283
+ * sync, made by the earliest deadline, covers every record appended since the last. The rules are
284
+ * at {@link commit} and {@link syncUnsynced}.
285
+ *
286
+ * A {@link checkpoint} makes the tree as durable as the log and then discards the log, so the log
287
+ * never grows without bound: it runs when the log passes `checkpointBytes`, `checkpointIdleMs` after
288
+ * the last append, and on the last {@link release}. Its three steps are under {@link checkpointOnce}.
289
+ *
290
+ * One instance per directory per process, shared by reference count through {@link acquire}, so
291
+ * every store over one directory in one process — `FileRawStorage` and `FileKVStore` alike — shares
292
+ * one log, one lock and one checkpoint, rather than deadlocking on the lock or interleaving two
293
+ * writers into one file. Each keeps its own overlay above this, which is why a second store opened
294
+ * beside a first cannot see the first's unflushed writes.
295
+ */
296
+ export class FileWriteLog {
297
+ basePath;
298
+ identity;
299
+ settings;
300
+ predecessorClosed;
301
+ static registry = new Map();
302
+ /** Logs whose last reference was released and whose handle and lock are still being let go of. */
303
+ static closing = new Map();
304
+ /**
305
+ * The shared log for the directory, counted once more. Synchronous, so two acquirers in one
306
+ * tick get one instance. Balance every call with one {@link release}.
307
+ *
308
+ * The checkpoint settings are the first acquirer's: a store opened over a directory whose log is
309
+ * already open shares that log's settings, and a setting it names that differs is logged and
310
+ * not applied.
311
+ */
312
+ static acquire(basePath, options = {}) {
313
+ const identity = fileStoreIdentity(basePath);
314
+ let shared = FileWriteLog.registry.get(identity);
315
+ if (shared) {
316
+ shared.noteDifferingSettings(options);
317
+ }
318
+ else {
319
+ shared = new FileWriteLog(path.resolve(basePath), identity, checkpointSettings(options), FileWriteLog.closing.get(identity));
320
+ FileWriteLog.registry.set(identity, shared);
321
+ }
322
+ shared.refs += 1;
323
+ return shared;
324
+ }
325
+ logPath;
326
+ sealedPath;
327
+ /** What the tree has been given since the log was last sealed; see {@link DirtyPaths}. Swapped for an empty set by each seal. */
328
+ dirty = emptyDirty();
329
+ /** Bytes of records in the current log, the size threshold's measure. Zero means there is nothing to checkpoint. */
330
+ appendedBytes = 0;
331
+ refs = 0;
332
+ opening;
333
+ handle;
334
+ lock;
335
+ /** Commits and seals run one at a time, in order, on this chain; it never rejects. */
336
+ queue = Promise.resolve();
337
+ fault;
338
+ closed = false;
339
+ /** The checkpoint running now, if any, which also runs every one asked for while it ran. */
340
+ checkpointRun;
341
+ /** A checkpoint was asked for while {@link checkpointRun} was running: run one more after it. */
342
+ checkpointAgain = false;
343
+ cancelIdleCheckpoint;
344
+ /**
345
+ * Records appended and not yet synced, in log order, each held with the entries the sync step
346
+ * writes to the tree and the `durable` it settles; with them the earliest deadline, the timer
347
+ * armed for it, and the `window-overrun` episode. Emptied by every sync step and by the latch.
348
+ * Bounded by the window: it holds what was committed since the last sync, and the earliest
349
+ * deadline among them forces one.
350
+ */
351
+ unsynced;
352
+ constructor(basePath, identity, settings,
353
+ /** A previous log over this directory still releasing its handle and lock; opening waits for it. */
354
+ predecessorClosed) {
355
+ this.basePath = basePath;
356
+ this.identity = identity;
357
+ this.settings = settings;
358
+ this.predecessorClosed = predecessorClosed;
359
+ this.logPath = path.join(basePath, WRITE_LOG_FILENAME);
360
+ this.sealedPath = path.join(basePath, SEALED_WRITE_LOG_FILENAME);
361
+ this.unsynced = new SyncDeadlines({
362
+ onDue: firedAt => this.syncInBackground(firedAt),
363
+ setTimer: settings.setTimer,
364
+ log,
365
+ subject: basePath,
366
+ });
367
+ }
368
+ /** True once an open, an append, an apply or a checkpoint failed; every later commit then throws {@link StoreFailedError}. */
369
+ get failed() {
370
+ return this.fault !== undefined;
371
+ }
372
+ /**
373
+ * Take the lock, replay the log into the tree, truncate it and hold it open. Memoized: the first
374
+ * operation opens, every later one waits for that. A directory another live process holds throws
375
+ * {@link StoreLockedError} from every operation; an I/O failure latches the log failed and throws
376
+ * {@link StoreFailedError} from every operation. Either outcome is kept for the life of the
377
+ * instance, and the instance is shared by every store over the directory until each has released
378
+ * it: to retry after the holder has exited, close those stores and open a new one.
379
+ */
380
+ open() {
381
+ return this.opening ??= this.openOnce();
382
+ }
383
+ /**
384
+ * Append the batch as one record and resolve with its `durable`, which settles once the record is
385
+ * synced and the batch written to the tree. Batches commit one at a time in the order they were
386
+ * submitted, and their `durable` settle in that order. `WriteGroupSink.commit`'s contract:
387
+ *
388
+ * - `syncWithinMs` 0 (**strict**): the sync step runs before this resolves, so the returned
389
+ * `durable` is already settled and every earlier batch is durable too.
390
+ * - Otherwise (**relaxed**): the record is in the operating system and the batch waits in
391
+ * {@link unsynced} with the deadline `syncWithinMs` after this was CALLED, not after the queue got
392
+ * to it, so a commit that waited in line past its own deadline is treated as overdue. A timer
393
+ * armed for the earliest deadline runs the sync step; a strict commit or a checkpoint meanwhile
394
+ * runs it sooner, for every batch waiting.
395
+ * - **Backpressure:** a commit that finds the earliest unsynced deadline already passed runs the
396
+ * sync step before it resolves, whatever its own window. When the disk falls behind, writers
397
+ * wait for it instead of the window quietly growing.
398
+ *
399
+ * The deadlines, the timer and the backpressure test are the rules of `SyncDeadlines`, from
400
+ * `@optimystic/db-p2p`, which every store that defers its sync shares.
401
+ *
402
+ * The tree is written at the sync, not here, so a reader of the tree never sees a batch whose
403
+ * record a power cut could still take; the overlay above each store serves the batch until its
404
+ * `durable` resolves.
405
+ */
406
+ commit(entries, { syncWithinMs }) {
407
+ if (!(syncWithinMs >= 0) || !Number.isFinite(syncWithinMs)) {
408
+ return Promise.reject(new RangeError(`syncWithinMs must be a finite number of milliseconds at or above 0, got ${syncWithinMs}`));
409
+ }
410
+ const deadline = this.unsynced.deadlineFor(syncWithinMs);
411
+ return this.enqueue(async () => {
412
+ await this.open();
413
+ if (this.closed)
414
+ throw new Error(`write-ahead log at ${this.basePath} is closed`);
415
+ this.throwIfFailed();
416
+ await this.append(entries);
417
+ // A checkpoint syncs the tree outside the queue, so its failure can latch the log while the
418
+ // append is in flight; the commit is then refused like every later one, its record left to the
419
+ // next open's replay. Checked with no await before `defer`, which throws once latched.
420
+ this.throwIfFailed();
421
+ const durable = this.unsynced.defer(entries, deadline);
422
+ if (this.unsynced.mustSyncNow(deadline))
423
+ await this.syncUnsynced();
424
+ this.scheduleCheckpoint();
425
+ return { durable };
426
+ });
427
+ }
428
+ /**
429
+ * Hasten: make every record appended before this call durable, and write its batch to the tree.
430
+ * Queued, so it covers exactly those records; with nothing unsynced it touches no disk.
431
+ * `WriteGroupSink.sync`'s contract.
432
+ */
433
+ sync() {
434
+ return this.enqueue(async () => {
435
+ this.throwIfFailed();
436
+ await this.syncUnsynced();
437
+ });
438
+ }
439
+ /**
440
+ * Make the tree as durable as the log, then discard the log. One at a time: asked for while one
441
+ * runs, it runs once more after that one, and resolves when that has. Skipped when the log has
442
+ * failed, was never opened, or holds no record. A failure latches the log failed, like a rejected
443
+ * commit: a half-synced tree under a discarded log cannot be recovered.
444
+ */
445
+ checkpoint() {
446
+ if (this.checkpointRun) {
447
+ this.checkpointAgain = true;
448
+ return this.checkpointRun;
449
+ }
450
+ return this.checkpointRun = this.runCheckpoints();
451
+ }
452
+ /**
453
+ * Give back one reference. The last release waits for the commit in flight and for a checkpoint
454
+ * in flight, runs a final checkpoint, closes the log handle and removes the lock; a log acquired
455
+ * for the directory meanwhile opens after that. Rejects only when that final checkpoint fails,
456
+ * and lets go of the handle and the lock either way.
457
+ */
458
+ async release() {
459
+ if (this.refs === 0)
460
+ throw new Error(`write-ahead log at ${this.basePath} was released more often than acquired`);
461
+ this.refs -= 1;
462
+ if (this.refs > 0)
463
+ return;
464
+ FileWriteLog.registry.delete(this.identity);
465
+ const done = this.shutDown();
466
+ const tracked = done.catch(noop).finally(() => {
467
+ if (FileWriteLog.closing.get(this.identity) === tracked)
468
+ FileWriteLog.closing.delete(this.identity);
469
+ });
470
+ FileWriteLog.closing.set(this.identity, tracked);
471
+ await done;
472
+ }
473
+ throwIfFailed() {
474
+ if (this.fault)
475
+ throw new StoreFailedError(this.fault.cause);
476
+ }
477
+ enqueue(task) {
478
+ const run = this.queue.then(task);
479
+ this.queue = run.catch(noop);
480
+ return run;
481
+ }
482
+ noteDifferingSettings(options) {
483
+ const differing = ['checkpointBytes', 'checkpointIdleMs']
484
+ .filter(name => options[name] !== undefined && options[name] !== this.settings[name]);
485
+ if (differing.length > 0) {
486
+ log('a store over %s asked for %o; the log already open there keeps %o', this.basePath, Object.fromEntries(differing.map(name => [name, options[name]])), Object.fromEntries(differing.map(name => [name, this.settings[name]])));
487
+ }
488
+ }
489
+ // --- open and replay ---
490
+ async openOnce() {
491
+ try {
492
+ await this.predecessorClosed;
493
+ await fs.mkdir(this.basePath, { recursive: true });
494
+ this.lock = await acquireStoreLock(this.basePath);
495
+ await this.replay();
496
+ this.handle = await this.createEmptyLog();
497
+ }
498
+ catch (err) {
499
+ await this.releaseResources().catch(closeErr => log('releasing after a failed open: %o', closeErr));
500
+ if (err instanceof StoreLockedError)
501
+ throw err;
502
+ throw this.latch(err);
503
+ }
504
+ }
505
+ /**
506
+ * Apply the sealed segment's records, then the current log's, in order; make what they touched
507
+ * durable; then remove the sealed segment. The two files are one log: a sealed segment that
508
+ * stops short ends the replay there, and the current log is discarded with the rest of it.
509
+ *
510
+ * Re-applying a sealed segment whose checkpoint already synced the tree is harmless, because the
511
+ * current log holds every later write of the same paths, in order, and is applied after it. What
512
+ * must never happen is the sealed segment outliving the current log's truncation, since replaying
513
+ * it alone would put older content over newer: so the sealed segment is removed, and the
514
+ * directory synced, before {@link openOnce} truncates the current log. (On win32, where
515
+ * {@link fsyncDir} is skipped, NTFS commits the unlink and the later truncation through its
516
+ * metadata journal in order.)
517
+ */
518
+ async replay() {
519
+ const touched = emptyDirty();
520
+ const sealed = await this.replaySegment(this.sealedPath, touched);
521
+ if (sealed === 'stopped-short') {
522
+ // NOTE: the current log is discarded only by the truncation after the sealed segment is
523
+ // removed, so a crash between the two replays the current log alone on the next open. A
524
+ // sealed segment holds only synced records, so it stops short only when its bytes were
525
+ // damaged on disk; if that is ever seen, truncate the current log before removing it.
526
+ log('replay of %s stopped short, so %s was not applied', this.sealedPath, this.logPath);
527
+ }
528
+ else {
529
+ await this.replaySegment(this.logPath, touched);
530
+ }
531
+ await syncDirtyPaths(touched);
532
+ if (sealed !== 'absent') {
533
+ await fs.unlink(this.sealedPath);
534
+ await fsyncDir(this.basePath);
535
+ }
536
+ }
537
+ /** Apply one log file's intact records, in order, into `touched`. */
538
+ async replaySegment(filePath, touched) {
539
+ // NOTE: a log file is read into memory for one decode. A checkpoint bounds the current log to
540
+ // about `checkpointBytes` plus what is appended while a checkpoint runs, and a sealed segment
541
+ // to one such log; if a log is ever found much larger than that, decode it record by record
542
+ // from a stream instead.
543
+ const bytes = await readFileIfExists(filePath);
544
+ if (bytes === undefined)
545
+ return 'absent';
546
+ const decoded = decodeWriteLog(bytes);
547
+ for (const batch of decoded.batches) {
548
+ await this.applyBatch(batch, touched);
549
+ }
550
+ if (!decoded.stoppedBy)
551
+ return 'complete';
552
+ log('replay of %s stopped at byte %d of %d (%s): %d batch(es) before it applied, the rest discarded', filePath, decoded.stoppedAt, bytes.length, decoded.stoppedBy, decoded.batches.length);
553
+ return 'stopped-short';
554
+ }
555
+ /** Create (or truncate) the current log to its header, and make it and its directory entry durable before any record goes in. */
556
+ async createEmptyLog() {
557
+ const handle = await fs.open(this.logPath, 'w');
558
+ try {
559
+ await writeAll(handle, WRITE_LOG_HEADER);
560
+ await handle.sync();
561
+ await fsyncDir(this.basePath);
562
+ }
563
+ catch (err) {
564
+ await handle.close().catch(noop);
565
+ throw err;
566
+ }
567
+ return handle;
568
+ }
569
+ // --- append and sync ---
570
+ /**
571
+ * Append the batch as one record. The record has reached the operating system when this
572
+ * resolves, so it survives a crash of this process: the next open's replay applies it. A failed
573
+ * append latches the log, and rejects every batch still waiting for its sync, since no sync will
574
+ * ever be made for them.
575
+ */
576
+ async append(entries) {
577
+ try {
578
+ const record = encodeWriteLogRecord(entries);
579
+ await writeAll(this.handle, record);
580
+ this.appendedBytes += record.length;
581
+ }
582
+ catch (err) {
583
+ throw this.latch(err);
584
+ }
585
+ }
586
+ /**
587
+ * The sync step: one sync of the log makes every record appended so far durable; then each waiting
588
+ * batch is written to the tree, in order, and its `durable` resolved, in order. Called only from a
589
+ * queued task — a commit, {@link sync}, the timer's task or a checkpoint's seal — so it covers
590
+ * exactly the records appended before that task began, and a sealed segment only ever holds
591
+ * synced records whose tree writes are done. Nothing waiting: nothing is touched.
592
+ *
593
+ * A failure anywhere latches the log and rejects every covered `durable`, the ones whose tree
594
+ * write succeeded included: the records are durable and the next open's replay rebuilds the tree
595
+ * from them, but a `durable` is a promise that this process finished the work, and it did not.
596
+ *
597
+ * `owedSince` is the moment the timer fired, when the timer's task runs this: `SyncDeadlines.take`
598
+ * counts the sync's lateness from it rather than from a deadline the timer's own slop missed, and
599
+ * the cover says `window-overrun` once per episode of syncs that started late.
600
+ */
601
+ async syncUnsynced(owedSince) {
602
+ const cover = this.unsynced.take(owedSince);
603
+ if (cover.items.length === 0)
604
+ return;
605
+ try {
606
+ await this.handle.sync();
607
+ for (const entries of cover.items)
608
+ await this.applyBatch(entries, this.dirty);
609
+ }
610
+ catch (err) {
611
+ const failure = this.latch(err);
612
+ cover.fail(err);
613
+ throw failure;
614
+ }
615
+ cover.settle();
616
+ }
617
+ /** The timer fired: run the sync step in the queue. Its failure has latched the log, so it is only logged here, like a background checkpoint's. */
618
+ syncInBackground(firedAt) {
619
+ this.enqueue(() => this.syncUnsynced(firedAt)).catch((err) => {
620
+ log('deferred sync of %s failed; the store refuses every later write: %o', this.basePath, err);
621
+ });
622
+ }
623
+ /**
624
+ * Latch the log failed on its first fault and reject every batch waiting for a sync that will now
625
+ * never be made, so no caller waits on a `durable` for ever. Returns the error to throw.
626
+ */
627
+ latch(err) {
628
+ this.fault ??= { cause: err };
629
+ this.unsynced.failAll(err);
630
+ return new StoreFailedError(err);
631
+ }
632
+ // --- apply ---
633
+ /**
634
+ * Write each entry to its canonical path, creating missing directories; record what the tree was given.
635
+ *
636
+ * NOTE: the write goes straight to the canonical path, so a reader of that path can see it
637
+ * half-written. The committing store's own readers are served from its overlay until the batch's
638
+ * `durable` resolves, and the two store kinds over one directory never share a path, so only a
639
+ * second store of the same kind over the same directory can read a torn file — a shape Invariant
640
+ * 5 already excludes. If that shape is ever supported, write through a temp file and rename.
641
+ */
642
+ async applyBatch(entries, touched) {
643
+ for (const entry of entries) {
644
+ const target = path.join(this.basePath, entry.path);
645
+ const dir = path.dirname(target);
646
+ if (entry.kind === 'put') {
647
+ const created = await fs.mkdir(dir, { recursive: true });
648
+ if (created !== undefined)
649
+ this.noteCreated(dir, touched);
650
+ await fs.writeFile(target, entry.value);
651
+ touched.files.add(target);
652
+ }
653
+ else {
654
+ await unlinkIfExists(target);
655
+ touched.files.delete(target);
656
+ }
657
+ touched.directories.add(dir);
658
+ }
659
+ }
660
+ /**
661
+ * `mkdir` created at least `dir`'s topmost missing ancestor, whose parent's entry is new too, so
662
+ * every directory from `dir` up to the store's root is marked — a superset of what was created,
663
+ * at most the layout's depth of extra directory syncs per checkpoint. (The created path `mkdir`
664
+ * returns is not compared against: on win32 it comes back in `\\?\` long-path form.)
665
+ */
666
+ noteCreated(dir, touched) {
667
+ for (let current = dir;; current = path.dirname(current)) {
668
+ touched.directories.add(current);
669
+ if (current === this.basePath || path.dirname(current) === current)
670
+ return;
671
+ }
672
+ }
673
+ // --- checkpoint ---
674
+ /**
675
+ * After an append: re-arm the idle checkpoint, and start one now when the log has passed its size
676
+ * threshold. Nothing once the last release has begun, since it runs the final checkpoint itself.
677
+ */
678
+ scheduleCheckpoint() {
679
+ if (this.closed)
680
+ return;
681
+ this.disarmIdleCheckpoint();
682
+ this.cancelIdleCheckpoint = this.settings.setTimer(() => {
683
+ this.cancelIdleCheckpoint = undefined;
684
+ this.checkpointInBackground('idle');
685
+ }, this.settings.checkpointIdleMs);
686
+ if (this.appendedBytes >= this.settings.checkpointBytes)
687
+ this.checkpointInBackground('size');
688
+ }
689
+ /** Never awaited by a commit: a commit holds the queue the checkpoint's seal waits in. Its failure has latched the log. */
690
+ checkpointInBackground(trigger) {
691
+ this.checkpoint().catch((err) => {
692
+ log('checkpoint (%s) of %s failed; the store refuses every later write: %o', trigger, this.basePath, err);
693
+ });
694
+ }
695
+ disarmIdleCheckpoint() {
696
+ this.cancelIdleCheckpoint?.();
697
+ this.cancelIdleCheckpoint = undefined;
698
+ }
699
+ async runCheckpoints() {
700
+ try {
701
+ do {
702
+ this.checkpointAgain = false;
703
+ await this.checkpointOnce();
704
+ } while (this.checkpointAgain);
705
+ }
706
+ finally {
707
+ // Cleared before this promise settles, so a checkpoint asked for from here on starts afresh.
708
+ this.checkpointRun = undefined;
709
+ }
710
+ }
711
+ /**
712
+ * 1. **Seal**, in the commit queue so no apply is in flight: run the sync step for whatever is
713
+ * still waiting, rename the log to {@link SEALED_WRITE_LOG_FILENAME} and start an empty one, and
714
+ * take the dirty set, leaving an empty one. Commits resume on the new log at once; only this
715
+ * step waits in the queue.
716
+ * 2. **Sync**, off the commit path: every file in the taken set, then every directory. A file
717
+ * deleted since is skipped; one rewritten since is synced with its newer content, which the
718
+ * new log covers too.
719
+ * 3. **Truncate**: unlink the sealed segment and sync the directory.
720
+ *
721
+ * A crash anywhere in this leaves the sealed segment, the current log or both, and the next open
722
+ * replays them in that order (see {@link replay}).
723
+ */
724
+ async checkpointOnce() {
725
+ if (!this.opening || this.fault)
726
+ return;
727
+ await this.opening.catch(noop);
728
+ const sealed = await this.seal();
729
+ if (!sealed)
730
+ return;
731
+ try {
732
+ await syncDirtyPaths(sealed);
733
+ await fs.unlink(this.sealedPath);
734
+ await fsyncDir(this.basePath);
735
+ }
736
+ catch (err) {
737
+ throw this.latch(err);
738
+ }
739
+ }
740
+ /**
741
+ * Step 1 of {@link checkpointOnce}: the dirty set the sealed segment covers, or `undefined` when
742
+ * there is nothing to seal. The sync step runs first, so the sealed segment holds only synced
743
+ * records whose tree writes are in the set the checkpoint goes on to sync. An unsynced record
744
+ * always has bytes, so `appendedBytes` is never 0 while one waits: the early return cannot skip
745
+ * one, and the final checkpoint on the last release syncs whatever is still waiting.
746
+ */
747
+ seal() {
748
+ return this.enqueue(async () => {
749
+ if (this.fault || this.appendedBytes === 0)
750
+ return undefined;
751
+ await this.syncUnsynced();
752
+ try {
753
+ const handle = this.handle;
754
+ this.handle = undefined;
755
+ await handle.close();
756
+ await fs.rename(this.logPath, this.sealedPath);
757
+ this.handle = await this.createEmptyLog();
758
+ }
759
+ catch (err) {
760
+ throw this.latch(err);
761
+ }
762
+ const sealed = this.dirty;
763
+ this.dirty = emptyDirty();
764
+ this.appendedBytes = 0;
765
+ return sealed;
766
+ });
767
+ }
768
+ // --- close ---
769
+ async shutDown() {
770
+ this.closed = true;
771
+ this.disarmIdleCheckpoint();
772
+ // The final checkpoint's seal syncs whatever is still waiting (see `seal`), which disarms the
773
+ // sync timer; one that fires before then enqueues a sync step that covers it sooner.
774
+ await this.queue;
775
+ try {
776
+ // A failure of the checkpoint in flight has been logged by whatever started it, and has
777
+ // latched the log, which skips the final one.
778
+ await this.checkpointRun?.catch(noop);
779
+ await this.checkpoint();
780
+ }
781
+ finally {
782
+ await this.releaseResources();
783
+ }
784
+ }
785
+ async releaseResources() {
786
+ const handle = this.handle;
787
+ this.handle = undefined;
788
+ if (handle)
789
+ await handle.close();
790
+ const lock = this.lock;
791
+ this.lock = undefined;
792
+ if (lock)
793
+ await lock.release();
794
+ }
795
+ }
796
+ /**
797
+ * Sync every file, then every directory, {@link CHECKPOINT_SYNC_CONCURRENCY} at a time.
798
+ *
799
+ * NOTE: a checkpoint's file sync holds a handle open while later commits may delete and re-create
800
+ * the same path. libuv opens with delete sharing and, on Windows 10 1809 and later, unlinks with
801
+ * POSIX semantics, so the name goes at once; on an older Windows the re-create would fail with
802
+ * EPERM while the deleted name lingers, latching the store. If that is ever seen, retry the apply's
803
+ * write on EPERM for the length of one sync.
804
+ */
805
+ async function syncDirtyPaths(dirty) {
806
+ await forEachConcurrently(dirty.files, CHECKPOINT_SYNC_CONCURRENCY, fsyncFile);
807
+ await forEachConcurrently(dirty.directories, CHECKPOINT_SYNC_CONCURRENCY, fsyncDir);
808
+ }
809
+ /** Run `fn` over `items` with at most `limit` in flight; after the first failure no more start, and it is thrown once all in flight have settled. */
810
+ async function forEachConcurrently(items, limit, fn) {
811
+ const iterator = items[Symbol.iterator]();
812
+ let failure;
813
+ const worker = async () => {
814
+ while (!failure) {
815
+ const next = iterator.next();
816
+ if (next.done)
817
+ return;
818
+ try {
819
+ await fn(next.value);
820
+ }
821
+ catch (err) {
822
+ failure ??= { cause: err };
823
+ }
824
+ }
825
+ };
826
+ await Promise.all(Array.from({ length: limit }, worker));
827
+ if (failure)
828
+ throw failure.cause;
829
+ }
830
+ async function writeAll(handle, bytes) {
831
+ let written = 0;
832
+ while (written < bytes.length) {
833
+ const { bytesWritten } = await handle.write(bytes, written, bytes.length - written);
834
+ if (bytesWritten <= 0)
835
+ throw new Error('write-ahead log append wrote nothing');
836
+ written += bytesWritten;
837
+ }
838
+ }
839
+ async function unlinkIfExists(filePath) {
840
+ try {
841
+ await fs.unlink(filePath);
842
+ }
843
+ catch (err) {
844
+ if (err.code !== 'ENOENT')
845
+ throw err;
846
+ }
847
+ }
848
+ //# sourceMappingURL=file-write-log.js.map