@indigoai-us/hq-cloud 6.15.73 → 6.15.74
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/sync/state-store.js
CHANGED
|
@@ -38,15 +38,55 @@ const MAX_FRAME_BYTES = 256 * 1024 * 1024;
|
|
|
38
38
|
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
|
+
const REPLACE_STATE_MAX_ATTEMPTS = 3;
|
|
41
42
|
// Test instrumentation for materialisation regressions. Kept at the storage
|
|
42
43
|
// seam so a fixture counts actual snapshot JSON decodes, not API invocations.
|
|
43
44
|
let snapshotRecoveryCount = 0;
|
|
45
|
+
let snapshotMaterializationCount = 0;
|
|
46
|
+
let snapshotEncodeCount = 0;
|
|
47
|
+
let snapshotWriteHookForTest;
|
|
48
|
+
let afterGenerationStageHookForTest;
|
|
49
|
+
let reflinkCopyHookForTest;
|
|
50
|
+
let positionalWriteForTest;
|
|
51
|
+
let rotationClaimWriteHookForTest;
|
|
44
52
|
export function resetStateStoreSnapshotRecoveryCountForTest() {
|
|
45
53
|
snapshotRecoveryCount = 0;
|
|
46
54
|
}
|
|
47
55
|
export function getStateStoreSnapshotRecoveryCountForTest() {
|
|
48
56
|
return snapshotRecoveryCount;
|
|
49
57
|
}
|
|
58
|
+
export function resetStateStoreSnapshotMaterializationCountForTest() {
|
|
59
|
+
snapshotMaterializationCount = 0;
|
|
60
|
+
}
|
|
61
|
+
export function getStateStoreSnapshotMaterializationCountForTest() {
|
|
62
|
+
return snapshotMaterializationCount;
|
|
63
|
+
}
|
|
64
|
+
export function resetStateStoreSnapshotEncodeCountForTest() {
|
|
65
|
+
snapshotEncodeCount = 0;
|
|
66
|
+
}
|
|
67
|
+
export function getStateStoreSnapshotEncodeCountForTest() {
|
|
68
|
+
return snapshotEncodeCount;
|
|
69
|
+
}
|
|
70
|
+
/** Test-only seam placed immediately before the expensive snapshot write. */
|
|
71
|
+
export function setStateStoreSnapshotWriteHookForTest(hook) {
|
|
72
|
+
snapshotWriteHookForTest = hook;
|
|
73
|
+
}
|
|
74
|
+
/** Test-only crash seam after durable staging and before lock-protected publish. */
|
|
75
|
+
export function setStateStoreAfterGenerationStageHookForTest(hook) {
|
|
76
|
+
afterGenerationStageHookForTest = hook;
|
|
77
|
+
}
|
|
78
|
+
/** Test-only seam for filesystems that do not support forced reflinks. */
|
|
79
|
+
export function setStateStoreReflinkCopyHookForTest(hook) {
|
|
80
|
+
reflinkCopyHookForTest = hook;
|
|
81
|
+
}
|
|
82
|
+
/** Test-only seam for short positional snapshot writes. */
|
|
83
|
+
export function setStateStorePositionalWriteForTest(write) {
|
|
84
|
+
positionalWriteForTest = write;
|
|
85
|
+
}
|
|
86
|
+
/** Test-only seam for rotation-claim PID write failures. */
|
|
87
|
+
export function setStateStoreRotationClaimWriteHookForTest(hook) {
|
|
88
|
+
rotationClaimWriteHookForTest = hook;
|
|
89
|
+
}
|
|
50
90
|
export class StateStoreCorruptionError extends Error {
|
|
51
91
|
constructor(message, options) {
|
|
52
92
|
super(`state-store corruption: ${message}`, options);
|
|
@@ -149,9 +189,10 @@ export class StateStore {
|
|
|
149
189
|
});
|
|
150
190
|
}
|
|
151
191
|
/**
|
|
152
|
-
* Repair an over-budget generation without
|
|
153
|
-
*
|
|
154
|
-
* the
|
|
192
|
+
* Repair an over-budget generation without making appenders wait for snapshot
|
|
193
|
+
* I/O. Recovery captures a stable generation while holding the scope lock,
|
|
194
|
+
* then materialises the replacement off-lock; publication is conditional on
|
|
195
|
+
* that source generation still being current.
|
|
155
196
|
*/
|
|
156
197
|
static repairIfNeeded(options, maintenance = {}) {
|
|
157
198
|
const normalized = normalizeOptions(options);
|
|
@@ -159,11 +200,11 @@ export class StateStore {
|
|
|
159
200
|
const dir = stateDirectory(normalized.rootDir, scopeDigest);
|
|
160
201
|
if (!hasSnapshotGeneration(dir))
|
|
161
202
|
return { status: "missing" };
|
|
162
|
-
|
|
203
|
+
const plan = withScopeLock(dir, () => {
|
|
163
204
|
cleanupUnpublishedInitialGeneration(dir);
|
|
164
205
|
const newestGeneration = highestSnapshotGenerationOnDisk(dir);
|
|
165
206
|
if (newestGeneration === undefined)
|
|
166
|
-
return
|
|
207
|
+
return undefined;
|
|
167
208
|
if (!maintenance.force) {
|
|
168
209
|
let walBytes;
|
|
169
210
|
let snapshotBytes;
|
|
@@ -181,28 +222,65 @@ export class StateStore {
|
|
|
181
222
|
retainedGenerationHistoryNeedsRepair(dir, newestGeneration, currentGeneration) ||
|
|
182
223
|
walBytes > normalized.maxWalBytesBeforeCompaction;
|
|
183
224
|
if (!overBudget)
|
|
184
|
-
return
|
|
225
|
+
return undefined;
|
|
185
226
|
}
|
|
186
227
|
const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce, maintenance.onProgress);
|
|
187
228
|
if (!recovered)
|
|
188
|
-
return
|
|
189
|
-
const store = new StateStore(normalized, recovered, false);
|
|
190
|
-
const recoveredGeneration = recovered.generation;
|
|
191
|
-
const recoveredWalBytes = recovered.walBytes;
|
|
192
|
-
const firstCompactGeneration = highestGenerationOnDisk(dir) + 1;
|
|
193
|
-
const first = store.compactGeneration(firstCompactGeneration);
|
|
194
|
-
const second = store.compactGeneration(firstCompactGeneration + 1);
|
|
195
|
-
writeCurrentPointer(dir, second.generation);
|
|
196
|
-
pruneGenerations(dir, [first.generation, second.generation]);
|
|
197
|
-
store.installGeneration(second);
|
|
229
|
+
return undefined;
|
|
198
230
|
return {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
recoveredWalBytes,
|
|
202
|
-
firstCompactGeneration: first.generation,
|
|
203
|
-
currentGeneration: second.generation,
|
|
231
|
+
recovered,
|
|
232
|
+
highestGeneration: highestGenerationOnDisk(dir),
|
|
204
233
|
};
|
|
205
234
|
});
|
|
235
|
+
if (!plan)
|
|
236
|
+
return hasSnapshotGeneration(dir) ? { status: "not-needed" } : { status: "missing" };
|
|
237
|
+
const first = {
|
|
238
|
+
generation: plan.highestGeneration + 1,
|
|
239
|
+
state: plan.recovered.state,
|
|
240
|
+
lastRecordSequence: plan.recovered.lastRecordSequence,
|
|
241
|
+
recordCount: 0,
|
|
242
|
+
walBytes: WAL_HEADER_BYTES,
|
|
243
|
+
maxReadBytes: 0,
|
|
244
|
+
};
|
|
245
|
+
// Keep two rollback-readable generations, but derive the second snapshot
|
|
246
|
+
// from a copy-on-write clone of the first so one logical repair has one
|
|
247
|
+
// full snapshot materialisation on filesystems that support reflinks.
|
|
248
|
+
const next = { ...first, generation: first.generation + 1 };
|
|
249
|
+
const stagedFirst = stageGeneration(dir, scopeDigest, first);
|
|
250
|
+
let staged;
|
|
251
|
+
try {
|
|
252
|
+
staged = stageGenerationFromSnapshot(dir, scopeDigest, stagedFirst.snapshotTemporary, next);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
discardStagedGeneration(stagedFirst);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
afterGenerationStageHookForTest?.();
|
|
260
|
+
return withScopeLock(dir, () => {
|
|
261
|
+
// A writer won while the snapshot was being built. It has kept the old
|
|
262
|
+
// WAL authoritative, so leave it alone and let a later maintenance pass
|
|
263
|
+
// compact a fresh view rather than publishing a stale snapshot.
|
|
264
|
+
if (highestGenerationOnDisk(dir) !== plan.highestGeneration ||
|
|
265
|
+
generationFileSize(walPath(dir, plan.recovered.generation), "WAL", plan.recovered.generation) !== plan.recovered.walBytes)
|
|
266
|
+
return { status: "not-needed" };
|
|
267
|
+
publishStagedGeneration(dir, stagedFirst);
|
|
268
|
+
publishStagedGeneration(dir, staged);
|
|
269
|
+
writeCurrentPointer(dir, next.generation);
|
|
270
|
+
pruneGenerations(dir, [first.generation, next.generation]);
|
|
271
|
+
return {
|
|
272
|
+
status: "repaired",
|
|
273
|
+
recoveredGeneration: plan.recovered.generation,
|
|
274
|
+
recoveredWalBytes: plan.recovered.walBytes,
|
|
275
|
+
firstCompactGeneration: first.generation,
|
|
276
|
+
currentGeneration: next.generation,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
discardStagedGeneration(stagedFirst);
|
|
282
|
+
discardStagedGeneration(staged);
|
|
283
|
+
}
|
|
206
284
|
}
|
|
207
285
|
getState() {
|
|
208
286
|
return this.state;
|
|
@@ -219,7 +297,15 @@ export class StateStore {
|
|
|
219
297
|
append(type, payload, flags = 0) {
|
|
220
298
|
assertUint16(type, "record type");
|
|
221
299
|
assertUint16(flags, "record flags");
|
|
222
|
-
|
|
300
|
+
let compact = false;
|
|
301
|
+
const result = withScopeLock(this.stateDir, () => {
|
|
302
|
+
const appended = this.appendUnlocked(type, payload, flags);
|
|
303
|
+
compact = this.needsCompaction();
|
|
304
|
+
return appended;
|
|
305
|
+
});
|
|
306
|
+
if (compact)
|
|
307
|
+
this.compact();
|
|
308
|
+
return result;
|
|
223
309
|
}
|
|
224
310
|
/**
|
|
225
311
|
* Async lock acquisition for callback-originated work. The write itself
|
|
@@ -229,13 +315,31 @@ export class StateStore {
|
|
|
229
315
|
async appendAsync(type, payload, flags = 0) {
|
|
230
316
|
assertUint16(type, "record type");
|
|
231
317
|
assertUint16(flags, "record flags");
|
|
232
|
-
|
|
318
|
+
let compact = false;
|
|
319
|
+
const result = await withScopeLockAsync(this.stateDir, () => {
|
|
320
|
+
const appended = this.appendUnlocked(type, payload, flags);
|
|
321
|
+
compact = this.needsCompaction();
|
|
322
|
+
return appended;
|
|
323
|
+
});
|
|
324
|
+
if (compact)
|
|
325
|
+
this.compact();
|
|
326
|
+
return result;
|
|
233
327
|
}
|
|
234
328
|
/** Conditionally append after obtaining the async scope lock. */
|
|
235
329
|
async appendIfAsync(type, payload, shouldAppend, flags = 0) {
|
|
236
330
|
assertUint16(type, "record type");
|
|
237
331
|
assertUint16(flags, "record flags");
|
|
238
|
-
|
|
332
|
+
let compact = false;
|
|
333
|
+
const result = await withScopeLockAsync(this.stateDir, () => {
|
|
334
|
+
if (!shouldAppend())
|
|
335
|
+
return undefined;
|
|
336
|
+
const appended = this.appendUnlocked(type, payload, flags);
|
|
337
|
+
compact = this.needsCompaction();
|
|
338
|
+
return appended;
|
|
339
|
+
});
|
|
340
|
+
if (compact)
|
|
341
|
+
this.compact();
|
|
342
|
+
return result;
|
|
239
343
|
}
|
|
240
344
|
/** Refresh only the authenticated unseen WAL suffix when possible. */
|
|
241
345
|
refresh() {
|
|
@@ -271,9 +375,6 @@ export class StateStore {
|
|
|
271
375
|
this.lastRecordSequence = record.sequence;
|
|
272
376
|
this.recordCount++;
|
|
273
377
|
this.walBytes += frame.length;
|
|
274
|
-
if (this.recordCount >= this.options.maxRecordsBeforeCompaction ||
|
|
275
|
-
this.walBytes >= this.options.maxWalBytesBeforeCompaction)
|
|
276
|
-
this.compactUnlocked();
|
|
277
378
|
return { ...record, refresh };
|
|
278
379
|
}
|
|
279
380
|
/**
|
|
@@ -286,27 +387,87 @@ export class StateStore {
|
|
|
286
387
|
* and retain the prior compact copy.
|
|
287
388
|
*/
|
|
288
389
|
replaceState(nextState) {
|
|
289
|
-
|
|
390
|
+
for (let attempt = 0; attempt < REPLACE_STATE_MAX_ATTEMPTS; attempt++) {
|
|
391
|
+
if (this.replaceStateAttempt(nextState))
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
throw new StateStoreLockError(`cannot replace state after ${REPLACE_STATE_MAX_ATTEMPTS} concurrent writes`);
|
|
395
|
+
}
|
|
396
|
+
replaceStateAttempt(nextState) {
|
|
397
|
+
const plan = withScopeLock(this.stateDir, () => {
|
|
290
398
|
this.refreshFromDisk();
|
|
291
399
|
const previousGeneration = this.generation;
|
|
292
400
|
const previousWalHasRecords = this.walBytes > WAL_HEADER_BYTES;
|
|
293
|
-
const first =
|
|
401
|
+
const first = {
|
|
402
|
+
generation: highestGenerationOnDisk(this.stateDir) + 1,
|
|
403
|
+
state: nextState,
|
|
404
|
+
lastRecordSequence: this.lastRecordSequence,
|
|
405
|
+
recordCount: 0,
|
|
406
|
+
walBytes: WAL_HEADER_BYTES,
|
|
407
|
+
maxReadBytes: 0,
|
|
408
|
+
};
|
|
294
409
|
let retainedGeneration = previousGeneration;
|
|
295
410
|
let current = first;
|
|
296
411
|
if (previousWalHasRecords) {
|
|
297
|
-
current =
|
|
412
|
+
current = { ...first, generation: first.generation + 1 };
|
|
298
413
|
retainedGeneration = first.generation;
|
|
299
414
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
415
|
+
return {
|
|
416
|
+
sourceGeneration: previousGeneration,
|
|
417
|
+
sourceSequence: this.lastRecordSequence,
|
|
418
|
+
sourceWalBytes: this.walBytes,
|
|
419
|
+
first,
|
|
420
|
+
current,
|
|
421
|
+
retainedGeneration,
|
|
422
|
+
};
|
|
303
423
|
});
|
|
424
|
+
const first = stageGeneration(this.stateDir, this.scopeDigest, plan.first);
|
|
425
|
+
let second;
|
|
426
|
+
try {
|
|
427
|
+
second = plan.current.generation === plan.first.generation
|
|
428
|
+
? undefined
|
|
429
|
+
: stageGenerationFromSnapshot(this.stateDir, this.scopeDigest, first.snapshotTemporary, plan.current);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
discardStagedGeneration(first);
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
return withScopeLock(this.stateDir, () => {
|
|
437
|
+
this.refreshFromDisk();
|
|
438
|
+
if (this.generation !== plan.sourceGeneration ||
|
|
439
|
+
this.lastRecordSequence !== plan.sourceSequence ||
|
|
440
|
+
this.walBytes !== plan.sourceWalBytes)
|
|
441
|
+
return false;
|
|
442
|
+
publishStagedGeneration(this.stateDir, first);
|
|
443
|
+
if (second)
|
|
444
|
+
publishStagedGeneration(this.stateDir, second);
|
|
445
|
+
writeCurrentPointer(this.stateDir, plan.current.generation);
|
|
446
|
+
pruneGenerations(this.stateDir, [plan.retainedGeneration, plan.current.generation]);
|
|
447
|
+
this.installGeneration(plan.current);
|
|
448
|
+
return true;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
discardStagedGeneration(first);
|
|
453
|
+
if (second)
|
|
454
|
+
discardStagedGeneration(second);
|
|
455
|
+
}
|
|
304
456
|
}
|
|
305
457
|
/** Create G+1 without ever merging two generations during recovery. */
|
|
306
458
|
compact() {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
this
|
|
459
|
+
withRotationClaim(this.stateDir, () => {
|
|
460
|
+
// Appenders do not wait for rotation.lock. If one advances the source
|
|
461
|
+
// while this claim owns off-lock staging, it will skip its own compact;
|
|
462
|
+
// keep this claim and retry from the refreshed source instead.
|
|
463
|
+
while (true) {
|
|
464
|
+
const plan = withScopeLock(this.stateDir, () => {
|
|
465
|
+
this.refreshFromDisk();
|
|
466
|
+
return this.compactionPlan(this.state, this.generation, this.lastRecordSequence, this.walBytes);
|
|
467
|
+
});
|
|
468
|
+
if (this.publishCompactionPlan(plan))
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
310
471
|
});
|
|
311
472
|
}
|
|
312
473
|
refreshFromDisk() {
|
|
@@ -336,24 +497,46 @@ export class StateStore {
|
|
|
336
497
|
this.installGeneration(recovered);
|
|
337
498
|
return { records: [], recovered: true };
|
|
338
499
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
writeCurrentPointer(this.stateDir, next.generation);
|
|
343
|
-
pruneGenerations(this.stateDir, [previousGeneration, next.generation]);
|
|
344
|
-
this.installGeneration(next);
|
|
500
|
+
needsCompaction() {
|
|
501
|
+
return this.recordCount >= this.options.maxRecordsBeforeCompaction ||
|
|
502
|
+
this.walBytes >= this.options.maxWalBytesBeforeCompaction;
|
|
345
503
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
504
|
+
compactionPlan(state, sourceGeneration, sourceSequence, sourceWalBytes) {
|
|
505
|
+
return {
|
|
506
|
+
sourceGeneration,
|
|
507
|
+
sourceSequence,
|
|
508
|
+
sourceWalBytes,
|
|
509
|
+
next: {
|
|
510
|
+
generation: highestGenerationOnDisk(this.stateDir) + 1,
|
|
511
|
+
state,
|
|
512
|
+
lastRecordSequence: sourceSequence,
|
|
513
|
+
recordCount: 0,
|
|
514
|
+
walBytes: WAL_HEADER_BYTES,
|
|
515
|
+
maxReadBytes: 0,
|
|
516
|
+
},
|
|
354
517
|
};
|
|
355
|
-
|
|
356
|
-
|
|
518
|
+
}
|
|
519
|
+
/** Materialise off-lock, then publish only if no writer advanced the source. */
|
|
520
|
+
publishCompactionPlan(plan) {
|
|
521
|
+
const staged = stageGeneration(this.stateDir, this.scopeDigest, plan.next);
|
|
522
|
+
try {
|
|
523
|
+
afterGenerationStageHookForTest?.();
|
|
524
|
+
return withScopeLock(this.stateDir, () => {
|
|
525
|
+
this.refreshFromDisk();
|
|
526
|
+
if (this.generation !== plan.sourceGeneration ||
|
|
527
|
+
this.lastRecordSequence !== plan.sourceSequence ||
|
|
528
|
+
this.walBytes !== plan.sourceWalBytes)
|
|
529
|
+
return false;
|
|
530
|
+
publishStagedGeneration(this.stateDir, staged);
|
|
531
|
+
writeCurrentPointer(this.stateDir, plan.next.generation);
|
|
532
|
+
pruneGenerations(this.stateDir, [plan.sourceGeneration, plan.next.generation]);
|
|
533
|
+
this.installGeneration(plan.next);
|
|
534
|
+
return true;
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
discardStagedGeneration(staged);
|
|
539
|
+
}
|
|
357
540
|
}
|
|
358
541
|
installGeneration(generation) {
|
|
359
542
|
this.generation = generation.generation;
|
|
@@ -418,6 +601,130 @@ function writeGeneration(dir, scopeDigest, generation) {
|
|
|
418
601
|
const header = encodeWalHeader(generation.generation, scopeDigest);
|
|
419
602
|
writeNewDurably(walPath(dir, generation.generation), header);
|
|
420
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* Build both files for a generation before taking append.lock for publication.
|
|
606
|
+
* Temporary names never match the recovery generation pattern, so a process
|
|
607
|
+
* crash here leaves the prior complete generation authoritative.
|
|
608
|
+
*/
|
|
609
|
+
function stageGeneration(dir, scopeDigest, generation) {
|
|
610
|
+
snapshotMaterializationCount++;
|
|
611
|
+
const snapshot = encodeSnapshot(generation.generation, scopeDigest, {
|
|
612
|
+
lastRecordSequence: generation.lastRecordSequence,
|
|
613
|
+
state: generation.state,
|
|
614
|
+
});
|
|
615
|
+
let snapshotTemporary;
|
|
616
|
+
let walTemporary;
|
|
617
|
+
try {
|
|
618
|
+
snapshotTemporary = writeDurableTemporary(dir, `snapshot-${generation.generation}`, snapshot);
|
|
619
|
+
walTemporary = writeDurableTemporary(dir, `wal-${generation.generation}`, encodeWalHeader(generation.generation, scopeDigest));
|
|
620
|
+
return { generation, snapshotTemporary, walTemporary };
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
if (snapshotTemporary !== undefined)
|
|
624
|
+
try {
|
|
625
|
+
fs.rmSync(snapshotTemporary, { force: true });
|
|
626
|
+
}
|
|
627
|
+
catch { /* preserve staging error */ }
|
|
628
|
+
if (walTemporary !== undefined)
|
|
629
|
+
try {
|
|
630
|
+
fs.rmSync(walTemporary, { force: true });
|
|
631
|
+
}
|
|
632
|
+
catch { /* preserve staging error */ }
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Make the rollback copy without serialising or writing the snapshot payload a
|
|
638
|
+
* second time when the store directory is on a reflink-capable filesystem.
|
|
639
|
+
*/
|
|
640
|
+
function stageGenerationFromSnapshot(dir, scopeDigest, sourceSnapshot, generation) {
|
|
641
|
+
const snapshotTemporary = temporaryPath(dir, `snapshot-${generation.generation}`);
|
|
642
|
+
let walTemporary;
|
|
643
|
+
try {
|
|
644
|
+
try {
|
|
645
|
+
reflinkCopyHookForTest?.();
|
|
646
|
+
fs.copyFileSync(sourceSnapshot, snapshotTemporary, fs.constants.COPYFILE_FICLONE_FORCE);
|
|
647
|
+
rewriteStagedSnapshotGeneration(snapshotTemporary, generation.generation);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
// Reflinks are an optimisation, not a storage prerequisite. Reuse the
|
|
651
|
+
// bytes already encoded for the first snapshot on filesystems without
|
|
652
|
+
// clone support rather than materialising the state a second time.
|
|
653
|
+
if (fs.existsSync(snapshotTemporary))
|
|
654
|
+
fs.rmSync(snapshotTemporary, { force: true });
|
|
655
|
+
fs.copyFileSync(sourceSnapshot, snapshotTemporary);
|
|
656
|
+
rewriteStagedSnapshotGeneration(snapshotTemporary, generation.generation);
|
|
657
|
+
}
|
|
658
|
+
walTemporary = writeDurableTemporary(dir, `wal-${generation.generation}`, encodeWalHeader(generation.generation, scopeDigest));
|
|
659
|
+
return { generation, snapshotTemporary, walTemporary };
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
try {
|
|
663
|
+
fs.rmSync(snapshotTemporary, { force: true });
|
|
664
|
+
}
|
|
665
|
+
catch { /* preserve staging error */ }
|
|
666
|
+
if (walTemporary !== undefined)
|
|
667
|
+
try {
|
|
668
|
+
fs.rmSync(walTemporary, { force: true });
|
|
669
|
+
}
|
|
670
|
+
catch { /* preserve staging error */ }
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function rewriteStagedSnapshotGeneration(filePath, generation) {
|
|
675
|
+
let fd;
|
|
676
|
+
try {
|
|
677
|
+
fd = fs.openSync(filePath, "r+");
|
|
678
|
+
const header = Buffer.alloc(52);
|
|
679
|
+
if (readIntoSync(fd, header, 0) !== header.length)
|
|
680
|
+
throw new Error("state-store: cloned snapshot header is torn");
|
|
681
|
+
writeU64(header, generation, 8);
|
|
682
|
+
writeAllAtSync(fd, header, 0);
|
|
683
|
+
const payloadLength = header.readUInt32BE(16);
|
|
684
|
+
const hash = crypto.createHash("sha256").update(header);
|
|
685
|
+
const chunk = Buffer.allocUnsafe(Math.min(payloadLength, 1024 * 1024));
|
|
686
|
+
let offset = 0;
|
|
687
|
+
while (offset < payloadLength) {
|
|
688
|
+
const length = Math.min(chunk.length, payloadLength - offset);
|
|
689
|
+
if (readIntoSync(fd, chunk.subarray(0, length), 52 + offset) !== length) {
|
|
690
|
+
throw new Error("state-store: cloned snapshot payload is torn");
|
|
691
|
+
}
|
|
692
|
+
hash.update(chunk.subarray(0, length));
|
|
693
|
+
offset += length;
|
|
694
|
+
}
|
|
695
|
+
const checksum = hash.digest();
|
|
696
|
+
writeAllAtSync(fd, checksum, 52 + payloadLength);
|
|
697
|
+
fs.fdatasyncSync(fd);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
throw new Error(`state-store: failed to prepare cloned snapshot generation ${generation}`, { cause: error });
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
if (fd !== undefined)
|
|
704
|
+
fs.closeSync(fd);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/** Publish already-fsynced files; this is the only snapshot-rotation I/O under the lock. */
|
|
708
|
+
function publishStagedGeneration(dir, staged) {
|
|
709
|
+
try {
|
|
710
|
+
fs.renameSync(staged.snapshotTemporary, snapshotPath(dir, staged.generation.generation));
|
|
711
|
+
fs.renameSync(staged.walTemporary, walPath(dir, staged.generation.generation));
|
|
712
|
+
fsyncDirectory(dir);
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
throw new Error(`state-store: staged generation publication failed for ${staged.generation.generation}`, { cause: error });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function discardStagedGeneration(staged) {
|
|
719
|
+
try {
|
|
720
|
+
fs.rmSync(staged.snapshotTemporary, { force: true });
|
|
721
|
+
}
|
|
722
|
+
catch { /* staged files are best-effort cleanup */ }
|
|
723
|
+
try {
|
|
724
|
+
fs.rmSync(staged.walTemporary, { force: true });
|
|
725
|
+
}
|
|
726
|
+
catch { /* staged files are best-effort cleanup */ }
|
|
727
|
+
}
|
|
421
728
|
function readSnapshot(filePath, scopeDigest, generation) {
|
|
422
729
|
snapshotRecoveryCount++;
|
|
423
730
|
let fd;
|
|
@@ -759,6 +1066,7 @@ function decodeFrame(bytes, offset) {
|
|
|
759
1066
|
};
|
|
760
1067
|
}
|
|
761
1068
|
function encodeSnapshot(generation, scopeDigest, payload) {
|
|
1069
|
+
snapshotEncodeCount++;
|
|
762
1070
|
const data = Buffer.from(canonicalJson(payload), "utf8");
|
|
763
1071
|
const header = Buffer.alloc(52);
|
|
764
1072
|
SNAPSHOT_MAGIC.copy(header, 0);
|
|
@@ -845,6 +1153,35 @@ function writeDurableTempThenRename(dir, destination, bytes) {
|
|
|
845
1153
|
throw new Error(`state-store: snapshot write/rename failed for ${destination}`, { cause: error });
|
|
846
1154
|
}
|
|
847
1155
|
}
|
|
1156
|
+
function writeDurableTemporary(dir, label, bytes) {
|
|
1157
|
+
const temporary = temporaryPath(dir, label);
|
|
1158
|
+
writeDurableTemporaryAt(temporary, bytes, label);
|
|
1159
|
+
return temporary;
|
|
1160
|
+
}
|
|
1161
|
+
function temporaryPath(dir, label) {
|
|
1162
|
+
return path.join(dir, `.${label}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
1163
|
+
}
|
|
1164
|
+
function writeDurableTemporaryAt(temporary, bytes, label) {
|
|
1165
|
+
let fd;
|
|
1166
|
+
try {
|
|
1167
|
+
fd = fs.openSync(temporary, "wx");
|
|
1168
|
+
if (label.startsWith("snapshot-"))
|
|
1169
|
+
snapshotWriteHookForTest?.();
|
|
1170
|
+
writeAllSync(fd, bytes);
|
|
1171
|
+
fs.fdatasyncSync(fd);
|
|
1172
|
+
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
try {
|
|
1175
|
+
fs.rmSync(temporary, { force: true });
|
|
1176
|
+
}
|
|
1177
|
+
catch { /* preserve durable-write error */ }
|
|
1178
|
+
throw new Error(`state-store: staged write failed for ${label}`, { cause: error });
|
|
1179
|
+
}
|
|
1180
|
+
finally {
|
|
1181
|
+
if (fd !== undefined)
|
|
1182
|
+
fs.closeSync(fd);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
848
1185
|
function writeCurrentPointer(dir, generation) {
|
|
849
1186
|
const pointer = path.join(dir, "current");
|
|
850
1187
|
writeDurableTempThenRename(dir, pointer, Buffer.from(`${generation}\n`, "ascii"));
|
|
@@ -858,6 +1195,18 @@ function writeAllSync(fd, bytes) {
|
|
|
858
1195
|
offset += written;
|
|
859
1196
|
}
|
|
860
1197
|
}
|
|
1198
|
+
function writeAllAtSync(fd, bytes, position) {
|
|
1199
|
+
let offset = 0;
|
|
1200
|
+
while (offset < bytes.length) {
|
|
1201
|
+
const length = bytes.length - offset;
|
|
1202
|
+
const written = positionalWriteForTest
|
|
1203
|
+
? positionalWriteForTest(fd, bytes, offset, length, position + offset)
|
|
1204
|
+
: fs.writeSync(fd, bytes, offset, length, position + offset);
|
|
1205
|
+
if (written <= 0)
|
|
1206
|
+
throw new Error("state-store: short positional write made no progress");
|
|
1207
|
+
offset += written;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
861
1210
|
function readIntoSync(fd, target, position) {
|
|
862
1211
|
let offset = 0;
|
|
863
1212
|
while (offset < target.length) {
|
|
@@ -911,6 +1260,73 @@ function withScopeLock(dir, action) {
|
|
|
911
1260
|
throw actionError;
|
|
912
1261
|
return result;
|
|
913
1262
|
}
|
|
1263
|
+
/**
|
|
1264
|
+
* A best-effort, non-blocking claim for off-lock snapshot materialisation.
|
|
1265
|
+
* Appenders deliberately ignore it; it only prevents several stale handles
|
|
1266
|
+
* from concurrently staging the same logical rotation.
|
|
1267
|
+
*/
|
|
1268
|
+
function withRotationClaim(dir, action) {
|
|
1269
|
+
const claimPath = path.join(dir, "rotation.lock");
|
|
1270
|
+
let fd;
|
|
1271
|
+
for (let attempt = 0; attempt < 2 && fd === undefined; attempt++) {
|
|
1272
|
+
try {
|
|
1273
|
+
fd = fs.openSync(claimPath, "wx");
|
|
1274
|
+
rotationClaimWriteHookForTest?.();
|
|
1275
|
+
fs.writeFileSync(fd, String(process.pid));
|
|
1276
|
+
fs.fdatasyncSync(fd);
|
|
1277
|
+
}
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
if (fd !== undefined) {
|
|
1280
|
+
let closeError;
|
|
1281
|
+
try {
|
|
1282
|
+
fs.closeSync(fd);
|
|
1283
|
+
}
|
|
1284
|
+
catch (cleanupError) {
|
|
1285
|
+
closeError = cleanupError;
|
|
1286
|
+
}
|
|
1287
|
+
finally {
|
|
1288
|
+
fd = undefined;
|
|
1289
|
+
}
|
|
1290
|
+
try {
|
|
1291
|
+
fs.unlinkSync(claimPath);
|
|
1292
|
+
}
|
|
1293
|
+
catch (cleanupError) {
|
|
1294
|
+
if (!isMissing(cleanupError)) {
|
|
1295
|
+
throw new StateStoreLockError(`cannot clean up ${claimPath}`, { cause: cleanupError });
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (closeError !== undefined) {
|
|
1299
|
+
throw new StateStoreLockError(`cannot close ${claimPath}`, { cause: closeError });
|
|
1300
|
+
}
|
|
1301
|
+
return undefined;
|
|
1302
|
+
}
|
|
1303
|
+
if (!isAlreadyExists(error) || !removeDeadLock(claimPath))
|
|
1304
|
+
return undefined;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (fd === undefined)
|
|
1308
|
+
return undefined;
|
|
1309
|
+
let result;
|
|
1310
|
+
let actionError;
|
|
1311
|
+
try {
|
|
1312
|
+
result = action();
|
|
1313
|
+
}
|
|
1314
|
+
catch (error) {
|
|
1315
|
+
actionError = error;
|
|
1316
|
+
}
|
|
1317
|
+
finally {
|
|
1318
|
+
fs.closeSync(fd);
|
|
1319
|
+
}
|
|
1320
|
+
try {
|
|
1321
|
+
fs.unlinkSync(claimPath);
|
|
1322
|
+
}
|
|
1323
|
+
catch (error) {
|
|
1324
|
+
throw new Error(`state-store: cannot release ${claimPath}`, { cause: error });
|
|
1325
|
+
}
|
|
1326
|
+
if (actionError !== undefined)
|
|
1327
|
+
throw actionError;
|
|
1328
|
+
return result;
|
|
1329
|
+
}
|
|
914
1330
|
/**
|
|
915
1331
|
* Acquire the same inter-process lock without parking the Node event loop
|
|
916
1332
|
* while another writer owns it. This is deliberately additive: pass-side
|
|
@@ -968,7 +1384,7 @@ function removeDeadLock(lockPath) {
|
|
|
968
1384
|
return isMissing(error);
|
|
969
1385
|
}
|
|
970
1386
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
971
|
-
return
|
|
1387
|
+
return removeMalformedLock(lockPath);
|
|
972
1388
|
try {
|
|
973
1389
|
process.kill(pid, 0);
|
|
974
1390
|
return false;
|
|
@@ -996,7 +1412,7 @@ async function removeDeadLockAsync(lockPath) {
|
|
|
996
1412
|
return false;
|
|
997
1413
|
}
|
|
998
1414
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
999
|
-
return
|
|
1415
|
+
return removeMalformedLockAsync(lockPath);
|
|
1000
1416
|
try {
|
|
1001
1417
|
process.kill(pid, 0);
|
|
1002
1418
|
return false;
|
|
@@ -1015,6 +1431,24 @@ async function removeDeadLockAsync(lockPath) {
|
|
|
1015
1431
|
return false;
|
|
1016
1432
|
}
|
|
1017
1433
|
}
|
|
1434
|
+
function removeMalformedLock(lockPath) {
|
|
1435
|
+
try {
|
|
1436
|
+
fs.unlinkSync(lockPath);
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
catch (error) {
|
|
1440
|
+
return isMissing(error);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
async function removeMalformedLockAsync(lockPath) {
|
|
1444
|
+
try {
|
|
1445
|
+
await fs.promises.unlink(lockPath);
|
|
1446
|
+
return true;
|
|
1447
|
+
}
|
|
1448
|
+
catch (error) {
|
|
1449
|
+
return isMissing(error);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1018
1452
|
function truncateWal(filePath, boundary) {
|
|
1019
1453
|
let fd;
|
|
1020
1454
|
try {
|