@indigoai-us/hq-cloud 6.15.74 → 6.15.76
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/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +12 -2
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +4 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +63 -3
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/journal-store.test.js +12 -0
- package/dist/journal-store.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +5 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +3 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +45 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/state-store.d.ts +7 -0
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +380 -112
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +199 -1
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/watcher.d.ts +59 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +186 -15
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +311 -3
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
package/dist/sync/state-store.js
CHANGED
|
@@ -39,12 +39,15 @@ const DEFAULT_MAX_WAL_BYTES_BEFORE_COMPACTION = 16 * 1024 * 1024;
|
|
|
39
39
|
const LOCK_RETRY_MS = 10;
|
|
40
40
|
const LOCK_TIMEOUT_MS = 5_000;
|
|
41
41
|
const REPLACE_STATE_MAX_ATTEMPTS = 3;
|
|
42
|
+
const OPTIMISTIC_RECOVERY_MAX_RESAMPLES = 3;
|
|
42
43
|
// Test instrumentation for materialisation regressions. Kept at the storage
|
|
43
44
|
// seam so a fixture counts actual snapshot JSON decodes, not API invocations.
|
|
44
45
|
let snapshotRecoveryCount = 0;
|
|
45
46
|
let snapshotMaterializationCount = 0;
|
|
46
47
|
let snapshotEncodeCount = 0;
|
|
47
48
|
let snapshotWriteHookForTest;
|
|
49
|
+
let snapshotReadHookForTest;
|
|
50
|
+
let walReadHookForTest;
|
|
48
51
|
let afterGenerationStageHookForTest;
|
|
49
52
|
let reflinkCopyHookForTest;
|
|
50
53
|
let positionalWriteForTest;
|
|
@@ -71,6 +74,14 @@ export function getStateStoreSnapshotEncodeCountForTest() {
|
|
|
71
74
|
export function setStateStoreSnapshotWriteHookForTest(hook) {
|
|
72
75
|
snapshotWriteHookForTest = hook;
|
|
73
76
|
}
|
|
77
|
+
/** Test-only seam after snapshot validation and before its large payload read. */
|
|
78
|
+
export function setStateStoreSnapshotReadHookForTest(hook) {
|
|
79
|
+
snapshotReadHookForTest = hook;
|
|
80
|
+
}
|
|
81
|
+
/** Test-only seam after the WAL length is sampled during cold recovery. */
|
|
82
|
+
export function setStateStoreWalReadHookForTest(hook) {
|
|
83
|
+
walReadHookForTest = hook;
|
|
84
|
+
}
|
|
74
85
|
/** Test-only crash seam after durable staging and before lock-protected publish. */
|
|
75
86
|
export function setStateStoreAfterGenerationStageHookForTest(hook) {
|
|
76
87
|
afterGenerationStageHookForTest = hook;
|
|
@@ -105,6 +116,9 @@ class StateStoreReducerError extends Error {
|
|
|
105
116
|
this.name = "StateStoreReducerError";
|
|
106
117
|
}
|
|
107
118
|
}
|
|
119
|
+
/** A sampled generation disappeared while it was being read off-lock. */
|
|
120
|
+
class StateStoreGenerationMissingError extends StateStoreCorruptionError {
|
|
121
|
+
}
|
|
108
122
|
/** A recovered v3 store. `append` is the ordinary hot path. */
|
|
109
123
|
export class StateStore {
|
|
110
124
|
options;
|
|
@@ -117,6 +131,7 @@ export class StateStore {
|
|
|
117
131
|
recordCount;
|
|
118
132
|
walBytes;
|
|
119
133
|
maxRecoveryReadBytes;
|
|
134
|
+
observedHighestSnapshotGeneration;
|
|
120
135
|
constructor(options, recovered, wasCreated) {
|
|
121
136
|
this.options = options;
|
|
122
137
|
this.scopeDigest = digestScope(options.scopeId);
|
|
@@ -127,6 +142,7 @@ export class StateStore {
|
|
|
127
142
|
this.recordCount = recovered.recordCount;
|
|
128
143
|
this.walBytes = recovered.walBytes;
|
|
129
144
|
this.maxRecoveryReadBytes = recovered.maxReadBytes;
|
|
145
|
+
this.observedHighestSnapshotGeneration = recovered.observedHighestSnapshotGeneration ?? recovered.generation;
|
|
130
146
|
this.wasCreated = wasCreated;
|
|
131
147
|
}
|
|
132
148
|
static exists(rootDir, scopeId) {
|
|
@@ -145,24 +161,32 @@ export class StateStore {
|
|
|
145
161
|
const scopeDigest = digestScope(normalized.scopeId);
|
|
146
162
|
const dir = stateDirectory(normalized.rootDir, scopeDigest);
|
|
147
163
|
fs.mkdirSync(dir, { recursive: true });
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (recovered) {
|
|
164
|
+
while (true) {
|
|
165
|
+
const recovered = readNewestGenerationOptimistically(dir, scopeDigest, normalized.reduce);
|
|
166
|
+
if (recovered)
|
|
152
167
|
return new StateStore(normalized, recovered, false);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
const created = withScopeLock(dir, () => {
|
|
169
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
170
|
+
if (highestCompleteGenerationOnDisk(dir) !== undefined)
|
|
171
|
+
return undefined;
|
|
172
|
+
if (hasStateStoreArtifacts(dir)) {
|
|
173
|
+
throw new StateStoreCorruptionError("no complete generation remains while opening");
|
|
174
|
+
}
|
|
175
|
+
const initial = {
|
|
176
|
+
generation: 1,
|
|
177
|
+
state: normalized.initialState,
|
|
178
|
+
lastRecordSequence: 0,
|
|
179
|
+
recordCount: 0,
|
|
180
|
+
walBytes: WAL_HEADER_BYTES,
|
|
181
|
+
maxReadBytes: 0,
|
|
182
|
+
};
|
|
183
|
+
writeGeneration(dir, scopeDigest, initial);
|
|
184
|
+
writeCurrentPointer(dir, initial.generation);
|
|
185
|
+
return new StateStore(normalized, initial, true);
|
|
186
|
+
});
|
|
187
|
+
if (created)
|
|
188
|
+
return created;
|
|
189
|
+
}
|
|
166
190
|
}
|
|
167
191
|
/** Yielding first-open variant for callback-originated projections. */
|
|
168
192
|
static async openAsync(options) {
|
|
@@ -170,23 +194,32 @@ export class StateStore {
|
|
|
170
194
|
const scopeDigest = digestScope(normalized.scopeId);
|
|
171
195
|
const dir = stateDirectory(normalized.rootDir, scopeDigest);
|
|
172
196
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce);
|
|
197
|
+
while (true) {
|
|
198
|
+
const recovered = await readNewestGenerationOptimisticallyAsync(dir, scopeDigest, normalized.reduce);
|
|
176
199
|
if (recovered)
|
|
177
200
|
return new StateStore(normalized, recovered, false);
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
const created = await withScopeLockAsync(dir, () => {
|
|
202
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
203
|
+
if (highestCompleteGenerationOnDisk(dir) !== undefined)
|
|
204
|
+
return undefined;
|
|
205
|
+
if (hasStateStoreArtifacts(dir)) {
|
|
206
|
+
throw new StateStoreCorruptionError("no complete generation remains while opening");
|
|
207
|
+
}
|
|
208
|
+
const initial = {
|
|
209
|
+
generation: 1,
|
|
210
|
+
state: normalized.initialState,
|
|
211
|
+
lastRecordSequence: 0,
|
|
212
|
+
recordCount: 0,
|
|
213
|
+
walBytes: WAL_HEADER_BYTES,
|
|
214
|
+
maxReadBytes: 0,
|
|
215
|
+
};
|
|
216
|
+
writeGeneration(dir, scopeDigest, initial);
|
|
217
|
+
writeCurrentPointer(dir, initial.generation);
|
|
218
|
+
return new StateStore(normalized, initial, true);
|
|
219
|
+
});
|
|
220
|
+
if (created)
|
|
221
|
+
return created;
|
|
222
|
+
}
|
|
190
223
|
}
|
|
191
224
|
/**
|
|
192
225
|
* Repair an over-budget generation without making appenders wait for snapshot
|
|
@@ -200,40 +233,44 @@ export class StateStore {
|
|
|
200
233
|
const dir = stateDirectory(normalized.rootDir, scopeDigest);
|
|
201
234
|
if (!hasSnapshotGeneration(dir))
|
|
202
235
|
return { status: "missing" };
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
walBytes = fs.statSync(walPath(dir, newestGeneration)).size;
|
|
214
|
-
snapshotBytes = fs.statSync(snapshotPath(dir, newestGeneration)).size;
|
|
236
|
+
let plan;
|
|
237
|
+
while (!plan) {
|
|
238
|
+
const preflight = withScopeLock(dir, () => {
|
|
239
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
240
|
+
const newestGeneration = highestCompleteGenerationOnDisk(dir);
|
|
241
|
+
if (newestGeneration === undefined)
|
|
242
|
+
return undefined;
|
|
243
|
+
if (!maintenance.force && !needsMaintenance(dir, newestGeneration, normalized)) {
|
|
244
|
+
return false;
|
|
215
245
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
246
|
+
return newestGeneration;
|
|
247
|
+
});
|
|
248
|
+
if (preflight === undefined) {
|
|
249
|
+
// Preserve recovery's corruption classification for a crashed or
|
|
250
|
+
// malformed store that has snapshots but no complete generation.
|
|
251
|
+
if (hasSnapshotGeneration(dir)) {
|
|
252
|
+
readNewestGeneration(dir, scopeDigest, normalized.reduce, maintenance.onProgress, false);
|
|
253
|
+
continue;
|
|
219
254
|
}
|
|
220
|
-
|
|
221
|
-
snapshotBytes === undefined ||
|
|
222
|
-
retainedGenerationHistoryNeedsRepair(dir, newestGeneration, currentGeneration) ||
|
|
223
|
-
walBytes > normalized.maxWalBytesBeforeCompaction;
|
|
224
|
-
if (!overBudget)
|
|
225
|
-
return undefined;
|
|
255
|
+
return { status: "missing" };
|
|
226
256
|
}
|
|
227
|
-
|
|
257
|
+
if (preflight === false)
|
|
258
|
+
return { status: "not-needed" };
|
|
259
|
+
// Snapshot and WAL replay are read-only. Do not make appenders wait for
|
|
260
|
+
// this potentially multi-second materialisation; validate its view before
|
|
261
|
+
// using it to build a replacement generation.
|
|
262
|
+
const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce, maintenance.onProgress, false);
|
|
228
263
|
if (!recovered)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
recovered
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
264
|
+
continue;
|
|
265
|
+
plan = withScopeLock(dir, () => {
|
|
266
|
+
const validated = validateOptimisticRecovery(dir, scopeDigest, normalized.reduce, preflight, recovered);
|
|
267
|
+
if (!validated)
|
|
268
|
+
return undefined;
|
|
269
|
+
if (!maintenance.force && !needsMaintenance(dir, preflight, normalized))
|
|
270
|
+
return undefined;
|
|
271
|
+
return { recovered: validated, highestGeneration: highestGenerationOnDisk(dir) };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
237
274
|
const first = {
|
|
238
275
|
generation: plan.highestGeneration + 1,
|
|
239
276
|
state: plan.recovered.state,
|
|
@@ -297,15 +334,26 @@ export class StateStore {
|
|
|
297
334
|
append(type, payload, flags = 0) {
|
|
298
335
|
assertUint16(type, "record type");
|
|
299
336
|
assertUint16(flags, "record flags");
|
|
300
|
-
let
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
337
|
+
let recovered = false;
|
|
338
|
+
while (true) {
|
|
339
|
+
let compact = false;
|
|
340
|
+
const result = withScopeLock(this.stateDir, () => {
|
|
341
|
+
const refresh = this.refreshFromDisk();
|
|
342
|
+
if (!refresh)
|
|
343
|
+
return undefined;
|
|
344
|
+
const appended = this.appendUnlocked(type, payload, flags, recovered ? { records: [], recovered: true } : refresh);
|
|
345
|
+
compact = this.needsCompaction();
|
|
346
|
+
return appended;
|
|
347
|
+
});
|
|
348
|
+
if (!result) {
|
|
349
|
+
this.recoverNewestGenerationOutsideLock();
|
|
350
|
+
recovered = true;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (compact)
|
|
354
|
+
this.compact();
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
309
357
|
}
|
|
310
358
|
/**
|
|
311
359
|
* Async lock acquisition for callback-originated work. The write itself
|
|
@@ -315,42 +363,77 @@ export class StateStore {
|
|
|
315
363
|
async appendAsync(type, payload, flags = 0) {
|
|
316
364
|
assertUint16(type, "record type");
|
|
317
365
|
assertUint16(flags, "record flags");
|
|
318
|
-
let
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
366
|
+
let recovered = false;
|
|
367
|
+
while (true) {
|
|
368
|
+
let compact = false;
|
|
369
|
+
const result = await withScopeLockAsync(this.stateDir, () => {
|
|
370
|
+
const refresh = this.refreshFromDisk();
|
|
371
|
+
if (!refresh)
|
|
372
|
+
return undefined;
|
|
373
|
+
const appended = this.appendUnlocked(type, payload, flags, recovered ? { records: [], recovered: true } : refresh);
|
|
374
|
+
compact = this.needsCompaction();
|
|
375
|
+
return appended;
|
|
376
|
+
});
|
|
377
|
+
if (!result) {
|
|
378
|
+
await this.recoverNewestGenerationOutsideLockAsync();
|
|
379
|
+
recovered = true;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (compact)
|
|
383
|
+
this.compact();
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
327
386
|
}
|
|
328
387
|
/** Conditionally append after obtaining the async scope lock. */
|
|
329
388
|
async appendIfAsync(type, payload, shouldAppend, flags = 0) {
|
|
330
389
|
assertUint16(type, "record type");
|
|
331
390
|
assertUint16(flags, "record flags");
|
|
332
|
-
let
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
391
|
+
let recovered = false;
|
|
392
|
+
while (true) {
|
|
393
|
+
let compact = false;
|
|
394
|
+
const result = await withScopeLockAsync(this.stateDir, () => {
|
|
395
|
+
const refresh = this.refreshFromDisk();
|
|
396
|
+
if (!refresh)
|
|
397
|
+
return { retry: true };
|
|
398
|
+
if (!shouldAppend())
|
|
399
|
+
return { retry: false, result: undefined };
|
|
400
|
+
const appended = this.appendUnlocked(type, payload, flags, recovered ? { records: [], recovered: true } : refresh);
|
|
401
|
+
compact = this.needsCompaction();
|
|
402
|
+
return { retry: false, result: appended };
|
|
403
|
+
});
|
|
404
|
+
if (result.retry) {
|
|
405
|
+
await this.recoverNewestGenerationOutsideLockAsync();
|
|
406
|
+
recovered = true;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (compact)
|
|
410
|
+
this.compact();
|
|
411
|
+
return result.result;
|
|
412
|
+
}
|
|
343
413
|
}
|
|
344
414
|
/** Refresh only the authenticated unseen WAL suffix when possible. */
|
|
345
415
|
refresh() {
|
|
346
|
-
|
|
416
|
+
let recovered = false;
|
|
417
|
+
while (true) {
|
|
418
|
+
const refreshed = withScopeLock(this.stateDir, () => this.refreshFromDisk());
|
|
419
|
+
if (refreshed)
|
|
420
|
+
return recovered ? { records: [], recovered: true } : refreshed;
|
|
421
|
+
this.recoverNewestGenerationOutsideLock();
|
|
422
|
+
recovered = true;
|
|
423
|
+
}
|
|
347
424
|
}
|
|
348
425
|
/** Async counterpart for callback-originated projections. */
|
|
349
426
|
async refreshAsync() {
|
|
350
|
-
|
|
427
|
+
let recovered = false;
|
|
428
|
+
while (true) {
|
|
429
|
+
const refreshed = await withScopeLockAsync(this.stateDir, () => this.refreshFromDisk());
|
|
430
|
+
if (refreshed)
|
|
431
|
+
return recovered ? { records: [], recovered: true } : refreshed;
|
|
432
|
+
await this.recoverNewestGenerationOutsideLockAsync();
|
|
433
|
+
recovered = true;
|
|
434
|
+
}
|
|
351
435
|
}
|
|
352
|
-
appendUnlocked(type, payload, flags) {
|
|
353
|
-
const refresh = this.refreshFromDisk();
|
|
436
|
+
appendUnlocked(type, payload, flags, refresh) {
|
|
354
437
|
const record = {
|
|
355
438
|
sequence: this.lastRecordSequence + 1,
|
|
356
439
|
type,
|
|
@@ -395,7 +478,8 @@ export class StateStore {
|
|
|
395
478
|
}
|
|
396
479
|
replaceStateAttempt(nextState) {
|
|
397
480
|
const plan = withScopeLock(this.stateDir, () => {
|
|
398
|
-
this.refreshFromDisk()
|
|
481
|
+
if (!this.refreshFromDisk())
|
|
482
|
+
return undefined;
|
|
399
483
|
const previousGeneration = this.generation;
|
|
400
484
|
const previousWalHasRecords = this.walBytes > WAL_HEADER_BYTES;
|
|
401
485
|
const first = {
|
|
@@ -421,6 +505,10 @@ export class StateStore {
|
|
|
421
505
|
retainedGeneration,
|
|
422
506
|
};
|
|
423
507
|
});
|
|
508
|
+
if (!plan) {
|
|
509
|
+
this.recoverNewestGenerationOutsideLock();
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
424
512
|
const first = stageGeneration(this.stateDir, this.scopeDigest, plan.first);
|
|
425
513
|
let second;
|
|
426
514
|
try {
|
|
@@ -434,7 +522,8 @@ export class StateStore {
|
|
|
434
522
|
}
|
|
435
523
|
try {
|
|
436
524
|
return withScopeLock(this.stateDir, () => {
|
|
437
|
-
this.refreshFromDisk()
|
|
525
|
+
if (!this.refreshFromDisk())
|
|
526
|
+
return false;
|
|
438
527
|
if (this.generation !== plan.sourceGeneration ||
|
|
439
528
|
this.lastRecordSequence !== plan.sourceSequence ||
|
|
440
529
|
this.walBytes !== plan.sourceWalBytes)
|
|
@@ -462,9 +551,14 @@ export class StateStore {
|
|
|
462
551
|
// keep this claim and retry from the refreshed source instead.
|
|
463
552
|
while (true) {
|
|
464
553
|
const plan = withScopeLock(this.stateDir, () => {
|
|
465
|
-
this.refreshFromDisk()
|
|
554
|
+
if (!this.refreshFromDisk())
|
|
555
|
+
return undefined;
|
|
466
556
|
return this.compactionPlan(this.state, this.generation, this.lastRecordSequence, this.walBytes);
|
|
467
557
|
});
|
|
558
|
+
if (!plan) {
|
|
559
|
+
this.recoverNewestGenerationOutsideLock();
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
468
562
|
if (this.publishCompactionPlan(plan))
|
|
469
563
|
return;
|
|
470
564
|
}
|
|
@@ -473,7 +567,7 @@ export class StateStore {
|
|
|
473
567
|
refreshFromDisk() {
|
|
474
568
|
// The current pointer is deliberately not consulted: ordinary append does
|
|
475
569
|
// not rewrite it. A newer snapshot generation is the publication signal.
|
|
476
|
-
if (highestSnapshotGenerationOnDisk(this.stateDir) === this.
|
|
570
|
+
if (highestSnapshotGenerationOnDisk(this.stateDir) === this.observedHighestSnapshotGeneration) {
|
|
477
571
|
try {
|
|
478
572
|
const tail = readWalTail(walPath(this.stateDir, this.generation), this.scopeDigest, this.generation, this.walBytes, this.lastRecordSequence, this.state, this.options.reduce);
|
|
479
573
|
this.state = tail.state;
|
|
@@ -491,11 +585,19 @@ export class StateStore {
|
|
|
491
585
|
// stale-pointer and corrupt-newest-generation selection semantics.
|
|
492
586
|
}
|
|
493
587
|
}
|
|
494
|
-
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
recoverNewestGenerationOutsideLock() {
|
|
591
|
+
const recovered = readNewestGenerationOptimistically(this.stateDir, this.scopeDigest, this.options.reduce);
|
|
592
|
+
if (!recovered)
|
|
593
|
+
throw new StateStoreCorruptionError("no valid generation remains while appending");
|
|
594
|
+
this.installGeneration(recovered);
|
|
595
|
+
}
|
|
596
|
+
async recoverNewestGenerationOutsideLockAsync() {
|
|
597
|
+
const recovered = await readNewestGenerationOptimisticallyAsync(this.stateDir, this.scopeDigest, this.options.reduce);
|
|
495
598
|
if (!recovered)
|
|
496
599
|
throw new StateStoreCorruptionError("no valid generation remains while appending");
|
|
497
600
|
this.installGeneration(recovered);
|
|
498
|
-
return { records: [], recovered: true };
|
|
499
601
|
}
|
|
500
602
|
needsCompaction() {
|
|
501
603
|
return this.recordCount >= this.options.maxRecordsBeforeCompaction ||
|
|
@@ -522,7 +624,8 @@ export class StateStore {
|
|
|
522
624
|
try {
|
|
523
625
|
afterGenerationStageHookForTest?.();
|
|
524
626
|
return withScopeLock(this.stateDir, () => {
|
|
525
|
-
this.refreshFromDisk()
|
|
627
|
+
if (!this.refreshFromDisk())
|
|
628
|
+
return false;
|
|
526
629
|
if (this.generation !== plan.sourceGeneration ||
|
|
527
630
|
this.lastRecordSequence !== plan.sourceSequence ||
|
|
528
631
|
this.walBytes !== plan.sourceWalBytes)
|
|
@@ -545,9 +648,10 @@ export class StateStore {
|
|
|
545
648
|
this.recordCount = generation.recordCount;
|
|
546
649
|
this.walBytes = generation.walBytes;
|
|
547
650
|
this.maxRecoveryReadBytes = generation.maxReadBytes;
|
|
651
|
+
this.observedHighestSnapshotGeneration = generation.observedHighestSnapshotGeneration ?? generation.generation;
|
|
548
652
|
}
|
|
549
653
|
}
|
|
550
|
-
function readNewestGeneration(dir, scopeDigest, reduce, onProgress) {
|
|
654
|
+
function readNewestGeneration(dir, scopeDigest, reduce, onProgress, repairTornTail = true) {
|
|
551
655
|
const names = fs.readdirSync(dir);
|
|
552
656
|
const generations = names
|
|
553
657
|
.map((name) => /^snapshot-(\d+)\.bin$/.exec(name)?.[1])
|
|
@@ -556,9 +660,13 @@ function readNewestGeneration(dir, scopeDigest, reduce, onProgress) {
|
|
|
556
660
|
.filter(Number.isSafeInteger)
|
|
557
661
|
.sort((a, b) => b - a);
|
|
558
662
|
const errors = [];
|
|
663
|
+
let sawMissingGeneration = false;
|
|
559
664
|
for (const generation of generations) {
|
|
560
665
|
try {
|
|
561
|
-
return
|
|
666
|
+
return {
|
|
667
|
+
...readGeneration(dir, scopeDigest, generation, reduce, onProgress, repairTornTail),
|
|
668
|
+
observedHighestSnapshotGeneration: generations[0],
|
|
669
|
+
};
|
|
562
670
|
}
|
|
563
671
|
catch (error) {
|
|
564
672
|
if (error instanceof StateStoreReducerError) {
|
|
@@ -566,22 +674,27 @@ function readNewestGeneration(dir, scopeDigest, reduce, onProgress) {
|
|
|
566
674
|
}
|
|
567
675
|
if (!(error instanceof StateStoreCorruptionError))
|
|
568
676
|
throw error;
|
|
677
|
+
if (error instanceof StateStoreGenerationMissingError)
|
|
678
|
+
sawMissingGeneration = true;
|
|
569
679
|
errors.push(error);
|
|
570
680
|
}
|
|
571
681
|
}
|
|
572
682
|
if (generations.length > 0) {
|
|
573
|
-
|
|
683
|
+
const ErrorType = sawMissingGeneration
|
|
684
|
+
? StateStoreGenerationMissingError
|
|
685
|
+
: StateStoreCorruptionError;
|
|
686
|
+
throw new ErrorType("no valid snapshot/WAL generation remains", {
|
|
574
687
|
cause: errors[0],
|
|
575
688
|
});
|
|
576
689
|
}
|
|
577
690
|
return undefined;
|
|
578
691
|
}
|
|
579
|
-
function readGeneration(dir, scopeDigest, generation, reduce, onProgress) {
|
|
692
|
+
function readGeneration(dir, scopeDigest, generation, reduce, onProgress, repairTornTail = true) {
|
|
580
693
|
const snapshotBytes = generationFileSize(snapshotPath(dir, generation), "snapshot", generation);
|
|
581
694
|
const totalBytes = snapshotBytes + generationFileSize(walPath(dir, generation), "WAL", generation);
|
|
582
695
|
const snapshot = readSnapshot(snapshotPath(dir, generation), scopeDigest, generation);
|
|
583
696
|
onProgress?.({ bytesRead: snapshotBytes, totalBytes });
|
|
584
|
-
const parsed = readWal(walPath(dir, generation), scopeDigest, generation, snapshot.payload.lastRecordSequence, snapshot.payload.state, reduce, snapshotBytes, totalBytes, onProgress);
|
|
697
|
+
const parsed = readWal(walPath(dir, generation), scopeDigest, generation, snapshot.payload.lastRecordSequence, snapshot.payload.state, reduce, snapshotBytes, totalBytes, onProgress, repairTornTail);
|
|
585
698
|
return {
|
|
586
699
|
generation,
|
|
587
700
|
state: parsed.state,
|
|
@@ -589,8 +702,141 @@ function readGeneration(dir, scopeDigest, generation, reduce, onProgress) {
|
|
|
589
702
|
recordCount: parsed.recordCount,
|
|
590
703
|
walBytes: parsed.walBytes,
|
|
591
704
|
maxReadBytes: Math.max(snapshot.maxReadBytes, parsed.maxReadBytes),
|
|
705
|
+
observedWalBytes: parsed.observedWalBytes,
|
|
706
|
+
tornWalTailBoundary: parsed.tornWalTailBoundary,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Snapshots are immutable once published. Cold recovery therefore parses the
|
|
711
|
+
* selected snapshot and WAL without append.lock, then validates its generation
|
|
712
|
+
* under the lock. Appends made during the replay are read as one short locked
|
|
713
|
+
* WAL suffix rather than restarting the expensive immutable snapshot read.
|
|
714
|
+
* Rotation or a torn suffix remains lock-protected.
|
|
715
|
+
*/
|
|
716
|
+
function readNewestGenerationOptimistically(dir, scopeDigest, reduce, onProgress) {
|
|
717
|
+
let resamples = 0;
|
|
718
|
+
while (true) {
|
|
719
|
+
const candidate = withScopeLock(dir, () => {
|
|
720
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
721
|
+
return highestCompleteGenerationOnDisk(dir);
|
|
722
|
+
});
|
|
723
|
+
if (candidate === undefined)
|
|
724
|
+
return undefined;
|
|
725
|
+
let recovered;
|
|
726
|
+
try {
|
|
727
|
+
recovered = readNewestGeneration(dir, scopeDigest, reduce, onProgress, false);
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
if (!(error instanceof StateStoreGenerationMissingError))
|
|
731
|
+
throw error;
|
|
732
|
+
const freshCandidate = withScopeLock(dir, () => highestCompleteGenerationOnDisk(dir));
|
|
733
|
+
if (freshCandidate === candidate)
|
|
734
|
+
throw error;
|
|
735
|
+
if (resamples++ >= OPTIMISTIC_RECOVERY_MAX_RESAMPLES) {
|
|
736
|
+
throw new StateStoreLockError("generation changed repeatedly during optimistic recovery", { cause: error });
|
|
737
|
+
}
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (!recovered)
|
|
741
|
+
continue;
|
|
742
|
+
const validated = withScopeLock(dir, () => validateOptimisticRecovery(dir, scopeDigest, reduce, candidate, recovered));
|
|
743
|
+
if (validated)
|
|
744
|
+
return validated;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async function readNewestGenerationOptimisticallyAsync(dir, scopeDigest, reduce, onProgress) {
|
|
748
|
+
let resamples = 0;
|
|
749
|
+
while (true) {
|
|
750
|
+
const candidate = await withScopeLockAsync(dir, () => {
|
|
751
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
752
|
+
return highestCompleteGenerationOnDisk(dir);
|
|
753
|
+
});
|
|
754
|
+
if (candidate === undefined)
|
|
755
|
+
return undefined;
|
|
756
|
+
let recovered;
|
|
757
|
+
try {
|
|
758
|
+
recovered = readNewestGeneration(dir, scopeDigest, reduce, onProgress, false);
|
|
759
|
+
}
|
|
760
|
+
catch (error) {
|
|
761
|
+
if (!(error instanceof StateStoreGenerationMissingError))
|
|
762
|
+
throw error;
|
|
763
|
+
const freshCandidate = await withScopeLockAsync(dir, () => highestCompleteGenerationOnDisk(dir));
|
|
764
|
+
if (freshCandidate === candidate)
|
|
765
|
+
throw error;
|
|
766
|
+
if (resamples++ >= OPTIMISTIC_RECOVERY_MAX_RESAMPLES) {
|
|
767
|
+
throw new StateStoreLockError("generation changed repeatedly during optimistic recovery", { cause: error });
|
|
768
|
+
}
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (!recovered)
|
|
772
|
+
continue;
|
|
773
|
+
const validated = await withScopeLockAsync(dir, () => validateOptimisticRecovery(dir, scopeDigest, reduce, candidate, recovered));
|
|
774
|
+
if (validated)
|
|
775
|
+
return validated;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
function validateOptimisticRecovery(dir, scopeDigest, reduce, candidate, recovered) {
|
|
779
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
780
|
+
if (highestCompleteGenerationOnDisk(dir) !== candidate)
|
|
781
|
+
return undefined;
|
|
782
|
+
const observedWalBytes = recovered.observedWalBytes ?? recovered.walBytes;
|
|
783
|
+
const filePath = walPath(dir, recovered.generation);
|
|
784
|
+
const currentWalBytes = generationFileSize(filePath, "WAL", recovered.generation);
|
|
785
|
+
if (recovered.tornWalTailBoundary !== undefined) {
|
|
786
|
+
if (currentWalBytes !== observedWalBytes)
|
|
787
|
+
return undefined;
|
|
788
|
+
truncateWal(filePath, recovered.tornWalTailBoundary);
|
|
789
|
+
return {
|
|
790
|
+
...recovered,
|
|
791
|
+
observedWalBytes: recovered.tornWalTailBoundary,
|
|
792
|
+
tornWalTailBoundary: undefined,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
if (currentWalBytes < observedWalBytes)
|
|
796
|
+
return undefined;
|
|
797
|
+
if (currentWalBytes === observedWalBytes)
|
|
798
|
+
return recovered;
|
|
799
|
+
const tail = (() => {
|
|
800
|
+
try {
|
|
801
|
+
return readWalTail(filePath, scopeDigest, recovered.generation, recovered.walBytes, recovered.lastRecordSequence, recovered.state, reduce);
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
// A complete but invalid concurrent suffix makes this generation invalid;
|
|
805
|
+
// replay selection below can still choose a retained valid generation.
|
|
806
|
+
if (error instanceof StateStoreCorruptionError)
|
|
807
|
+
return undefined;
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
})();
|
|
811
|
+
if (!tail)
|
|
812
|
+
return undefined;
|
|
813
|
+
return {
|
|
814
|
+
...recovered,
|
|
815
|
+
state: tail.state,
|
|
816
|
+
lastRecordSequence: tail.lastRecordSequence,
|
|
817
|
+
recordCount: recovered.recordCount + tail.recordCount,
|
|
818
|
+
walBytes: tail.walBytes,
|
|
819
|
+
maxReadBytes: Math.max(recovered.maxReadBytes, tail.maxReadBytes),
|
|
820
|
+
observedWalBytes: tail.walBytes,
|
|
592
821
|
};
|
|
593
822
|
}
|
|
823
|
+
function needsMaintenance(dir, newestGeneration, options) {
|
|
824
|
+
let walBytes;
|
|
825
|
+
let snapshotBytes;
|
|
826
|
+
const currentGeneration = currentGenerationOnDisk(dir);
|
|
827
|
+
try {
|
|
828
|
+
walBytes = fs.statSync(walPath(dir, newestGeneration)).size;
|
|
829
|
+
snapshotBytes = fs.statSync(snapshotPath(dir, newestGeneration)).size;
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
// Structural recovery below classifies a missing or incomplete generation;
|
|
833
|
+
// it must not be mistaken for a healthy no-op.
|
|
834
|
+
}
|
|
835
|
+
return walBytes === undefined ||
|
|
836
|
+
snapshotBytes === undefined ||
|
|
837
|
+
retainedGenerationHistoryNeedsRepair(dir, newestGeneration, currentGeneration) ||
|
|
838
|
+
walBytes > options.maxWalBytesBeforeCompaction;
|
|
839
|
+
}
|
|
594
840
|
function writeGeneration(dir, scopeDigest, generation) {
|
|
595
841
|
const snapshot = encodeSnapshot(generation.generation, scopeDigest, {
|
|
596
842
|
lastRecordSequence: generation.lastRecordSequence,
|
|
@@ -733,7 +979,7 @@ function readSnapshot(filePath, scopeDigest, generation) {
|
|
|
733
979
|
}
|
|
734
980
|
catch (error) {
|
|
735
981
|
if (isMissing(error)) {
|
|
736
|
-
throw new
|
|
982
|
+
throw new StateStoreGenerationMissingError(`snapshot generation ${generation} is missing`, { cause: error });
|
|
737
983
|
}
|
|
738
984
|
throw new Error(`state-store: cannot read snapshot generation ${generation}`, { cause: error });
|
|
739
985
|
}
|
|
@@ -756,6 +1002,7 @@ function readSnapshot(filePath, scopeDigest, generation) {
|
|
|
756
1002
|
if (!header.subarray(20, 52).equals(scopeDigest)) {
|
|
757
1003
|
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a scope digest mismatch`);
|
|
758
1004
|
}
|
|
1005
|
+
snapshotReadHookForTest?.();
|
|
759
1006
|
const data = Buffer.allocUnsafe(payloadLength);
|
|
760
1007
|
if (readIntoSync(fd, data, 52) !== data.length) {
|
|
761
1008
|
throw new StateStoreCorruptionError(`snapshot generation ${generation} has a torn payload`);
|
|
@@ -789,20 +1036,21 @@ function readSnapshot(filePath, scopeDigest, generation) {
|
|
|
789
1036
|
fs.closeSync(fd);
|
|
790
1037
|
}
|
|
791
1038
|
}
|
|
792
|
-
function readWal(filePath, scopeDigest, generation, snapshotSequence, initialState, reduce, recoveryBytesBeforeWal = 0, recoveryTotalBytes, onProgress) {
|
|
1039
|
+
function readWal(filePath, scopeDigest, generation, snapshotSequence, initialState, reduce, recoveryBytesBeforeWal = 0, recoveryTotalBytes, onProgress, repairTornTail = true) {
|
|
793
1040
|
let fd;
|
|
794
1041
|
try {
|
|
795
1042
|
fd = fs.openSync(filePath, "r");
|
|
796
1043
|
}
|
|
797
1044
|
catch (error) {
|
|
798
1045
|
if (isMissing(error)) {
|
|
799
|
-
throw new
|
|
1046
|
+
throw new StateStoreGenerationMissingError(`WAL generation ${generation} is missing`, { cause: error });
|
|
800
1047
|
}
|
|
801
1048
|
throw new Error(`state-store: cannot read WAL generation ${generation}`, { cause: error });
|
|
802
1049
|
}
|
|
803
1050
|
let truncateBoundary;
|
|
804
1051
|
try {
|
|
805
1052
|
const walBytes = fs.fstatSync(fd).size;
|
|
1053
|
+
walReadHookForTest?.();
|
|
806
1054
|
const header = Buffer.alloc(WAL_HEADER_BYTES);
|
|
807
1055
|
const headerBytes = readIntoSync(fd, header, 0);
|
|
808
1056
|
if (headerBytes < WAL_HEADER_BYTES) {
|
|
@@ -879,6 +1127,8 @@ function readWal(filePath, scopeDigest, generation, snapshotSequence, initialSta
|
|
|
879
1127
|
lastRecordSequence: sequence,
|
|
880
1128
|
recordCount,
|
|
881
1129
|
walBytes: truncateBoundary ?? walBytes,
|
|
1130
|
+
observedWalBytes: walBytes,
|
|
1131
|
+
tornWalTailBoundary: truncateBoundary,
|
|
882
1132
|
maxReadBytes,
|
|
883
1133
|
};
|
|
884
1134
|
}
|
|
@@ -890,7 +1140,7 @@ function readWal(filePath, scopeDigest, generation, snapshotSequence, initialSta
|
|
|
890
1140
|
}
|
|
891
1141
|
finally {
|
|
892
1142
|
fs.closeSync(fd);
|
|
893
|
-
if (truncateBoundary !== undefined)
|
|
1143
|
+
if (repairTornTail && truncateBoundary !== undefined)
|
|
894
1144
|
truncateWal(filePath, truncateBoundary);
|
|
895
1145
|
}
|
|
896
1146
|
}
|
|
@@ -1505,7 +1755,7 @@ function generationFileSize(filePath, kind, generation) {
|
|
|
1505
1755
|
}
|
|
1506
1756
|
catch (error) {
|
|
1507
1757
|
if (isMissing(error)) {
|
|
1508
|
-
throw new
|
|
1758
|
+
throw new StateStoreGenerationMissingError(`${kind} generation ${generation} is missing`, {
|
|
1509
1759
|
cause: error,
|
|
1510
1760
|
});
|
|
1511
1761
|
}
|
|
@@ -1524,6 +1774,9 @@ function hasSnapshotGeneration(dir) {
|
|
|
1524
1774
|
throw new Error(`state-store: cannot inspect ${dir}`, { cause: error });
|
|
1525
1775
|
}
|
|
1526
1776
|
}
|
|
1777
|
+
function hasStateStoreArtifacts(dir) {
|
|
1778
|
+
return fs.readdirSync(dir).some((name) => name === "current" || /^(?:snapshot|wal)-\d+\.bin$/.test(name));
|
|
1779
|
+
}
|
|
1527
1780
|
function snapshotPath(dir, generation) {
|
|
1528
1781
|
return path.join(dir, `snapshot-${generation}.bin`);
|
|
1529
1782
|
}
|
|
@@ -1546,6 +1799,21 @@ function highestSnapshotGenerationOnDisk(dir) {
|
|
|
1546
1799
|
.filter(Number.isSafeInteger);
|
|
1547
1800
|
return generations.length === 0 ? undefined : Math.max(...generations);
|
|
1548
1801
|
}
|
|
1802
|
+
/** The highest published generation: both immutable snapshot and WAL exist. */
|
|
1803
|
+
function highestCompleteGenerationOnDisk(dir) {
|
|
1804
|
+
const snapshots = new Set();
|
|
1805
|
+
const wals = new Set();
|
|
1806
|
+
for (const name of fs.readdirSync(dir)) {
|
|
1807
|
+
const snapshot = /^snapshot-(\d+)\.bin$/.exec(name);
|
|
1808
|
+
if (snapshot)
|
|
1809
|
+
snapshots.add(Number(snapshot[1]));
|
|
1810
|
+
const wal = /^wal-(\d+)\.bin$/.exec(name);
|
|
1811
|
+
if (wal)
|
|
1812
|
+
wals.add(Number(wal[1]));
|
|
1813
|
+
}
|
|
1814
|
+
const generations = [...snapshots].filter((generation) => wals.has(generation));
|
|
1815
|
+
return generations.length === 0 ? undefined : Math.max(...generations);
|
|
1816
|
+
}
|
|
1549
1817
|
function currentGenerationOnDisk(dir) {
|
|
1550
1818
|
try {
|
|
1551
1819
|
const generation = Number(fs.readFileSync(path.join(dir, "current"), "ascii").trim());
|