@feltdb/core 0.11.6 → 0.11.7
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/dist/create/package-versions.js +1 -1
- package/dist/file-db.d.ts +143 -14
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +690 -244
- package/dist/file-journal.d.ts +70 -0
- package/dist/file-journal.d.ts.map +1 -0
- package/dist/file-journal.js +184 -0
- package/package.json +1 -1
package/dist/file-db.js
CHANGED
|
@@ -4,6 +4,8 @@ import * as nodeFs from 'fs';
|
|
|
4
4
|
import * as nodePath from 'path';
|
|
5
5
|
import { decideTransaction, embeddedResult, EmbeddedTransactionRejected, } from './embedded-transaction.js';
|
|
6
6
|
import { EmbeddedQueryEngine } from './embedded-query.js';
|
|
7
|
+
import { encodeRecord, scanJournal, JournalCorruptionError, PersistenceProfiler, } from './file-journal.js';
|
|
8
|
+
export { JournalCorruptionError } from './file-journal.js';
|
|
7
9
|
// Synchronous versions for sync API (will throw if called in browser)
|
|
8
10
|
function getFsSync() {
|
|
9
11
|
if (typeof nodeFs.existsSync === 'function')
|
|
@@ -15,11 +17,24 @@ function getPathSync() {
|
|
|
15
17
|
return nodePath;
|
|
16
18
|
throw new Error('FileJsDb is only available in Node.js environments');
|
|
17
19
|
}
|
|
20
|
+
const DEFAULT_MIN_JOURNAL_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
const JOURNAL_FILE = /^state\.(\d+)\.journal$/;
|
|
22
|
+
function isMissing(error) {
|
|
23
|
+
return error?.code === 'ENOENT';
|
|
24
|
+
}
|
|
25
|
+
function writeFully(fd, bytes, position) {
|
|
26
|
+
let written = 0;
|
|
27
|
+
while (written < bytes.length) {
|
|
28
|
+
written += getFsSync().writeSync(fd, bytes, written, bytes.length - written, position + written);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
18
31
|
export class FileJsDb {
|
|
19
32
|
dataPath;
|
|
20
33
|
statePath;
|
|
34
|
+
backupPath;
|
|
21
35
|
lockPath;
|
|
22
36
|
state;
|
|
37
|
+
view;
|
|
23
38
|
origin;
|
|
24
39
|
peers = new Set();
|
|
25
40
|
lockFd = null;
|
|
@@ -27,7 +42,10 @@ export class FileJsDb {
|
|
|
27
42
|
applied = new Set();
|
|
28
43
|
queryEngine;
|
|
29
44
|
indexedSequence = -1;
|
|
30
|
-
|
|
45
|
+
profiler;
|
|
46
|
+
compaction;
|
|
47
|
+
compactionScheduled = false;
|
|
48
|
+
constructor(path, options = {}) {
|
|
31
49
|
const nodePath = getPathSync();
|
|
32
50
|
const nodeFs = getFsSync();
|
|
33
51
|
// Normalize and resolve the path to prevent directory traversal
|
|
@@ -38,7 +56,14 @@ export class FileJsDb {
|
|
|
38
56
|
}
|
|
39
57
|
this.dataPath = resolvedPath;
|
|
40
58
|
this.statePath = nodePath.join(this.dataPath, 'state.json');
|
|
59
|
+
this.backupPath = `${this.statePath}.backup`;
|
|
41
60
|
this.lockPath = nodePath.join(this.dataPath, 'state.lock');
|
|
61
|
+
this.profiler = new PersistenceProfiler(options.profile ?? (typeof process !== 'undefined' && process.env?.FELTDB_PERSISTENCE_PROFILE === '1'));
|
|
62
|
+
this.compaction = {
|
|
63
|
+
mode: options.compaction?.mode ?? 'auto',
|
|
64
|
+
minJournalBytes: options.compaction?.minJournalBytes ?? DEFAULT_MIN_JOURNAL_BYTES,
|
|
65
|
+
journalToSnapshotRatio: options.compaction?.journalToSnapshotRatio ?? 1,
|
|
66
|
+
};
|
|
42
67
|
// Create directory if it doesn't exist
|
|
43
68
|
if (!nodeFs.existsSync(this.dataPath)) {
|
|
44
69
|
nodeFs.mkdirSync(this.dataPath, { recursive: true });
|
|
@@ -48,80 +73,73 @@ export class FileJsDb {
|
|
|
48
73
|
// Clean up any stale locks from previous crashes on startup
|
|
49
74
|
this.cleanupStaleLock();
|
|
50
75
|
// Load or initialize state
|
|
51
|
-
this.state = this.
|
|
52
|
-
this.
|
|
53
|
-
for (const transactionId of this.state.applied_transactions ?? []) {
|
|
54
|
-
this.applied.add(transactionId);
|
|
55
|
-
}
|
|
76
|
+
this.state = this.initializeState();
|
|
77
|
+
this.loadDurableState();
|
|
56
78
|
this.queryEngine = new EmbeddedQueryEngine(() => Object.entries(this.state.rows));
|
|
57
79
|
this.indexedSequence = this.state.sequence;
|
|
58
80
|
}
|
|
59
|
-
|
|
81
|
+
/* ------------------------------------------------------------------------
|
|
82
|
+
* Durable layout
|
|
83
|
+
* ---------------------------------------------------------------------- */
|
|
84
|
+
journalPath(generation) {
|
|
85
|
+
return getPathSync().join(this.dataPath, `state.${generation}.journal`);
|
|
86
|
+
}
|
|
87
|
+
snapshotKey() {
|
|
88
|
+
try {
|
|
89
|
+
const stat = getFsSync().statSync(this.statePath, { bigint: true });
|
|
90
|
+
return `${stat.ino}:${stat.size}:${stat.mtimeNs}`;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (isMissing(error))
|
|
94
|
+
return 'none';
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
parseSnapshot(path) {
|
|
99
|
+
const content = getFsSync().readFileSync(path, 'utf-8');
|
|
100
|
+
const snapshot = JSON.parse(content);
|
|
101
|
+
const known = snapshot.version === 1
|
|
102
|
+
|| (snapshot.version === 2 && Number.isInteger(snapshot.journal?.generation) && Number.isInteger(snapshot.journal?.commit));
|
|
103
|
+
if (!known || !snapshot.meta || !snapshot.rows || !snapshot.events)
|
|
104
|
+
return null;
|
|
105
|
+
return { snapshot, bytes: Buffer.byteLength(content) };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read the snapshot this handle should start from: the primary, or the
|
|
109
|
+
* backup when the primary cannot be read (unchanged from the single-file
|
|
110
|
+
* runtime), or nothing at all.
|
|
111
|
+
*/
|
|
112
|
+
readSnapshot() {
|
|
60
113
|
const nodeFs = getFsSync();
|
|
61
|
-
const backupPath = `${this.statePath}.backup`;
|
|
62
|
-
// Load atomic snapshot if it exists
|
|
63
114
|
if (nodeFs.existsSync(this.statePath)) {
|
|
64
115
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
console.warn(`Invalid snapshot structure at ${this.statePath}, attempting backup recovery`);
|
|
70
|
-
return this.loadStateFromBackup();
|
|
71
|
-
}
|
|
72
|
-
// Successfully loaded valid state, preserve it as backup
|
|
73
|
-
try {
|
|
74
|
-
nodeFs.copyFileSync(this.statePath, backupPath);
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
// Backup failure is non-fatal
|
|
78
|
-
}
|
|
79
|
-
return {
|
|
80
|
-
version: snapshot.version,
|
|
81
|
-
instance_id: this.origin,
|
|
82
|
-
sequence: snapshot.meta.sequence,
|
|
83
|
-
rows: snapshot.rows,
|
|
84
|
-
events: snapshot.events,
|
|
85
|
-
peers: snapshot.meta.peers ?? [],
|
|
86
|
-
sequences: snapshot.meta.sequences ?? {},
|
|
87
|
-
applied_transactions: snapshot.applied_transactions ?? []
|
|
88
|
-
};
|
|
116
|
+
const parsed = this.parseSnapshot(this.statePath);
|
|
117
|
+
if (parsed)
|
|
118
|
+
return { snapshot: parsed.snapshot, bytes: parsed.bytes, source: 'primary' };
|
|
119
|
+
console.warn(`Invalid snapshot structure at ${this.statePath}, attempting backup recovery`);
|
|
89
120
|
}
|
|
90
121
|
catch (error) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
122
|
+
if (!isMissing(error)) {
|
|
123
|
+
console.warn(`Failed to load snapshot from ${this.statePath}: ${error}`);
|
|
124
|
+
console.warn(`Attempting to recover from backup...`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
return { snapshot: null, bytes: 0, source: 'empty' };
|
|
128
|
+
}
|
|
94
129
|
}
|
|
95
|
-
}
|
|
96
|
-
return this.initializeState();
|
|
97
|
-
}
|
|
98
|
-
loadStateFromBackup() {
|
|
99
|
-
const nodeFs = getFsSync();
|
|
100
|
-
const backupPath = `${this.statePath}.backup`;
|
|
101
|
-
if (nodeFs.existsSync(backupPath)) {
|
|
102
130
|
try {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return {
|
|
108
|
-
version: snapshot.version,
|
|
109
|
-
instance_id: this.origin,
|
|
110
|
-
sequence: snapshot.meta.sequence,
|
|
111
|
-
rows: snapshot.rows,
|
|
112
|
-
events: snapshot.events,
|
|
113
|
-
peers: snapshot.meta.peers ?? [],
|
|
114
|
-
sequences: snapshot.meta.sequences ?? {},
|
|
115
|
-
applied_transactions: snapshot.applied_transactions ?? []
|
|
116
|
-
};
|
|
131
|
+
const parsed = this.parseSnapshot(this.backupPath);
|
|
132
|
+
if (parsed) {
|
|
133
|
+
console.warn(`Recovered state from backup at ${this.backupPath}`);
|
|
134
|
+
return { snapshot: parsed.snapshot, bytes: parsed.bytes, source: 'backup' };
|
|
117
135
|
}
|
|
118
136
|
}
|
|
119
137
|
catch {
|
|
120
138
|
// Backup is also corrupted or invalid
|
|
121
139
|
}
|
|
140
|
+
console.warn(`Backup recovery failed, reinitializing state`);
|
|
122
141
|
}
|
|
123
|
-
|
|
124
|
-
return this.initializeState();
|
|
142
|
+
return { snapshot: null, bytes: 0, source: 'empty' };
|
|
125
143
|
}
|
|
126
144
|
initializeState() {
|
|
127
145
|
return {
|
|
@@ -135,11 +153,540 @@ export class FileJsDb {
|
|
|
135
153
|
applied_transactions: []
|
|
136
154
|
};
|
|
137
155
|
}
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Materialize the durable state from scratch: snapshot, then every complete
|
|
158
|
+
* commit in its journal, in order. A journal that disappears while it is
|
|
159
|
+
* being read has been superseded by a compaction, so the read starts over
|
|
160
|
+
* from the new snapshot. One that is missing for the current snapshot is
|
|
161
|
+
* corruption.
|
|
162
|
+
*/
|
|
163
|
+
loadDurableState() {
|
|
164
|
+
const started = this.profiler.now();
|
|
165
|
+
for (let attempt = 0;; attempt += 1) {
|
|
166
|
+
const snapshotKey = this.snapshotKey();
|
|
167
|
+
const { snapshot, bytes, source } = this.readSnapshot();
|
|
168
|
+
const state = snapshot ? {
|
|
169
|
+
version: snapshot.version,
|
|
170
|
+
instance_id: this.origin,
|
|
171
|
+
sequence: snapshot.meta.sequence,
|
|
172
|
+
rows: snapshot.rows,
|
|
173
|
+
events: snapshot.events,
|
|
174
|
+
peers: snapshot.meta.peers ?? [],
|
|
175
|
+
sequences: snapshot.meta.sequences ?? {},
|
|
176
|
+
applied_transactions: snapshot.applied_transactions ?? [],
|
|
177
|
+
} : this.initializeState();
|
|
178
|
+
const view = {
|
|
179
|
+
snapshotKey,
|
|
180
|
+
format: snapshot ? (snapshot.version === 2 ? 'v2' : 'v1') : 'none',
|
|
181
|
+
source,
|
|
182
|
+
generation: snapshot?.journal?.generation ?? 0,
|
|
183
|
+
commit: snapshot?.journal?.commit ?? 0,
|
|
184
|
+
journalOffset: 0,
|
|
185
|
+
journalRecords: 0,
|
|
186
|
+
snapshotBytes: bytes,
|
|
187
|
+
tornBytes: 0,
|
|
188
|
+
};
|
|
189
|
+
let records = [];
|
|
190
|
+
if (view.format === 'v2') {
|
|
191
|
+
const journalPath = this.journalPath(view.generation);
|
|
192
|
+
let journal;
|
|
193
|
+
try {
|
|
194
|
+
journal = getFsSync().readFileSync(journalPath);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!isMissing(error))
|
|
198
|
+
throw error;
|
|
199
|
+
if (attempt < 8 && this.snapshotKey() !== snapshotKey)
|
|
200
|
+
continue;
|
|
201
|
+
if (source === 'backup') {
|
|
202
|
+
console.warn(`Backup snapshot journal ${journalPath} is missing, reinitializing state`);
|
|
203
|
+
this.adopt(this.initializeState(), { ...view, format: 'none', source: 'empty', generation: 0, commit: 0 });
|
|
204
|
+
this.profiler.record('recovery', started);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
throw new JournalCorruptionError(journalPath, 1, 0, 'the journal named by the current snapshot is missing');
|
|
208
|
+
}
|
|
209
|
+
let scan;
|
|
210
|
+
try {
|
|
211
|
+
scan = scanJournal(journal, {
|
|
212
|
+
journalPath, generation: view.generation, expectedCommit: view.commit + 1,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
// A reader holds no lock, so a writer repairing a torn tail can be
|
|
217
|
+
// observed mid-way. Real corruption is still there on a re-read.
|
|
218
|
+
if (attempt < 3 && error instanceof JournalCorruptionError)
|
|
219
|
+
continue;
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
records = scan.records;
|
|
223
|
+
view.journalOffset = scan.consumed;
|
|
224
|
+
view.journalRecords = scan.records.length;
|
|
225
|
+
view.tornBytes = scan.tornBytes;
|
|
226
|
+
view.commit += scan.records.length;
|
|
227
|
+
}
|
|
228
|
+
if (source === 'backup') {
|
|
229
|
+
// The unreadable primary was the compaction of this backup and its
|
|
230
|
+
// journal, so the journals written after it continue the backup's
|
|
231
|
+
// commits exactly. Replaying them recovers everything that primary
|
|
232
|
+
// and its journal held, instead of stopping at the backup.
|
|
233
|
+
for (let generation = view.generation + 1;; generation += 1) {
|
|
234
|
+
const journalPath = this.journalPath(generation);
|
|
235
|
+
let journal;
|
|
236
|
+
try {
|
|
237
|
+
journal = getFsSync().readFileSync(journalPath);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (isMissing(error))
|
|
241
|
+
break;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
const scan = scanJournal(journal, {
|
|
245
|
+
journalPath, generation, expectedCommit: view.commit + 1,
|
|
246
|
+
});
|
|
247
|
+
records.push(...scan.records);
|
|
248
|
+
view.format = 'v2';
|
|
249
|
+
view.generation = generation;
|
|
250
|
+
view.journalOffset = scan.consumed;
|
|
251
|
+
view.journalRecords = scan.records.length;
|
|
252
|
+
view.tornBytes = scan.tornBytes;
|
|
253
|
+
view.commit += scan.records.length;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
this.adopt(state, view);
|
|
257
|
+
for (const record of records)
|
|
258
|
+
this.applyCommit(record.body, false);
|
|
259
|
+
this.profiler.record('recovery', started);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
adopt(state, view) {
|
|
264
|
+
this.state = state;
|
|
265
|
+
this.view = view;
|
|
266
|
+
this.peers.clear();
|
|
267
|
+
for (const peer of state.peers)
|
|
140
268
|
this.peers.add(peer);
|
|
269
|
+
this.applied.clear();
|
|
270
|
+
for (const transactionId of state.applied_transactions ?? [])
|
|
271
|
+
this.applied.add(transactionId);
|
|
272
|
+
}
|
|
273
|
+
/** Apply one commit to memory. The only way any mutation reaches memory. */
|
|
274
|
+
applyCommit(body, index) {
|
|
275
|
+
for (const mutation of body.m ?? []) {
|
|
276
|
+
const [key] = mutation;
|
|
277
|
+
if (mutation.length === 1)
|
|
278
|
+
delete this.state.rows[key];
|
|
279
|
+
else
|
|
280
|
+
this.state.rows[key] = mutation[1];
|
|
281
|
+
if (index)
|
|
282
|
+
this.queryEngine?.apply(key, mutation.length === 1 ? undefined : mutation[1]);
|
|
283
|
+
}
|
|
284
|
+
for (const event of body.e ?? [])
|
|
285
|
+
this.state.events.push(event);
|
|
286
|
+
if (body.q)
|
|
287
|
+
Object.assign(this.state.sequences, body.q);
|
|
288
|
+
if (body.p) {
|
|
289
|
+
this.state.peers = [...body.p];
|
|
290
|
+
this.peers.clear();
|
|
291
|
+
for (const peer of body.p)
|
|
292
|
+
this.peers.add(peer);
|
|
293
|
+
}
|
|
294
|
+
if (body.t !== undefined && !this.applied.has(body.t)) {
|
|
295
|
+
this.applied.add(body.t);
|
|
296
|
+
this.state.applied_transactions.push(body.t);
|
|
297
|
+
}
|
|
298
|
+
this.state.sequence = body.s;
|
|
299
|
+
if (index)
|
|
300
|
+
this.indexedSequence = this.state.sequence;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Bring memory up to date with the durable files.
|
|
304
|
+
*
|
|
305
|
+
* While the snapshot is unchanged, only journal bytes after the last
|
|
306
|
+
* complete record already applied are read, so a read costs what was
|
|
307
|
+
* committed since the previous read — not the size of all history. Any
|
|
308
|
+
* change to the snapshot, or a journal that no longer continues what was
|
|
309
|
+
* applied, falls back to a complete load.
|
|
310
|
+
*/
|
|
311
|
+
refresh() {
|
|
312
|
+
const key = this.snapshotKey();
|
|
313
|
+
if (key === this.view.snapshotKey) {
|
|
314
|
+
if (this.view.format !== 'v2')
|
|
315
|
+
return;
|
|
316
|
+
if (this.tailJournal())
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this.loadDurableState();
|
|
320
|
+
this.queryEngine.rebuild();
|
|
321
|
+
this.indexedSequence = this.state.sequence;
|
|
322
|
+
}
|
|
323
|
+
/** Apply newly committed journal records. False when a full load is required. */
|
|
324
|
+
tailJournal() {
|
|
325
|
+
const nodeFs = getFsSync();
|
|
326
|
+
const journalPath = this.journalPath(this.view.generation);
|
|
327
|
+
let fd;
|
|
328
|
+
try {
|
|
329
|
+
fd = nodeFs.openSync(journalPath, 'r');
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (isMissing(error))
|
|
333
|
+
return false;
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
for (let attempt = 0;; attempt += 1) {
|
|
338
|
+
const size = nodeFs.fstatSync(fd).size;
|
|
339
|
+
if (size < this.view.journalOffset)
|
|
340
|
+
return false;
|
|
341
|
+
const length = size - this.view.journalOffset;
|
|
342
|
+
if (length === 0) {
|
|
343
|
+
this.view.tornBytes = 0;
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
const bytes = Buffer.allocUnsafe(length);
|
|
347
|
+
let read = 0;
|
|
348
|
+
while (read < length) {
|
|
349
|
+
const count = nodeFs.readSync(fd, bytes, read, length - read, this.view.journalOffset + read);
|
|
350
|
+
if (count === 0)
|
|
351
|
+
break;
|
|
352
|
+
read += count;
|
|
353
|
+
}
|
|
354
|
+
let scan;
|
|
355
|
+
try {
|
|
356
|
+
scan = scanJournal(bytes.subarray(0, read), {
|
|
357
|
+
journalPath,
|
|
358
|
+
generation: this.view.generation,
|
|
359
|
+
expectedCommit: this.view.commit + 1,
|
|
360
|
+
baseOffset: this.view.journalOffset,
|
|
361
|
+
firstRecord: this.view.journalRecords + 1,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
if (attempt < 3 && error instanceof JournalCorruptionError && this.lockToken === null)
|
|
366
|
+
continue;
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
for (const record of scan.records)
|
|
370
|
+
this.applyCommit(record.body, true);
|
|
371
|
+
this.view.journalOffset += scan.consumed;
|
|
372
|
+
this.view.journalRecords += scan.records.length;
|
|
373
|
+
this.view.commit += scan.records.length;
|
|
374
|
+
this.view.tornBytes = scan.tornBytes;
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
nodeFs.closeSync(fd);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/* ------------------------------------------------------------------------
|
|
383
|
+
* Commit protocol
|
|
384
|
+
* ---------------------------------------------------------------------- */
|
|
385
|
+
/** A commit body starting from the current sequence. */
|
|
386
|
+
stage() {
|
|
387
|
+
return { s: this.state.sequence };
|
|
388
|
+
}
|
|
389
|
+
stageRow(body, key, value) {
|
|
390
|
+
(body.m ??= []).push(value === undefined ? [key] : [key, value]);
|
|
391
|
+
}
|
|
392
|
+
stageEvent(body, collection, key, type, value, timestamp = Date.now()) {
|
|
393
|
+
body.s += 1;
|
|
394
|
+
(body.e ??= []).push({
|
|
395
|
+
sequence: body.s,
|
|
396
|
+
collection,
|
|
397
|
+
key,
|
|
398
|
+
type,
|
|
399
|
+
value,
|
|
400
|
+
timestamp,
|
|
401
|
+
origin: this.origin,
|
|
402
|
+
id: `${this.origin}:${timestamp}:${body.s}`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/** Stage a row write together with the event that records it. */
|
|
406
|
+
stagePut(body, key, value) {
|
|
407
|
+
this.stageRow(body, key, value);
|
|
408
|
+
this.stageEvent(body, key.split(':')[0], key, 'put', value);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Make one commit durable, then visible. Caller holds the lock and has
|
|
412
|
+
* refreshed, so memory reflects every earlier commit.
|
|
413
|
+
*
|
|
414
|
+
* 1 body framed and written, without its terminator
|
|
415
|
+
* 2 fdatasync ← the record's bytes are durable
|
|
416
|
+
* 3 terminator written ← the commit boundary exists
|
|
417
|
+
* 4 fdatasync ← the boundary is durable
|
|
418
|
+
* 5 applied to memory, success returned
|
|
419
|
+
*
|
|
420
|
+
* A crash before 3 leaves an unterminated record that recovery discards; it
|
|
421
|
+
* was never acknowledged. Readers only accept terminated records, so no
|
|
422
|
+
* process can observe a commit whose bytes were not already durable. A
|
|
423
|
+
* failure at any step removes whatever was written, so a failed commit is
|
|
424
|
+
* not visible after it returns either.
|
|
425
|
+
*/
|
|
426
|
+
persist(body) {
|
|
427
|
+
const nodeFs = getFsSync();
|
|
428
|
+
if (this.view.format !== 'v2' || this.view.source !== 'primary') {
|
|
429
|
+
// First write to a fresh, legacy, or backup-recovered directory
|
|
430
|
+
// establishes a journal-continued snapshot of the current state.
|
|
431
|
+
this.compactLocked();
|
|
432
|
+
}
|
|
433
|
+
const commitStarted = this.profiler.now();
|
|
434
|
+
const commit = this.view.commit + 1;
|
|
435
|
+
const serializationStarted = this.profiler.now();
|
|
436
|
+
const { header, body: bodyBytes } = encodeRecord(this.view.generation, commit, body);
|
|
437
|
+
const record = Buffer.concat([header, bodyBytes]);
|
|
438
|
+
this.profiler.record('serialization', serializationStarted);
|
|
439
|
+
const offset = this.view.journalOffset;
|
|
440
|
+
const fd = nodeFs.openSync(this.journalPath(this.view.generation), 'r+');
|
|
441
|
+
try {
|
|
442
|
+
try {
|
|
443
|
+
if (this.view.tornBytes > 0) {
|
|
444
|
+
// An earlier writer died mid-append. Its record was never
|
|
445
|
+
// acknowledged; remove it before anything is written after it.
|
|
446
|
+
console.warn(`FeltDB recovered an incomplete journal append at byte ${offset} `
|
|
447
|
+
+ `(${this.view.tornBytes} bytes discarded) in ${this.journalPath(this.view.generation)}`);
|
|
448
|
+
nodeFs.ftruncateSync(fd, offset);
|
|
449
|
+
this.timedSync(fd);
|
|
450
|
+
this.view.tornBytes = 0;
|
|
451
|
+
}
|
|
452
|
+
let started = this.profiler.now();
|
|
453
|
+
writeFully(fd, record, offset);
|
|
454
|
+
this.profiler.record('write', started);
|
|
455
|
+
this.timedSync(fd);
|
|
456
|
+
started = this.profiler.now();
|
|
457
|
+
writeFully(fd, Buffer.from('\n'), offset + record.length);
|
|
458
|
+
this.profiler.record('write', started);
|
|
459
|
+
this.timedSync(fd);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
try {
|
|
463
|
+
nodeFs.ftruncateSync(fd, offset);
|
|
464
|
+
nodeFs.fdatasyncSync(fd);
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// The unterminated remainder is discarded by the next writer.
|
|
468
|
+
}
|
|
469
|
+
throw new Error(`Failed to save FeltDB state: ${error}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
nodeFs.closeSync(fd);
|
|
474
|
+
}
|
|
475
|
+
this.view.journalOffset = offset + record.length + 1;
|
|
476
|
+
this.view.journalRecords += 1;
|
|
477
|
+
this.view.commit = commit;
|
|
478
|
+
this.profiler.bytes('journal', record.length + 1);
|
|
479
|
+
// Memory receives exactly the bytes that were made durable.
|
|
480
|
+
this.applyCommit(JSON.parse(bodyBytes.toString('utf8')), true);
|
|
481
|
+
this.profiler.record('commit', commitStarted);
|
|
482
|
+
this.scheduleCompaction();
|
|
483
|
+
}
|
|
484
|
+
timedSync(fd) {
|
|
485
|
+
const started = this.profiler.now();
|
|
486
|
+
getFsSync().fdatasyncSync(fd);
|
|
487
|
+
this.profiler.record('fsync', started);
|
|
488
|
+
}
|
|
489
|
+
/* ------------------------------------------------------------------------
|
|
490
|
+
* Compaction
|
|
491
|
+
* ---------------------------------------------------------------------- */
|
|
492
|
+
compactionDue() {
|
|
493
|
+
return this.view.format === 'v2'
|
|
494
|
+
&& this.view.journalOffset >= this.compaction.minJournalBytes
|
|
495
|
+
&& this.view.journalOffset >= this.view.snapshotBytes * this.compaction.journalToSnapshotRatio;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Compaction runs after the commit that made it due has been acknowledged,
|
|
499
|
+
* never as part of it, and under the same lock as every other writer.
|
|
500
|
+
*/
|
|
501
|
+
scheduleCompaction() {
|
|
502
|
+
if (this.compaction.mode !== 'auto' || this.compactionScheduled || !this.compactionDue())
|
|
503
|
+
return;
|
|
504
|
+
this.compactionScheduled = true;
|
|
505
|
+
const timer = setTimeout(() => {
|
|
506
|
+
this.compactionScheduled = false;
|
|
507
|
+
try {
|
|
508
|
+
this.withMutationLock(() => { if (this.compactionDue())
|
|
509
|
+
this.compactLocked(); });
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
// Committed data is already durable in the journal; compaction only
|
|
513
|
+
// reclaims it. A failed attempt is retried after a later commit.
|
|
514
|
+
console.warn(`FeltDB journal compaction failed: ${error}`);
|
|
515
|
+
}
|
|
516
|
+
}, 0);
|
|
517
|
+
timer.unref?.();
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Rewrite the current state as a new snapshot and start an empty journal.
|
|
521
|
+
* Caller holds the lock and has refreshed.
|
|
522
|
+
*
|
|
523
|
+
* 1 new empty journal G+1 created and synced
|
|
524
|
+
* 2 snapshot naming (G+1, commit C) written to a temporary file, synced
|
|
525
|
+
* 3 previous snapshot linked as the backup
|
|
526
|
+
* 4 snapshot renamed into place, directory synced ← compaction commits
|
|
527
|
+
* 5 journals other than G and G+1 removed
|
|
528
|
+
*
|
|
529
|
+
* A crash before 4 leaves the previous snapshot and journal authoritative;
|
|
530
|
+
* an orphaned journal G+1 is never read and is removed by the next
|
|
531
|
+
* compaction. After 4 the new snapshot is complete by itself.
|
|
532
|
+
*/
|
|
533
|
+
compactLocked() {
|
|
534
|
+
const nodeFs = getFsSync();
|
|
535
|
+
const started = this.profiler.now();
|
|
536
|
+
const previousGeneration = this.view.generation;
|
|
537
|
+
let generation = previousGeneration;
|
|
538
|
+
for (const name of nodeFs.readdirSync(this.dataPath)) {
|
|
539
|
+
const match = JOURNAL_FILE.exec(name);
|
|
540
|
+
if (match)
|
|
541
|
+
generation = Math.max(generation, Number(match[1]));
|
|
542
|
+
}
|
|
543
|
+
generation += 1;
|
|
544
|
+
const commit = this.view.commit;
|
|
545
|
+
const journal = nodeFs.openSync(this.journalPath(generation), 'w');
|
|
546
|
+
try {
|
|
547
|
+
nodeFs.fsyncSync(journal);
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
nodeFs.closeSync(journal);
|
|
551
|
+
}
|
|
552
|
+
// The journal's directory entry must be durable before a snapshot names it.
|
|
553
|
+
this.syncDirectory();
|
|
554
|
+
this.state.peers = [...this.peers];
|
|
555
|
+
const snapshot = {
|
|
556
|
+
version: 2,
|
|
557
|
+
timestamp: Date.now(),
|
|
558
|
+
meta: {
|
|
559
|
+
instance_id: this.origin,
|
|
560
|
+
sequence: this.state.sequence,
|
|
561
|
+
peers: this.state.peers,
|
|
562
|
+
sequences: this.state.sequences,
|
|
563
|
+
},
|
|
564
|
+
rows: this.state.rows,
|
|
565
|
+
events: this.state.events,
|
|
566
|
+
applied_transactions: [...this.applied],
|
|
567
|
+
journal: { generation, commit },
|
|
568
|
+
};
|
|
569
|
+
// Compaction is profiled as one phase, so that it never inflates the
|
|
570
|
+
// serialization, write and fsync figures of ordinary commits.
|
|
571
|
+
const content = Buffer.from(JSON.stringify(snapshot), 'utf8');
|
|
572
|
+
const tempPath = `${this.statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
573
|
+
try {
|
|
574
|
+
const handle = nodeFs.openSync(tempPath, 'w');
|
|
575
|
+
try {
|
|
576
|
+
writeFully(handle, content, 0);
|
|
577
|
+
// Force the contents to disk before the rename publishes them.
|
|
578
|
+
nodeFs.fsyncSync(handle);
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
nodeFs.closeSync(handle);
|
|
582
|
+
}
|
|
583
|
+
if (this.view.source === 'primary' && nodeFs.existsSync(this.statePath)) {
|
|
584
|
+
// The snapshot being replaced becomes the backup. Its journal is kept
|
|
585
|
+
// below, so the backup remains a complete state on its own.
|
|
586
|
+
const backupTemp = `${this.backupPath}.${process.pid}.tmp`;
|
|
587
|
+
try {
|
|
588
|
+
nodeFs.unlinkSync(backupTemp);
|
|
589
|
+
}
|
|
590
|
+
catch { /* absent */ }
|
|
591
|
+
try {
|
|
592
|
+
nodeFs.linkSync(this.statePath, backupTemp);
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
nodeFs.copyFileSync(this.statePath, backupTemp);
|
|
596
|
+
}
|
|
597
|
+
nodeFs.renameSync(backupTemp, this.backupPath);
|
|
598
|
+
}
|
|
599
|
+
// Atomic rename commits the new snapshot.
|
|
600
|
+
nodeFs.renameSync(tempPath, this.statePath);
|
|
601
|
+
this.syncDirectory();
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
try {
|
|
605
|
+
nodeFs.unlinkSync(tempPath);
|
|
606
|
+
}
|
|
607
|
+
catch { /* absent */ }
|
|
608
|
+
throw new Error(`Failed to save FeltDB state: ${error}`);
|
|
609
|
+
}
|
|
610
|
+
for (const name of nodeFs.readdirSync(this.dataPath)) {
|
|
611
|
+
const match = JOURNAL_FILE.exec(name);
|
|
612
|
+
if (!match)
|
|
613
|
+
continue;
|
|
614
|
+
const kept = Number(match[1]);
|
|
615
|
+
if (kept === generation || kept === previousGeneration)
|
|
616
|
+
continue;
|
|
617
|
+
try {
|
|
618
|
+
nodeFs.unlinkSync(getPathSync().join(this.dataPath, name));
|
|
619
|
+
}
|
|
620
|
+
catch { /* already gone */ }
|
|
141
621
|
}
|
|
622
|
+
this.view = {
|
|
623
|
+
snapshotKey: this.snapshotKey(),
|
|
624
|
+
format: 'v2',
|
|
625
|
+
source: 'primary',
|
|
626
|
+
generation,
|
|
627
|
+
commit,
|
|
628
|
+
journalOffset: 0,
|
|
629
|
+
journalRecords: 0,
|
|
630
|
+
snapshotBytes: content.length,
|
|
631
|
+
tornBytes: 0,
|
|
632
|
+
};
|
|
633
|
+
this.profiler.bytes('snapshot', content.length);
|
|
634
|
+
this.profiler.record('compaction', started);
|
|
142
635
|
}
|
|
636
|
+
syncDirectory() {
|
|
637
|
+
const nodeFs = getFsSync();
|
|
638
|
+
// Persist the rename itself, so the commit survives power loss.
|
|
639
|
+
try {
|
|
640
|
+
const directory = nodeFs.openSync(this.dataPath, 'r');
|
|
641
|
+
try {
|
|
642
|
+
nodeFs.fsyncSync(directory);
|
|
643
|
+
}
|
|
644
|
+
finally {
|
|
645
|
+
nodeFs.closeSync(directory);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
// Directory fsync is not available on every platform. The rename is
|
|
650
|
+
// still atomic; only the durability of the rename itself is weaker.
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Fold the journal into a new snapshot now. Logical state is unchanged;
|
|
655
|
+
* only its physical representation is. Normal commits never do this.
|
|
656
|
+
*/
|
|
657
|
+
compact() {
|
|
658
|
+
this.withMutationLock(() => this.compactLocked());
|
|
659
|
+
return this.persistence_status();
|
|
660
|
+
}
|
|
661
|
+
/** How the durable state is currently represented on disk. */
|
|
662
|
+
persistence_status() {
|
|
663
|
+
this.refresh();
|
|
664
|
+
const view = this.view;
|
|
665
|
+
return {
|
|
666
|
+
format: view.format === 'v2' ? 'snapshot+journal' : view.format === 'v1' ? 'legacy-snapshot' : 'none',
|
|
667
|
+
source: view.source,
|
|
668
|
+
generation: view.generation,
|
|
669
|
+
commit: view.commit,
|
|
670
|
+
sequence: this.state.sequence,
|
|
671
|
+
snapshotBytes: view.snapshotBytes,
|
|
672
|
+
journalBytes: view.journalOffset,
|
|
673
|
+
journalRecords: view.journalRecords,
|
|
674
|
+
recovery: view.tornBytes > 0
|
|
675
|
+
? { tornTail: { byteOffset: view.journalOffset, discardedBytes: view.tornBytes } }
|
|
676
|
+
: 'clean',
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Persistence timings collected by this handle: mutation, serialization,
|
|
681
|
+
* write, fsync, commit, compaction and recovery. Empty unless profiling was
|
|
682
|
+
* enabled with the `profile` option or `FELTDB_PERSISTENCE_PROFILE=1`.
|
|
683
|
+
*/
|
|
684
|
+
persistence_profile(options = {}) {
|
|
685
|
+
return this.profiler.summary(options.reset ?? false);
|
|
686
|
+
}
|
|
687
|
+
/* ------------------------------------------------------------------------
|
|
688
|
+
* Cross-process lock
|
|
689
|
+
* ---------------------------------------------------------------------- */
|
|
143
690
|
isStalelock() {
|
|
144
691
|
const nodeFs = getFsSync();
|
|
145
692
|
const STALE_LOCK_THRESHOLD_MS = 5000;
|
|
@@ -264,110 +811,32 @@ export class FileJsDb {
|
|
|
264
811
|
}
|
|
265
812
|
withMutationLock(operation) {
|
|
266
813
|
this.acquireLockSync();
|
|
814
|
+
const started = this.profiler.now();
|
|
267
815
|
try {
|
|
268
816
|
this.refresh();
|
|
269
817
|
return operation();
|
|
270
818
|
}
|
|
271
819
|
finally {
|
|
820
|
+
this.profiler.record('mutation', started);
|
|
272
821
|
this.releaseLockAsync();
|
|
273
822
|
}
|
|
274
823
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
this.restorePeers();
|
|
279
|
-
this.applied.clear();
|
|
280
|
-
for (const transactionId of this.state.applied_transactions ?? [])
|
|
281
|
-
this.applied.add(transactionId);
|
|
282
|
-
if (this.queryEngine && this.indexedSequence !== this.state.sequence) {
|
|
283
|
-
this.queryEngine.rebuild();
|
|
284
|
-
this.indexedSequence = this.state.sequence;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
saveState() {
|
|
288
|
-
const nodeFs = getFsSync();
|
|
289
|
-
const nodePathModule = getPathSync();
|
|
290
|
-
try {
|
|
291
|
-
// Update peers in state
|
|
292
|
-
this.state.peers = [...this.peers];
|
|
293
|
-
// Serialize complete snapshot (single logical state)
|
|
294
|
-
const snapshot = {
|
|
295
|
-
version: 1,
|
|
296
|
-
timestamp: Date.now(),
|
|
297
|
-
meta: {
|
|
298
|
-
instance_id: this.origin,
|
|
299
|
-
sequence: this.state.sequence,
|
|
300
|
-
peers: this.state.peers,
|
|
301
|
-
sequences: this.state.sequences
|
|
302
|
-
},
|
|
303
|
-
rows: this.state.rows,
|
|
304
|
-
events: this.state.events,
|
|
305
|
-
applied_transactions: [...this.applied]
|
|
306
|
-
};
|
|
307
|
-
const content = JSON.stringify(snapshot, null, 2);
|
|
308
|
-
// Write to temporary file
|
|
309
|
-
const tempPath = `${this.statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
310
|
-
const handle = nodeFs.openSync(tempPath, 'w');
|
|
311
|
-
try {
|
|
312
|
-
nodeFs.writeFileSync(handle, content, 'utf-8');
|
|
313
|
-
// Force the contents to disk before the rename publishes them.
|
|
314
|
-
// A rename is a metadata operation: without this, a crash can leave
|
|
315
|
-
// the new name pointing at a file whose contents never landed.
|
|
316
|
-
nodeFs.fsyncSync(handle);
|
|
317
|
-
}
|
|
318
|
-
finally {
|
|
319
|
-
nodeFs.closeSync(handle);
|
|
320
|
-
}
|
|
321
|
-
// Atomic rename commits the new state
|
|
322
|
-
nodeFs.renameSync(tempPath, this.statePath);
|
|
323
|
-
// Persist the rename itself, so the commit survives power loss.
|
|
324
|
-
try {
|
|
325
|
-
const directory = nodeFs.openSync(nodePathModule.dirname(this.statePath), 'r');
|
|
326
|
-
try {
|
|
327
|
-
nodeFs.fsyncSync(directory);
|
|
328
|
-
}
|
|
329
|
-
finally {
|
|
330
|
-
nodeFs.closeSync(directory);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
catch {
|
|
334
|
-
// Directory fsync is not available on every platform. The rename is
|
|
335
|
-
// still atomic; only the durability of the rename itself is weaker.
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
catch (error) {
|
|
339
|
-
throw new Error(`Failed to save FeltDB state: ${error}`);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
event(collection, key, type, value) {
|
|
343
|
-
this.state.sequence += 1;
|
|
344
|
-
const timestamp = Date.now();
|
|
345
|
-
this.state.events.push({
|
|
346
|
-
sequence: this.state.sequence,
|
|
347
|
-
collection,
|
|
348
|
-
key,
|
|
349
|
-
type,
|
|
350
|
-
value,
|
|
351
|
-
timestamp,
|
|
352
|
-
origin: this.origin,
|
|
353
|
-
id: `${this.origin}:${timestamp}:${this.state.sequence}`
|
|
354
|
-
});
|
|
355
|
-
this.queryEngine?.apply(key, type === 'put' ? this.state.rows[key] : undefined);
|
|
356
|
-
this.indexedSequence = this.state.sequence;
|
|
357
|
-
}
|
|
824
|
+
/* ------------------------------------------------------------------------
|
|
825
|
+
* JsDb
|
|
826
|
+
* ---------------------------------------------------------------------- */
|
|
358
827
|
/**
|
|
359
828
|
* Commit several operations atomically and durably.
|
|
360
829
|
*
|
|
361
|
-
* The
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* leaves one of those two states on disk — never a mixture.
|
|
830
|
+
* The whole transaction is one journal record, and a record is either
|
|
831
|
+
* terminated — committed, every mutation in it — or discarded on recovery.
|
|
832
|
+
* A reader or a restart sees the whole transaction or none of it.
|
|
365
833
|
*
|
|
366
|
-
* Order matters here. The
|
|
367
|
-
*
|
|
834
|
+
* Order matters here. The commit is made durable *before* it is applied in
|
|
835
|
+
* memory, so a failed write cannot leave memory ahead of disk.
|
|
368
836
|
*/
|
|
369
837
|
async commit_transaction(request) {
|
|
370
838
|
await this.acquireLockAsync();
|
|
839
|
+
const started = this.profiler.now();
|
|
371
840
|
try {
|
|
372
841
|
this.refresh();
|
|
373
842
|
const before = this.state.sequence;
|
|
@@ -380,53 +849,22 @@ export class FileJsDb {
|
|
|
380
849
|
return embeddedResult(request, true, before, this.state.sequence);
|
|
381
850
|
if (plan.kind === 'reject')
|
|
382
851
|
throw new EmbeddedTransactionRejected(plan.reason, plan.failure);
|
|
383
|
-
|
|
384
|
-
// Critical: Deep-copy rows to prevent stale references after refresh
|
|
385
|
-
const rows = {};
|
|
386
|
-
for (const [key, value] of Object.entries(this.state.rows)) {
|
|
387
|
-
rows[key] = value;
|
|
388
|
-
}
|
|
389
|
-
const events = [...this.state.events];
|
|
390
|
-
let sequence = this.state.sequence;
|
|
852
|
+
const body = this.stage();
|
|
391
853
|
const timestamp = Date.now();
|
|
392
854
|
for (const mutation of plan.mutations) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
delete rows[mutation.key];
|
|
396
|
-
}
|
|
397
|
-
else {
|
|
398
|
-
rows[mutation.key] = mutation.value;
|
|
399
|
-
}
|
|
400
|
-
events.push({
|
|
401
|
-
sequence,
|
|
402
|
-
collection: mutation.collection,
|
|
403
|
-
key: mutation.key,
|
|
404
|
-
type: mutation.value === undefined ? 'delete' : 'put',
|
|
405
|
-
value: mutation.value,
|
|
406
|
-
timestamp,
|
|
407
|
-
origin: this.origin,
|
|
408
|
-
id: `${this.origin}:${timestamp}:${sequence}`,
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
const previous = this.state;
|
|
412
|
-
this.state = { ...this.state, rows, events, sequence };
|
|
413
|
-
// The transaction id has to be part of the snapshot being written, not
|
|
414
|
-
// added after it. Recording it afterwards would leave a committed
|
|
415
|
-
// transaction whose identity did not survive a restart, and redelivery
|
|
416
|
-
// would then apply it a second time.
|
|
417
|
-
this.applied.add(request.transactionId);
|
|
418
|
-
try {
|
|
419
|
-
this.saveState();
|
|
420
|
-
}
|
|
421
|
-
catch (error) {
|
|
422
|
-
// The commit did not become durable, so it must not be visible either.
|
|
423
|
-
this.state = previous;
|
|
424
|
-
this.applied.delete(request.transactionId);
|
|
425
|
-
throw error;
|
|
855
|
+
this.stageRow(body, mutation.key, mutation.value);
|
|
856
|
+
this.stageEvent(body, mutation.collection, mutation.key, mutation.value === undefined ? 'delete' : 'put', mutation.value, timestamp);
|
|
426
857
|
}
|
|
427
|
-
|
|
858
|
+
// The transaction id is part of the commit being written, not added
|
|
859
|
+
// after it. Recording it afterwards would leave a committed transaction
|
|
860
|
+
// whose identity did not survive a restart, and redelivery would then
|
|
861
|
+
// apply it a second time.
|
|
862
|
+
body.t = request.transactionId;
|
|
863
|
+
this.persist(body);
|
|
864
|
+
return embeddedResult(request, false, before, body.s);
|
|
428
865
|
}
|
|
429
866
|
finally {
|
|
867
|
+
this.profiler.record('mutation', started);
|
|
430
868
|
this.releaseLockAsync();
|
|
431
869
|
}
|
|
432
870
|
}
|
|
@@ -434,10 +872,9 @@ export class FileJsDb {
|
|
|
434
872
|
try {
|
|
435
873
|
const parsed = JSON.parse(value);
|
|
436
874
|
return this.withMutationLock(() => {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
this.
|
|
440
|
-
this.saveState();
|
|
875
|
+
const body = this.stage();
|
|
876
|
+
this.stagePut(body, key, parsed);
|
|
877
|
+
this.persist(body);
|
|
441
878
|
return { success: true, data: key };
|
|
442
879
|
});
|
|
443
880
|
}
|
|
@@ -451,9 +888,9 @@ export class FileJsDb {
|
|
|
451
888
|
return this.withMutationLock(() => {
|
|
452
889
|
if (!(key in this.state.rows))
|
|
453
890
|
return { success: false, error: `Record ${key} not found` };
|
|
454
|
-
|
|
455
|
-
this.
|
|
456
|
-
this.
|
|
891
|
+
const body = this.stage();
|
|
892
|
+
this.stagePut(body, key, parsed);
|
|
893
|
+
this.persist(body);
|
|
457
894
|
return { success: true, data: key };
|
|
458
895
|
});
|
|
459
896
|
}
|
|
@@ -473,9 +910,10 @@ export class FileJsDb {
|
|
|
473
910
|
return this.withMutationLock(() => {
|
|
474
911
|
if (!(key in this.state.rows))
|
|
475
912
|
return { success: false, error: `Record ${key} not found` };
|
|
476
|
-
|
|
477
|
-
this.
|
|
478
|
-
this.
|
|
913
|
+
const body = this.stage();
|
|
914
|
+
this.stageRow(body, key, undefined);
|
|
915
|
+
this.stageEvent(body, key.split(':')[0], key, 'delete');
|
|
916
|
+
this.persist(body);
|
|
479
917
|
return { success: true, data: key };
|
|
480
918
|
});
|
|
481
919
|
}
|
|
@@ -531,10 +969,20 @@ export class FileJsDb {
|
|
|
531
969
|
return { success: true };
|
|
532
970
|
}
|
|
533
971
|
add_sync_peer(peerId) {
|
|
534
|
-
return this.withMutationLock(() => {
|
|
972
|
+
return this.withMutationLock(() => {
|
|
973
|
+
const body = this.stage();
|
|
974
|
+
body.p = [...new Set([...this.peers, peerId])];
|
|
975
|
+
this.persist(body);
|
|
976
|
+
return { success: true };
|
|
977
|
+
});
|
|
535
978
|
}
|
|
536
979
|
remove_sync_peer(peerId) {
|
|
537
|
-
return this.withMutationLock(() => {
|
|
980
|
+
return this.withMutationLock(() => {
|
|
981
|
+
const body = this.stage();
|
|
982
|
+
body.p = [...this.peers].filter(peer => peer !== peerId);
|
|
983
|
+
this.persist(body);
|
|
984
|
+
return { success: true };
|
|
985
|
+
});
|
|
538
986
|
}
|
|
539
987
|
acknowledge_peer_operations() {
|
|
540
988
|
return { success: true };
|
|
@@ -593,62 +1041,63 @@ export class FileJsDb {
|
|
|
593
1041
|
return this.withMutationLock(() => {
|
|
594
1042
|
let applied = 0;
|
|
595
1043
|
let ignored = 0;
|
|
1044
|
+
const body = this.stage();
|
|
1045
|
+
const staged = () => body.e ?? [];
|
|
596
1046
|
for (const operation of operations) {
|
|
597
1047
|
// Skip if we've already applied this operation
|
|
598
|
-
if (
|
|
1048
|
+
if (staged().some(event => event.id === operation.id)
|
|
1049
|
+
|| this.state.events.some(event => event.id === operation.id)) {
|
|
599
1050
|
ignored++;
|
|
600
1051
|
continue;
|
|
601
1052
|
}
|
|
602
1053
|
// Find the current state of this key
|
|
603
|
-
const current = [...
|
|
1054
|
+
const current = [...staged()].reverse().find(event => event.key === operation.key)
|
|
1055
|
+
?? [...this.state.events].reverse().find(event => event.key === operation.key);
|
|
604
1056
|
// Use lamport-clock-style ordering: timestamp, then origin, then id
|
|
605
1057
|
const incomingOrder = `${String(operation.timestamp).padStart(16, '0')}:${operation.origin}:${operation.id}`;
|
|
606
1058
|
const currentOrder = current ? `${String(current.timestamp).padStart(16, '0')}:${current.origin}:${current.id}` : '';
|
|
607
|
-
|
|
608
|
-
|
|
1059
|
+
body.s += 1;
|
|
1060
|
+
(body.e ??= []).push({ ...operation, sequence: body.s });
|
|
609
1061
|
// Apply if incoming is newer
|
|
610
1062
|
if (incomingOrder >= currentOrder) {
|
|
611
|
-
|
|
612
|
-
delete this.state.rows[operation.key];
|
|
613
|
-
}
|
|
614
|
-
else {
|
|
615
|
-
this.state.rows[operation.key] = operation.value;
|
|
616
|
-
}
|
|
1063
|
+
this.stageRow(body, operation.key, operation.type === 'delete' ? undefined : operation.value);
|
|
617
1064
|
applied++;
|
|
618
1065
|
}
|
|
619
1066
|
else {
|
|
620
1067
|
ignored++;
|
|
621
1068
|
}
|
|
622
1069
|
}
|
|
623
|
-
//
|
|
1070
|
+
// Persist after applying remote operations
|
|
624
1071
|
if (applied > 0) {
|
|
625
|
-
this.
|
|
1072
|
+
this.persist(body);
|
|
626
1073
|
}
|
|
627
1074
|
return { applied, ignored };
|
|
628
1075
|
});
|
|
629
1076
|
}
|
|
630
1077
|
async allocateSequence(params) {
|
|
631
1078
|
await this.acquireLockAsync();
|
|
1079
|
+
const started = this.profiler.now();
|
|
632
1080
|
try {
|
|
633
1081
|
this.refresh();
|
|
634
1082
|
const key = `${params.scope}:${params.scopeId}`;
|
|
635
1083
|
const next = (this.state.sequences[key] ?? 0) + 1;
|
|
636
|
-
|
|
637
|
-
|
|
1084
|
+
const body = this.stage();
|
|
1085
|
+
body.q = { [key]: next };
|
|
1086
|
+
this.persist(body);
|
|
638
1087
|
return next;
|
|
639
1088
|
}
|
|
640
1089
|
finally {
|
|
1090
|
+
this.profiler.record('mutation', started);
|
|
641
1091
|
this.releaseLockAsync();
|
|
642
1092
|
}
|
|
643
1093
|
}
|
|
644
1094
|
async putIfAbsent(key, value) {
|
|
645
|
-
const nodeFs = getFsSync();
|
|
646
1095
|
try {
|
|
647
1096
|
await this.acquireLockAsync();
|
|
648
1097
|
try {
|
|
649
1098
|
const parsed = JSON.parse(value);
|
|
650
|
-
//
|
|
651
|
-
this.
|
|
1099
|
+
// Bring state up to date with disk to ensure we have the latest
|
|
1100
|
+
this.refresh();
|
|
652
1101
|
// Check if key exists (atomic read within lock boundary)
|
|
653
1102
|
if (key in this.state.rows) {
|
|
654
1103
|
// Already exists, return the existing value
|
|
@@ -659,10 +1108,9 @@ export class FileJsDb {
|
|
|
659
1108
|
};
|
|
660
1109
|
}
|
|
661
1110
|
// Key doesn't exist, insert it atomically within lock
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
this.
|
|
665
|
-
this.saveState();
|
|
1111
|
+
const body = this.stage();
|
|
1112
|
+
this.stagePut(body, key, parsed);
|
|
1113
|
+
this.persist(body);
|
|
666
1114
|
return {
|
|
667
1115
|
inserted: true,
|
|
668
1116
|
value: value
|
|
@@ -696,7 +1144,6 @@ export class FileJsDb {
|
|
|
696
1144
|
metadata: input.metadata,
|
|
697
1145
|
authorityScope: input.authorityScope,
|
|
698
1146
|
};
|
|
699
|
-
// Use putIfAbsent to atomically store the operation
|
|
700
1147
|
// Key format: "operation:{idempotencyKey}" for idempotency lookup
|
|
701
1148
|
// Key format: "op-id:{operationId}" for direct operation lookup
|
|
702
1149
|
const idempotencyKey = `operation:${input.idempotencyKey}`;
|
|
@@ -710,11 +1157,10 @@ export class FileJsDb {
|
|
|
710
1157
|
? { inserted: true, value: operationValue }
|
|
711
1158
|
: { inserted: false, value: JSON.stringify(present) };
|
|
712
1159
|
if (result.inserted) {
|
|
713
|
-
|
|
714
|
-
this.
|
|
715
|
-
this.
|
|
716
|
-
this.
|
|
717
|
-
this.saveState();
|
|
1160
|
+
const body = this.stage();
|
|
1161
|
+
this.stagePut(body, idempotencyKey, durableOperation);
|
|
1162
|
+
this.stagePut(body, operationIdKey, durableOperation);
|
|
1163
|
+
this.persist(body);
|
|
718
1164
|
}
|
|
719
1165
|
// Parse the existing or newly inserted operation
|
|
720
1166
|
const existingOperation = JSON.parse(result.value);
|
|
@@ -749,8 +1195,8 @@ export class FileJsDb {
|
|
|
749
1195
|
try {
|
|
750
1196
|
await this.acquireLockAsync();
|
|
751
1197
|
try {
|
|
752
|
-
//
|
|
753
|
-
this.
|
|
1198
|
+
// Bring state up to date with disk to ensure we have latest
|
|
1199
|
+
this.refresh();
|
|
754
1200
|
// Look up operation by its operationId
|
|
755
1201
|
const operationIdKey = `op-id:${input.operationId}`;
|
|
756
1202
|
// Get current operation state
|
|
@@ -797,14 +1243,15 @@ export class FileJsDb {
|
|
|
797
1243
|
error: input.error ?? currentOperation.error,
|
|
798
1244
|
};
|
|
799
1245
|
// Persist the transition atomically within lock (update both indices)
|
|
800
|
-
|
|
1246
|
+
const body = this.stage();
|
|
1247
|
+
this.stageRow(body, operationIdKey, transitionedOperation);
|
|
801
1248
|
const idempotencyIndexKey = `operation:${currentOperation.idempotencyKey}`;
|
|
802
1249
|
if (idempotencyIndexKey in this.state.rows) {
|
|
803
|
-
this.
|
|
1250
|
+
this.stageRow(body, idempotencyIndexKey, transitionedOperation);
|
|
804
1251
|
}
|
|
805
1252
|
const collection = operationIdKey.split(':')[0];
|
|
806
|
-
this.
|
|
807
|
-
this.
|
|
1253
|
+
this.stageEvent(body, collection, operationIdKey, 'put', transitionedOperation);
|
|
1254
|
+
this.persist(body);
|
|
808
1255
|
return {
|
|
809
1256
|
transitioned: true,
|
|
810
1257
|
operation: transitionedOperation,
|
|
@@ -831,11 +1278,11 @@ export class FileJsDb {
|
|
|
831
1278
|
console.log(`[CAS-${casId}] lock acquired`);
|
|
832
1279
|
try {
|
|
833
1280
|
const parsed = JSON.parse(params.value);
|
|
834
|
-
// CRITICAL:
|
|
1281
|
+
// CRITICAL: Bring state up to date with disk while holding lock
|
|
835
1282
|
// This ensures we see the authoritative version, not just our in-memory cache
|
|
836
1283
|
if (debugMode)
|
|
837
1284
|
console.log(`[CAS-${casId}] reloading state from disk...`);
|
|
838
|
-
this.
|
|
1285
|
+
this.refresh();
|
|
839
1286
|
if (debugMode)
|
|
840
1287
|
console.log(`[CAS-${casId}] state reloaded`);
|
|
841
1288
|
// Check if key exists
|
|
@@ -883,13 +1330,12 @@ export class FileJsDb {
|
|
|
883
1330
|
if (debugMode)
|
|
884
1331
|
console.log(`[CAS-${casId}] version match - updating from ${currentVersion} to ${params.expectedVersion + 1}`);
|
|
885
1332
|
const newValue = { ...parsed, __version: params.expectedVersion + 1 };
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
// Persist atomically (temp file + rename)
|
|
1333
|
+
const body = this.stage();
|
|
1334
|
+
this.stagePut(body, params.key, newValue);
|
|
1335
|
+
// Persist durably (journal commit)
|
|
890
1336
|
if (debugMode)
|
|
891
1337
|
console.log(`[CAS-${casId}] saving state to disk...`);
|
|
892
|
-
this.
|
|
1338
|
+
this.persist(body);
|
|
893
1339
|
if (debugMode)
|
|
894
1340
|
console.log(`[CAS-${casId}] state saved, returning SUCCESS`);
|
|
895
1341
|
return {
|