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