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