@indigoai-us/hq-cloud 6.15.6 → 6.15.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/bin/journal-repair.d.ts +12 -0
- package/dist/bin/journal-repair.d.ts.map +1 -0
- package/dist/bin/journal-repair.js +46 -0
- package/dist/bin/journal-repair.js.map +1 -0
- package/dist/bin/journal-repair.test.d.ts +2 -0
- package/dist/bin/journal-repair.test.d.ts.map +1 -0
- package/dist/bin/journal-repair.test.js +53 -0
- package/dist/bin/journal-repair.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +37 -4
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +10 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +43 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +344 -66
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/journal.d.ts +34 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +82 -13
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +117 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/sync/state-store.d.ts +52 -0
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +411 -99
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +188 -1
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
package/dist/sync/state-store.js
CHANGED
|
@@ -16,6 +16,7 @@ const WAL_HEADER_BYTES = 8 + 8 + 32 + 32;
|
|
|
16
16
|
const FRAME_PREFIX_BYTES = 4 + 4;
|
|
17
17
|
const FRAME_FIXED_BODY_BYTES = 8 + 2 + 2 + 4 + 4 + 32;
|
|
18
18
|
const MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
const DEFAULT_MAX_WAL_BYTES_BEFORE_COMPACTION = 16 * 1024 * 1024;
|
|
19
20
|
const LOCK_RETRY_MS = 10;
|
|
20
21
|
const LOCK_TIMEOUT_MS = 5_000;
|
|
21
22
|
export class StateStoreCorruptionError extends Error {
|
|
@@ -30,6 +31,12 @@ class StateStoreLockError extends Error {
|
|
|
30
31
|
this.name = "StateStoreLockError";
|
|
31
32
|
}
|
|
32
33
|
}
|
|
34
|
+
class StateStoreReducerError extends Error {
|
|
35
|
+
constructor(message, options) {
|
|
36
|
+
super(message, options);
|
|
37
|
+
this.name = "StateStoreReducerError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
33
40
|
/** A recovered v3 store. `append` is the ordinary hot path. */
|
|
34
41
|
export class StateStore {
|
|
35
42
|
options;
|
|
@@ -40,6 +47,8 @@ export class StateStore {
|
|
|
40
47
|
state;
|
|
41
48
|
lastRecordSequence;
|
|
42
49
|
recordCount;
|
|
50
|
+
walBytes;
|
|
51
|
+
maxRecoveryReadBytes;
|
|
43
52
|
constructor(options, recovered, wasCreated) {
|
|
44
53
|
this.options = options;
|
|
45
54
|
this.scopeDigest = digestScope(options.scopeId);
|
|
@@ -48,6 +57,8 @@ export class StateStore {
|
|
|
48
57
|
this.state = recovered.state;
|
|
49
58
|
this.lastRecordSequence = recovered.lastRecordSequence;
|
|
50
59
|
this.recordCount = recovered.recordCount;
|
|
60
|
+
this.walBytes = recovered.walBytes;
|
|
61
|
+
this.maxRecoveryReadBytes = recovered.maxReadBytes;
|
|
51
62
|
this.wasCreated = wasCreated;
|
|
52
63
|
}
|
|
53
64
|
static exists(rootDir, scopeId) {
|
|
@@ -62,32 +73,86 @@ export class StateStore {
|
|
|
62
73
|
}
|
|
63
74
|
}
|
|
64
75
|
static open(options) {
|
|
65
|
-
|
|
66
|
-
throw new Error("state-store: scopeId is required");
|
|
67
|
-
const normalized = {
|
|
68
|
-
...options,
|
|
69
|
-
maxRecordsBeforeCompaction: options.maxRecordsBeforeCompaction ?? 256,
|
|
70
|
-
};
|
|
71
|
-
if (normalized.maxRecordsBeforeCompaction < 1) {
|
|
72
|
-
throw new Error("state-store: maxRecordsBeforeCompaction must be positive");
|
|
73
|
-
}
|
|
76
|
+
const normalized = normalizeOptions(options);
|
|
74
77
|
const scopeDigest = digestScope(normalized.scopeId);
|
|
75
78
|
const dir = stateDirectory(normalized.rootDir, scopeDigest);
|
|
76
79
|
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
return withScopeLock(dir, () => {
|
|
81
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
82
|
+
const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce);
|
|
83
|
+
if (recovered) {
|
|
84
|
+
return new StateStore(normalized, recovered, false);
|
|
85
|
+
}
|
|
86
|
+
const initial = {
|
|
87
|
+
generation: 1,
|
|
88
|
+
state: normalized.initialState,
|
|
89
|
+
lastRecordSequence: 0,
|
|
90
|
+
recordCount: 0,
|
|
91
|
+
walBytes: WAL_HEADER_BYTES,
|
|
92
|
+
maxReadBytes: 0,
|
|
93
|
+
};
|
|
94
|
+
writeGeneration(dir, scopeDigest, initial);
|
|
95
|
+
writeCurrentPointer(dir, initial.generation);
|
|
96
|
+
return new StateStore(normalized, initial, true);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Repair an over-budget generation without opening it once and refreshing it
|
|
101
|
+
* a second time. The entire recovery and two-copy publication happens under
|
|
102
|
+
* the scope lock, so an appender cannot race the migration.
|
|
103
|
+
*/
|
|
104
|
+
static repairIfNeeded(options, maintenance = {}) {
|
|
105
|
+
const normalized = normalizeOptions(options);
|
|
106
|
+
const scopeDigest = digestScope(normalized.scopeId);
|
|
107
|
+
const dir = stateDirectory(normalized.rootDir, scopeDigest);
|
|
108
|
+
if (!hasSnapshotGeneration(dir))
|
|
109
|
+
return { status: "missing" };
|
|
110
|
+
return withScopeLock(dir, () => {
|
|
111
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
112
|
+
const newestGeneration = highestSnapshotGenerationOnDisk(dir);
|
|
113
|
+
if (newestGeneration === undefined)
|
|
114
|
+
return { status: "missing" };
|
|
115
|
+
if (!maintenance.force) {
|
|
116
|
+
let walBytes;
|
|
117
|
+
let snapshotBytes;
|
|
118
|
+
const currentGeneration = currentGenerationOnDisk(dir);
|
|
119
|
+
try {
|
|
120
|
+
walBytes = fs.statSync(walPath(dir, newestGeneration)).size;
|
|
121
|
+
snapshotBytes = fs.statSync(snapshotPath(dir, newestGeneration)).size;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Let the structural recovery path below classify/fall back rather
|
|
125
|
+
// than turning an incomplete newest generation into a false no-op.
|
|
126
|
+
}
|
|
127
|
+
const overBudget = walBytes === undefined ||
|
|
128
|
+
snapshotBytes === undefined ||
|
|
129
|
+
retainedGenerationHistoryNeedsRepair(dir, newestGeneration, currentGeneration) ||
|
|
130
|
+
walBytes > normalized.maxWalBytesBeforeCompaction ||
|
|
131
|
+
(walBytes > WAL_HEADER_BYTES &&
|
|
132
|
+
snapshotBytes > normalized.maxWalBytesBeforeCompaction);
|
|
133
|
+
if (!overBudget)
|
|
134
|
+
return { status: "not-needed" };
|
|
135
|
+
}
|
|
136
|
+
const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce, maintenance.onProgress);
|
|
137
|
+
if (!recovered)
|
|
138
|
+
return { status: "missing" };
|
|
139
|
+
const store = new StateStore(normalized, recovered, false);
|
|
140
|
+
const recoveredGeneration = recovered.generation;
|
|
141
|
+
const recoveredWalBytes = recovered.walBytes;
|
|
142
|
+
const firstCompactGeneration = highestGenerationOnDisk(dir) + 1;
|
|
143
|
+
const first = store.compactGeneration(firstCompactGeneration);
|
|
144
|
+
const second = store.compactGeneration(firstCompactGeneration + 1);
|
|
145
|
+
writeCurrentPointer(dir, second.generation);
|
|
146
|
+
pruneGenerations(dir, [first.generation, second.generation]);
|
|
147
|
+
store.installGeneration(second);
|
|
148
|
+
return {
|
|
149
|
+
status: "repaired",
|
|
150
|
+
recoveredGeneration,
|
|
151
|
+
recoveredWalBytes,
|
|
152
|
+
firstCompactGeneration: first.generation,
|
|
153
|
+
currentGeneration: second.generation,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
91
156
|
}
|
|
92
157
|
getState() {
|
|
93
158
|
return this.state;
|
|
@@ -98,6 +163,9 @@ export class StateStore {
|
|
|
98
163
|
getLastRecordSequence() {
|
|
99
164
|
return this.lastRecordSequence;
|
|
100
165
|
}
|
|
166
|
+
getRecoveryStats() {
|
|
167
|
+
return { walBytes: this.walBytes, maxReadBytes: this.maxRecoveryReadBytes };
|
|
168
|
+
}
|
|
101
169
|
append(type, payload, flags = 0) {
|
|
102
170
|
assertUint16(type, "record type");
|
|
103
171
|
assertUint16(flags, "record flags");
|
|
@@ -114,15 +182,44 @@ export class StateStore {
|
|
|
114
182
|
}
|
|
115
183
|
// Validate reducer acceptance before making this frame durable.
|
|
116
184
|
const nextState = this.options.reduce(this.state, record);
|
|
117
|
-
|
|
185
|
+
const frame = encodeFrame(record);
|
|
186
|
+
appendDurably(walPath(this.stateDir, this.generation), frame);
|
|
118
187
|
this.state = nextState;
|
|
119
188
|
this.lastRecordSequence = record.sequence;
|
|
120
189
|
this.recordCount++;
|
|
121
|
-
|
|
190
|
+
this.walBytes += frame.length;
|
|
191
|
+
if (this.recordCount >= this.options.maxRecordsBeforeCompaction ||
|
|
192
|
+
this.walBytes >= this.options.maxWalBytesBeforeCompaction)
|
|
122
193
|
this.compactUnlocked();
|
|
123
194
|
return record;
|
|
124
195
|
});
|
|
125
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Atomically replace the current state with a compact snapshot generation.
|
|
199
|
+
*
|
|
200
|
+
* This is the rollback-compatible write path for domains whose previous
|
|
201
|
+
* reducer cannot understand a new WAL record type. If the recovered
|
|
202
|
+
* generation contains records, publish two compact copies so every retained
|
|
203
|
+
* WAL is header-only before pruning it. Later replacements need one new copy
|
|
204
|
+
* and retain the prior compact copy.
|
|
205
|
+
*/
|
|
206
|
+
replaceState(nextState) {
|
|
207
|
+
withScopeLock(this.stateDir, () => {
|
|
208
|
+
this.refreshFromDisk();
|
|
209
|
+
const previousGeneration = this.generation;
|
|
210
|
+
const previousWalHasRecords = this.walBytes > WAL_HEADER_BYTES;
|
|
211
|
+
const first = this.compactGeneration(highestGenerationOnDisk(this.stateDir) + 1, nextState);
|
|
212
|
+
let retainedGeneration = previousGeneration;
|
|
213
|
+
let current = first;
|
|
214
|
+
if (previousWalHasRecords) {
|
|
215
|
+
current = this.compactGeneration(first.generation + 1, nextState);
|
|
216
|
+
retainedGeneration = first.generation;
|
|
217
|
+
}
|
|
218
|
+
writeCurrentPointer(this.stateDir, current.generation);
|
|
219
|
+
pruneGenerations(this.stateDir, [retainedGeneration, current.generation]);
|
|
220
|
+
this.installGeneration(current);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
126
223
|
/** Create G+1 without ever merging two generations during recovery. */
|
|
127
224
|
compact() {
|
|
128
225
|
withScopeLock(this.stateDir, () => {
|
|
@@ -131,31 +228,41 @@ export class StateStore {
|
|
|
131
228
|
});
|
|
132
229
|
}
|
|
133
230
|
refreshFromDisk() {
|
|
134
|
-
const recovered =
|
|
135
|
-
if (recovered
|
|
231
|
+
const recovered = readNewestGeneration(this.stateDir, this.scopeDigest, this.options.reduce);
|
|
232
|
+
if (!recovered) {
|
|
136
233
|
throw new StateStoreCorruptionError("no valid generation remains while appending");
|
|
137
234
|
}
|
|
138
|
-
|
|
139
|
-
this.generation = current.generation;
|
|
140
|
-
this.state = current.state;
|
|
141
|
-
this.lastRecordSequence = current.lastRecordSequence;
|
|
142
|
-
this.recordCount = current.recordCount;
|
|
235
|
+
this.installGeneration(recovered);
|
|
143
236
|
}
|
|
144
237
|
compactUnlocked() {
|
|
238
|
+
const previousGeneration = this.generation;
|
|
239
|
+
const next = this.compactGeneration(highestGenerationOnDisk(this.stateDir) + 1);
|
|
240
|
+
writeCurrentPointer(this.stateDir, next.generation);
|
|
241
|
+
pruneGenerations(this.stateDir, [previousGeneration, next.generation]);
|
|
242
|
+
this.installGeneration(next);
|
|
243
|
+
}
|
|
244
|
+
compactGeneration(generation, state = this.state) {
|
|
145
245
|
const next = {
|
|
146
|
-
generation
|
|
147
|
-
state
|
|
246
|
+
generation,
|
|
247
|
+
state,
|
|
148
248
|
lastRecordSequence: this.lastRecordSequence,
|
|
149
249
|
recordCount: 0,
|
|
250
|
+
walBytes: WAL_HEADER_BYTES,
|
|
251
|
+
maxReadBytes: 0,
|
|
150
252
|
};
|
|
151
253
|
writeGeneration(this.stateDir, this.scopeDigest, next);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
this.
|
|
254
|
+
return next;
|
|
255
|
+
}
|
|
256
|
+
installGeneration(generation) {
|
|
257
|
+
this.generation = generation.generation;
|
|
258
|
+
this.state = generation.state;
|
|
259
|
+
this.lastRecordSequence = generation.lastRecordSequence;
|
|
260
|
+
this.recordCount = generation.recordCount;
|
|
261
|
+
this.walBytes = generation.walBytes;
|
|
262
|
+
this.maxRecoveryReadBytes = generation.maxReadBytes;
|
|
156
263
|
}
|
|
157
264
|
}
|
|
158
|
-
function
|
|
265
|
+
function readNewestGeneration(dir, scopeDigest, reduce, onProgress) {
|
|
159
266
|
const names = fs.readdirSync(dir);
|
|
160
267
|
const generations = names
|
|
161
268
|
.map((name) => /^snapshot-(\d+)\.bin$/.exec(name)?.[1])
|
|
@@ -163,37 +270,40 @@ function readGenerations(dir, scopeDigest, reduce) {
|
|
|
163
270
|
.map(Number)
|
|
164
271
|
.filter(Number.isSafeInteger)
|
|
165
272
|
.sort((a, b) => b - a);
|
|
166
|
-
const recovered = [];
|
|
167
273
|
const errors = [];
|
|
168
274
|
for (const generation of generations) {
|
|
169
275
|
try {
|
|
170
|
-
|
|
276
|
+
return readGeneration(dir, scopeDigest, generation, reduce, onProgress);
|
|
171
277
|
}
|
|
172
278
|
catch (error) {
|
|
173
|
-
if (
|
|
279
|
+
if (error instanceof StateStoreReducerError) {
|
|
174
280
|
throw new Error(`state-store: reducer rejected generation ${generation}`, { cause: error });
|
|
175
281
|
}
|
|
282
|
+
if (!(error instanceof StateStoreCorruptionError))
|
|
283
|
+
throw error;
|
|
176
284
|
errors.push(error);
|
|
177
285
|
}
|
|
178
286
|
}
|
|
179
|
-
if (
|
|
287
|
+
if (generations.length > 0) {
|
|
180
288
|
throw new StateStoreCorruptionError("no valid snapshot/WAL generation remains", {
|
|
181
289
|
cause: errors[0],
|
|
182
290
|
});
|
|
183
291
|
}
|
|
184
|
-
return
|
|
292
|
+
return undefined;
|
|
185
293
|
}
|
|
186
|
-
function readGeneration(dir, scopeDigest, generation, reduce) {
|
|
294
|
+
function readGeneration(dir, scopeDigest, generation, reduce, onProgress) {
|
|
295
|
+
const snapshotBytes = generationFileSize(snapshotPath(dir, generation), "snapshot", generation);
|
|
296
|
+
const totalBytes = snapshotBytes + generationFileSize(walPath(dir, generation), "WAL", generation);
|
|
187
297
|
const snapshot = readSnapshot(snapshotPath(dir, generation), scopeDigest, generation);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
for (const record of parsed.records)
|
|
191
|
-
state = reduce(state, record);
|
|
298
|
+
onProgress?.({ bytesRead: snapshotBytes, totalBytes });
|
|
299
|
+
const parsed = readWal(walPath(dir, generation), scopeDigest, generation, snapshot.payload.lastRecordSequence, snapshot.payload.state, reduce, snapshotBytes, totalBytes, onProgress);
|
|
192
300
|
return {
|
|
193
301
|
generation,
|
|
194
|
-
state,
|
|
302
|
+
state: parsed.state,
|
|
195
303
|
lastRecordSequence: parsed.lastRecordSequence,
|
|
196
|
-
recordCount: parsed.
|
|
304
|
+
recordCount: parsed.recordCount,
|
|
305
|
+
walBytes: parsed.walBytes,
|
|
306
|
+
maxReadBytes: Math.max(snapshot.maxReadBytes, parsed.maxReadBytes),
|
|
197
307
|
};
|
|
198
308
|
}
|
|
199
309
|
function writeGeneration(dir, scopeDigest, generation) {
|
|
@@ -207,71 +317,172 @@ function writeGeneration(dir, scopeDigest, generation) {
|
|
|
207
317
|
writeNewDurably(walPath(dir, generation.generation), header);
|
|
208
318
|
}
|
|
209
319
|
function readSnapshot(filePath, scopeDigest, generation) {
|
|
210
|
-
let
|
|
320
|
+
let fd;
|
|
211
321
|
try {
|
|
212
|
-
|
|
322
|
+
fd = fs.openSync(filePath, "r");
|
|
213
323
|
}
|
|
214
324
|
catch (error) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
throw new
|
|
219
|
-
}
|
|
220
|
-
if (!bytes.subarray(0, 8).equals(SNAPSHOT_MAGIC)) {
|
|
221
|
-
throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid magic`);
|
|
222
|
-
}
|
|
223
|
-
if (readU64(bytes, 8) !== generation) {
|
|
224
|
-
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a generation mismatch`);
|
|
225
|
-
}
|
|
226
|
-
const payloadLength = bytes.readUInt32BE(16);
|
|
227
|
-
if (bytes.length !== SNAPSHOT_HEADER_BYTES + payloadLength) {
|
|
228
|
-
throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid payload length`);
|
|
229
|
-
}
|
|
230
|
-
if (!bytes.subarray(20, 52).equals(scopeDigest)) {
|
|
231
|
-
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a scope digest mismatch`);
|
|
232
|
-
}
|
|
233
|
-
const expected = sha256(bytes.subarray(0, 52 + payloadLength));
|
|
234
|
-
if (!expected.equals(bytes.subarray(52 + payloadLength))) {
|
|
235
|
-
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a bad checksum`);
|
|
325
|
+
if (isMissing(error)) {
|
|
326
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} is missing`, { cause: error });
|
|
327
|
+
}
|
|
328
|
+
throw new Error(`state-store: cannot read snapshot generation ${generation}`, { cause: error });
|
|
236
329
|
}
|
|
237
|
-
let decoded;
|
|
238
330
|
try {
|
|
239
|
-
|
|
331
|
+
const stat = fs.fstatSync(fd);
|
|
332
|
+
const header = Buffer.alloc(52);
|
|
333
|
+
if (readIntoSync(fd, header, 0) !== header.length) {
|
|
334
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a torn header`);
|
|
335
|
+
}
|
|
336
|
+
if (!header.subarray(0, 8).equals(SNAPSHOT_MAGIC)) {
|
|
337
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid magic`);
|
|
338
|
+
}
|
|
339
|
+
if (readU64(header, 8) !== generation) {
|
|
340
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a generation mismatch`);
|
|
341
|
+
}
|
|
342
|
+
const payloadLength = header.readUInt32BE(16);
|
|
343
|
+
if (stat.size !== SNAPSHOT_HEADER_BYTES + payloadLength) {
|
|
344
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid payload length`);
|
|
345
|
+
}
|
|
346
|
+
if (!header.subarray(20, 52).equals(scopeDigest)) {
|
|
347
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a scope digest mismatch`);
|
|
348
|
+
}
|
|
349
|
+
const data = Buffer.allocUnsafe(payloadLength);
|
|
350
|
+
if (readIntoSync(fd, data, 52) !== data.length) {
|
|
351
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a torn payload`);
|
|
352
|
+
}
|
|
353
|
+
const checksum = Buffer.alloc(32);
|
|
354
|
+
if (readIntoSync(fd, checksum, 52 + payloadLength) !== checksum.length) {
|
|
355
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a torn checksum`);
|
|
356
|
+
}
|
|
357
|
+
const expected = crypto.createHash("sha256").update(header).update(data).digest();
|
|
358
|
+
if (!expected.equals(checksum)) {
|
|
359
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a bad checksum`);
|
|
360
|
+
}
|
|
361
|
+
let decoded;
|
|
362
|
+
try {
|
|
363
|
+
decoded = JSON.parse(data.toString("utf8"));
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has invalid JSON`, { cause: error });
|
|
367
|
+
}
|
|
368
|
+
if (!isSnapshotPayload(decoded)) {
|
|
369
|
+
throw new StateStoreCorruptionError(`snapshot generation ${generation} has an invalid state payload`);
|
|
370
|
+
}
|
|
371
|
+
return { payload: decoded, maxReadBytes: data.length };
|
|
240
372
|
}
|
|
241
373
|
catch (error) {
|
|
242
|
-
|
|
374
|
+
if (error instanceof StateStoreCorruptionError)
|
|
375
|
+
throw error;
|
|
376
|
+
throw new Error(`state-store: cannot read snapshot generation ${generation}`, { cause: error });
|
|
243
377
|
}
|
|
244
|
-
|
|
245
|
-
|
|
378
|
+
finally {
|
|
379
|
+
fs.closeSync(fd);
|
|
246
380
|
}
|
|
247
|
-
return decoded;
|
|
248
381
|
}
|
|
249
|
-
function readWal(filePath, scopeDigest, generation, snapshotSequence) {
|
|
250
|
-
let
|
|
382
|
+
function readWal(filePath, scopeDigest, generation, snapshotSequence, initialState, reduce, recoveryBytesBeforeWal = 0, recoveryTotalBytes, onProgress) {
|
|
383
|
+
let fd;
|
|
251
384
|
try {
|
|
252
|
-
|
|
385
|
+
fd = fs.openSync(filePath, "r");
|
|
253
386
|
}
|
|
254
387
|
catch (error) {
|
|
255
|
-
|
|
388
|
+
if (isMissing(error)) {
|
|
389
|
+
throw new StateStoreCorruptionError(`WAL generation ${generation} is missing`, { cause: error });
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`state-store: cannot read WAL generation ${generation}`, { cause: error });
|
|
256
392
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
truncateWal(filePath, offset);
|
|
265
|
-
break;
|
|
393
|
+
let truncateBoundary;
|
|
394
|
+
try {
|
|
395
|
+
const walBytes = fs.fstatSync(fd).size;
|
|
396
|
+
const header = Buffer.alloc(WAL_HEADER_BYTES);
|
|
397
|
+
const headerBytes = readIntoSync(fd, header, 0);
|
|
398
|
+
if (headerBytes < WAL_HEADER_BYTES) {
|
|
399
|
+
throw new StateStoreCorruptionError(`WAL generation ${generation} has a torn header`);
|
|
266
400
|
}
|
|
267
|
-
|
|
268
|
-
|
|
401
|
+
verifyWalHeader(header, scopeDigest, generation);
|
|
402
|
+
let state = initialState;
|
|
403
|
+
let offset = WAL_HEADER_BYTES;
|
|
404
|
+
let sequence = snapshotSequence;
|
|
405
|
+
let recordCount = 0;
|
|
406
|
+
let maxReadBytes = header.length;
|
|
407
|
+
while (offset < walBytes) {
|
|
408
|
+
const prefix = Buffer.alloc(FRAME_PREFIX_BYTES);
|
|
409
|
+
if (readIntoSync(fd, prefix, offset) < FRAME_PREFIX_BYTES) {
|
|
410
|
+
truncateBoundary = offset;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
if (prefix.readUInt32BE(0) !== FRAME_MAGIC) {
|
|
414
|
+
throw new StateStoreCorruptionError("WAL has an invalid frame magic");
|
|
415
|
+
}
|
|
416
|
+
const frameLength = prefix.readUInt32BE(4);
|
|
417
|
+
if (frameLength < FRAME_FIXED_BODY_BYTES || frameLength > MAX_FRAME_BYTES) {
|
|
418
|
+
throw new StateStoreCorruptionError("WAL has an invalid frame length");
|
|
419
|
+
}
|
|
420
|
+
const available = walBytes - offset - FRAME_PREFIX_BYTES;
|
|
421
|
+
if (available < frameLength) {
|
|
422
|
+
if (available >= 16) {
|
|
423
|
+
const fixed = Buffer.alloc(16);
|
|
424
|
+
readIntoSync(fd, fixed, offset + FRAME_PREFIX_BYTES);
|
|
425
|
+
const declaredPayloadLength = fixed.readUInt32BE(12);
|
|
426
|
+
const declaredFrameLength = FRAME_FIXED_BODY_BYTES + declaredPayloadLength;
|
|
427
|
+
if (declaredFrameLength <= available && declaredFrameLength !== frameLength) {
|
|
428
|
+
throw new StateStoreCorruptionError("WAL frame length prefix is corrupt");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
truncateBoundary = offset;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
const frame = Buffer.allocUnsafe(FRAME_PREFIX_BYTES + frameLength);
|
|
435
|
+
prefix.copy(frame);
|
|
436
|
+
if (readIntoSync(fd, frame.subarray(FRAME_PREFIX_BYTES), offset + FRAME_PREFIX_BYTES) !== frameLength) {
|
|
437
|
+
truncateBoundary = offset;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
maxReadBytes = Math.max(maxReadBytes, frame.length);
|
|
441
|
+
const parsed = decodeFrame(frame, 0);
|
|
442
|
+
if (parsed.kind === "torn") {
|
|
443
|
+
truncateBoundary = offset;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
if (parsed.record.sequence !== sequence + 1) {
|
|
447
|
+
throw new StateStoreCorruptionError(`WAL generation ${generation} has non-contiguous record sequence`);
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
state = reduce(state, parsed.record);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
throw new StateStoreReducerError(`record ${parsed.record.sequence} was rejected`, { cause: error });
|
|
454
|
+
}
|
|
455
|
+
sequence = parsed.record.sequence;
|
|
456
|
+
recordCount++;
|
|
457
|
+
offset += frame.length;
|
|
458
|
+
onProgress?.({
|
|
459
|
+
bytesRead: recoveryBytesBeforeWal + offset,
|
|
460
|
+
totalBytes: recoveryTotalBytes ?? recoveryBytesBeforeWal + walBytes,
|
|
461
|
+
});
|
|
269
462
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
463
|
+
onProgress?.({
|
|
464
|
+
bytesRead: recoveryBytesBeforeWal + (truncateBoundary ?? walBytes),
|
|
465
|
+
totalBytes: recoveryTotalBytes ?? recoveryBytesBeforeWal + walBytes,
|
|
466
|
+
});
|
|
467
|
+
return {
|
|
468
|
+
state,
|
|
469
|
+
lastRecordSequence: sequence,
|
|
470
|
+
recordCount,
|
|
471
|
+
walBytes: truncateBoundary ?? walBytes,
|
|
472
|
+
maxReadBytes,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (error instanceof StateStoreCorruptionError ||
|
|
477
|
+
error instanceof StateStoreReducerError)
|
|
478
|
+
throw error;
|
|
479
|
+
throw new Error(`state-store: cannot read WAL generation ${generation}`, { cause: error });
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
fs.closeSync(fd);
|
|
483
|
+
if (truncateBoundary !== undefined)
|
|
484
|
+
truncateWal(filePath, truncateBoundary);
|
|
273
485
|
}
|
|
274
|
-
return { records, lastRecordSequence: sequence };
|
|
275
486
|
}
|
|
276
487
|
function verifyWalHeader(bytes, scopeDigest, generation) {
|
|
277
488
|
if (bytes.length < WAL_HEADER_BYTES) {
|
|
@@ -442,6 +653,16 @@ function writeAllSync(fd, bytes) {
|
|
|
442
653
|
offset += written;
|
|
443
654
|
}
|
|
444
655
|
}
|
|
656
|
+
function readIntoSync(fd, target, position) {
|
|
657
|
+
let offset = 0;
|
|
658
|
+
while (offset < target.length) {
|
|
659
|
+
const read = fs.readSync(fd, target, offset, target.length - offset, position + offset);
|
|
660
|
+
if (read === 0)
|
|
661
|
+
break;
|
|
662
|
+
offset += read;
|
|
663
|
+
}
|
|
664
|
+
return offset;
|
|
665
|
+
}
|
|
445
666
|
function withScopeLock(dir, action) {
|
|
446
667
|
const lockPath = path.join(dir, "append.lock");
|
|
447
668
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
@@ -545,6 +766,47 @@ function fsyncDirectory(dir) {
|
|
|
545
766
|
function stateDirectory(rootDir, scopeDigest) {
|
|
546
767
|
return path.join(rootDir, "sync-state-v3", scopeDigest.toString("hex"));
|
|
547
768
|
}
|
|
769
|
+
function normalizeOptions(options) {
|
|
770
|
+
if (!options.scopeId)
|
|
771
|
+
throw new Error("state-store: scopeId is required");
|
|
772
|
+
const normalized = {
|
|
773
|
+
...options,
|
|
774
|
+
maxRecordsBeforeCompaction: options.maxRecordsBeforeCompaction ?? 256,
|
|
775
|
+
maxWalBytesBeforeCompaction: options.maxWalBytesBeforeCompaction ?? DEFAULT_MAX_WAL_BYTES_BEFORE_COMPACTION,
|
|
776
|
+
};
|
|
777
|
+
if (normalized.maxRecordsBeforeCompaction < 1) {
|
|
778
|
+
throw new Error("state-store: maxRecordsBeforeCompaction must be positive");
|
|
779
|
+
}
|
|
780
|
+
if (normalized.maxWalBytesBeforeCompaction < WAL_HEADER_BYTES + 1) {
|
|
781
|
+
throw new Error("state-store: maxWalBytesBeforeCompaction must exceed the WAL header size");
|
|
782
|
+
}
|
|
783
|
+
return normalized;
|
|
784
|
+
}
|
|
785
|
+
function generationFileSize(filePath, kind, generation) {
|
|
786
|
+
try {
|
|
787
|
+
return fs.statSync(filePath).size;
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
if (isMissing(error)) {
|
|
791
|
+
throw new StateStoreCorruptionError(`${kind} generation ${generation} is missing`, {
|
|
792
|
+
cause: error,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
throw new Error(`state-store: cannot inspect ${kind} generation ${generation}`, {
|
|
796
|
+
cause: error,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function hasSnapshotGeneration(dir) {
|
|
801
|
+
try {
|
|
802
|
+
return fs.readdirSync(dir).some((name) => /^snapshot-\d+\.bin$/.test(name));
|
|
803
|
+
}
|
|
804
|
+
catch (error) {
|
|
805
|
+
if (isMissing(error))
|
|
806
|
+
return false;
|
|
807
|
+
throw new Error(`state-store: cannot inspect ${dir}`, { cause: error });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
548
810
|
function snapshotPath(dir, generation) {
|
|
549
811
|
return path.join(dir, `snapshot-${generation}.bin`);
|
|
550
812
|
}
|
|
@@ -559,6 +821,56 @@ function highestGenerationOnDisk(dir) {
|
|
|
559
821
|
.filter(Number.isSafeInteger)
|
|
560
822
|
.reduce((highest, generation) => Math.max(highest, generation), 0);
|
|
561
823
|
}
|
|
824
|
+
function highestSnapshotGenerationOnDisk(dir) {
|
|
825
|
+
const generations = fs.readdirSync(dir)
|
|
826
|
+
.map((name) => /^snapshot-(\d+)\.bin$/.exec(name)?.[1])
|
|
827
|
+
.filter((value) => value !== undefined)
|
|
828
|
+
.map(Number)
|
|
829
|
+
.filter(Number.isSafeInteger);
|
|
830
|
+
return generations.length === 0 ? undefined : Math.max(...generations);
|
|
831
|
+
}
|
|
832
|
+
function currentGenerationOnDisk(dir) {
|
|
833
|
+
try {
|
|
834
|
+
const generation = Number(fs.readFileSync(path.join(dir, "current"), "ascii").trim());
|
|
835
|
+
return Number.isSafeInteger(generation) && generation > 0 ? generation : undefined;
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
if (isMissing(error))
|
|
839
|
+
return undefined;
|
|
840
|
+
throw new Error("state-store: cannot read current generation pointer", { cause: error });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Detect a crash after pointer publication but before obsolete-history prune.
|
|
845
|
+
* Journal maintenance promises rollback readers that every retained WAL is a
|
|
846
|
+
* compact header, so an old record-bearing generation is migration work even
|
|
847
|
+
* when the newest generation and pointer already look healthy.
|
|
848
|
+
*/
|
|
849
|
+
function retainedGenerationHistoryNeedsRepair(dir, newestGeneration, currentGeneration) {
|
|
850
|
+
if (currentGeneration !== newestGeneration)
|
|
851
|
+
return true;
|
|
852
|
+
const snapshots = new Set();
|
|
853
|
+
const wals = new Set();
|
|
854
|
+
for (const name of fs.readdirSync(dir)) {
|
|
855
|
+
const snapshot = /^snapshot-(\d+)\.bin$/.exec(name);
|
|
856
|
+
if (snapshot)
|
|
857
|
+
snapshots.add(Number(snapshot[1]));
|
|
858
|
+
const wal = /^wal-(\d+)\.bin$/.exec(name);
|
|
859
|
+
if (wal)
|
|
860
|
+
wals.add(Number(wal[1]));
|
|
861
|
+
}
|
|
862
|
+
if (snapshots.size === 0 || snapshots.size > 2 || snapshots.size !== wals.size) {
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
for (const generation of snapshots) {
|
|
866
|
+
if (!wals.has(generation))
|
|
867
|
+
return true;
|
|
868
|
+
if (generationFileSize(walPath(dir, generation), "WAL", generation) !== WAL_HEADER_BYTES) {
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
562
874
|
function pruneGenerations(dir, retain) {
|
|
563
875
|
const retained = new Set(retain);
|
|
564
876
|
for (const name of fs.readdirSync(dir)) {
|