@indigoai-us/hq-cloud 6.15.72 → 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/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +29 -6
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +1 -1
- 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 +1 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/journal.d.ts +4 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +28 -4
- package/dist/journal.js.map +1 -1
- package/dist/sync/state-store.d.ts +27 -5
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +517 -53
- package/dist/sync/state-store.js.map +1 -1
- package/dist/sync/state-store.test.js +144 -3
- package/dist/sync/state-store.test.js.map +1 -1
- package/dist/watcher.d.ts +2 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +17 -4
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +25 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
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);
|
|
@@ -124,10 +164,35 @@ export class StateStore {
|
|
|
124
164
|
return new StateStore(normalized, initial, true);
|
|
125
165
|
});
|
|
126
166
|
}
|
|
167
|
+
/** Yielding first-open variant for callback-originated projections. */
|
|
168
|
+
static async openAsync(options) {
|
|
169
|
+
const normalized = normalizeOptions(options);
|
|
170
|
+
const scopeDigest = digestScope(normalized.scopeId);
|
|
171
|
+
const dir = stateDirectory(normalized.rootDir, scopeDigest);
|
|
172
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
173
|
+
return withScopeLockAsync(dir, () => {
|
|
174
|
+
cleanupUnpublishedInitialGeneration(dir);
|
|
175
|
+
const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce);
|
|
176
|
+
if (recovered)
|
|
177
|
+
return new StateStore(normalized, recovered, false);
|
|
178
|
+
const initial = {
|
|
179
|
+
generation: 1,
|
|
180
|
+
state: normalized.initialState,
|
|
181
|
+
lastRecordSequence: 0,
|
|
182
|
+
recordCount: 0,
|
|
183
|
+
walBytes: WAL_HEADER_BYTES,
|
|
184
|
+
maxReadBytes: 0,
|
|
185
|
+
};
|
|
186
|
+
writeGeneration(dir, scopeDigest, initial);
|
|
187
|
+
writeCurrentPointer(dir, initial.generation);
|
|
188
|
+
return new StateStore(normalized, initial, true);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
127
191
|
/**
|
|
128
|
-
* Repair an over-budget generation without
|
|
129
|
-
*
|
|
130
|
-
* 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.
|
|
131
196
|
*/
|
|
132
197
|
static repairIfNeeded(options, maintenance = {}) {
|
|
133
198
|
const normalized = normalizeOptions(options);
|
|
@@ -135,11 +200,11 @@ export class StateStore {
|
|
|
135
200
|
const dir = stateDirectory(normalized.rootDir, scopeDigest);
|
|
136
201
|
if (!hasSnapshotGeneration(dir))
|
|
137
202
|
return { status: "missing" };
|
|
138
|
-
|
|
203
|
+
const plan = withScopeLock(dir, () => {
|
|
139
204
|
cleanupUnpublishedInitialGeneration(dir);
|
|
140
205
|
const newestGeneration = highestSnapshotGenerationOnDisk(dir);
|
|
141
206
|
if (newestGeneration === undefined)
|
|
142
|
-
return
|
|
207
|
+
return undefined;
|
|
143
208
|
if (!maintenance.force) {
|
|
144
209
|
let walBytes;
|
|
145
210
|
let snapshotBytes;
|
|
@@ -157,28 +222,65 @@ export class StateStore {
|
|
|
157
222
|
retainedGenerationHistoryNeedsRepair(dir, newestGeneration, currentGeneration) ||
|
|
158
223
|
walBytes > normalized.maxWalBytesBeforeCompaction;
|
|
159
224
|
if (!overBudget)
|
|
160
|
-
return
|
|
225
|
+
return undefined;
|
|
161
226
|
}
|
|
162
227
|
const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce, maintenance.onProgress);
|
|
163
228
|
if (!recovered)
|
|
164
|
-
return
|
|
165
|
-
const store = new StateStore(normalized, recovered, false);
|
|
166
|
-
const recoveredGeneration = recovered.generation;
|
|
167
|
-
const recoveredWalBytes = recovered.walBytes;
|
|
168
|
-
const firstCompactGeneration = highestGenerationOnDisk(dir) + 1;
|
|
169
|
-
const first = store.compactGeneration(firstCompactGeneration);
|
|
170
|
-
const second = store.compactGeneration(firstCompactGeneration + 1);
|
|
171
|
-
writeCurrentPointer(dir, second.generation);
|
|
172
|
-
pruneGenerations(dir, [first.generation, second.generation]);
|
|
173
|
-
store.installGeneration(second);
|
|
229
|
+
return undefined;
|
|
174
230
|
return {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
recoveredWalBytes,
|
|
178
|
-
firstCompactGeneration: first.generation,
|
|
179
|
-
currentGeneration: second.generation,
|
|
231
|
+
recovered,
|
|
232
|
+
highestGeneration: highestGenerationOnDisk(dir),
|
|
180
233
|
};
|
|
181
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
|
+
}
|
|
182
284
|
}
|
|
183
285
|
getState() {
|
|
184
286
|
return this.state;
|
|
@@ -195,7 +297,15 @@ export class StateStore {
|
|
|
195
297
|
append(type, payload, flags = 0) {
|
|
196
298
|
assertUint16(type, "record type");
|
|
197
299
|
assertUint16(flags, "record flags");
|
|
198
|
-
|
|
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;
|
|
199
309
|
}
|
|
200
310
|
/**
|
|
201
311
|
* Async lock acquisition for callback-originated work. The write itself
|
|
@@ -205,7 +315,31 @@ export class StateStore {
|
|
|
205
315
|
async appendAsync(type, payload, flags = 0) {
|
|
206
316
|
assertUint16(type, "record type");
|
|
207
317
|
assertUint16(flags, "record flags");
|
|
208
|
-
|
|
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;
|
|
327
|
+
}
|
|
328
|
+
/** Conditionally append after obtaining the async scope lock. */
|
|
329
|
+
async appendIfAsync(type, payload, shouldAppend, flags = 0) {
|
|
330
|
+
assertUint16(type, "record type");
|
|
331
|
+
assertUint16(flags, "record flags");
|
|
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;
|
|
209
343
|
}
|
|
210
344
|
/** Refresh only the authenticated unseen WAL suffix when possible. */
|
|
211
345
|
refresh() {
|
|
@@ -241,9 +375,6 @@ export class StateStore {
|
|
|
241
375
|
this.lastRecordSequence = record.sequence;
|
|
242
376
|
this.recordCount++;
|
|
243
377
|
this.walBytes += frame.length;
|
|
244
|
-
if (this.recordCount >= this.options.maxRecordsBeforeCompaction ||
|
|
245
|
-
this.walBytes >= this.options.maxWalBytesBeforeCompaction)
|
|
246
|
-
this.compactUnlocked();
|
|
247
378
|
return { ...record, refresh };
|
|
248
379
|
}
|
|
249
380
|
/**
|
|
@@ -256,27 +387,87 @@ export class StateStore {
|
|
|
256
387
|
* and retain the prior compact copy.
|
|
257
388
|
*/
|
|
258
389
|
replaceState(nextState) {
|
|
259
|
-
|
|
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, () => {
|
|
260
398
|
this.refreshFromDisk();
|
|
261
399
|
const previousGeneration = this.generation;
|
|
262
400
|
const previousWalHasRecords = this.walBytes > WAL_HEADER_BYTES;
|
|
263
|
-
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
|
+
};
|
|
264
409
|
let retainedGeneration = previousGeneration;
|
|
265
410
|
let current = first;
|
|
266
411
|
if (previousWalHasRecords) {
|
|
267
|
-
current =
|
|
412
|
+
current = { ...first, generation: first.generation + 1 };
|
|
268
413
|
retainedGeneration = first.generation;
|
|
269
414
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
415
|
+
return {
|
|
416
|
+
sourceGeneration: previousGeneration,
|
|
417
|
+
sourceSequence: this.lastRecordSequence,
|
|
418
|
+
sourceWalBytes: this.walBytes,
|
|
419
|
+
first,
|
|
420
|
+
current,
|
|
421
|
+
retainedGeneration,
|
|
422
|
+
};
|
|
273
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
|
+
}
|
|
274
456
|
}
|
|
275
457
|
/** Create G+1 without ever merging two generations during recovery. */
|
|
276
458
|
compact() {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
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
|
+
}
|
|
280
471
|
});
|
|
281
472
|
}
|
|
282
473
|
refreshFromDisk() {
|
|
@@ -306,24 +497,46 @@ export class StateStore {
|
|
|
306
497
|
this.installGeneration(recovered);
|
|
307
498
|
return { records: [], recovered: true };
|
|
308
499
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
writeCurrentPointer(this.stateDir, next.generation);
|
|
313
|
-
pruneGenerations(this.stateDir, [previousGeneration, next.generation]);
|
|
314
|
-
this.installGeneration(next);
|
|
500
|
+
needsCompaction() {
|
|
501
|
+
return this.recordCount >= this.options.maxRecordsBeforeCompaction ||
|
|
502
|
+
this.walBytes >= this.options.maxWalBytesBeforeCompaction;
|
|
315
503
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
+
},
|
|
324
517
|
};
|
|
325
|
-
|
|
326
|
-
|
|
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
|
+
}
|
|
327
540
|
}
|
|
328
541
|
installGeneration(generation) {
|
|
329
542
|
this.generation = generation.generation;
|
|
@@ -388,6 +601,130 @@ function writeGeneration(dir, scopeDigest, generation) {
|
|
|
388
601
|
const header = encodeWalHeader(generation.generation, scopeDigest);
|
|
389
602
|
writeNewDurably(walPath(dir, generation.generation), header);
|
|
390
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
|
+
}
|
|
391
728
|
function readSnapshot(filePath, scopeDigest, generation) {
|
|
392
729
|
snapshotRecoveryCount++;
|
|
393
730
|
let fd;
|
|
@@ -729,6 +1066,7 @@ function decodeFrame(bytes, offset) {
|
|
|
729
1066
|
};
|
|
730
1067
|
}
|
|
731
1068
|
function encodeSnapshot(generation, scopeDigest, payload) {
|
|
1069
|
+
snapshotEncodeCount++;
|
|
732
1070
|
const data = Buffer.from(canonicalJson(payload), "utf8");
|
|
733
1071
|
const header = Buffer.alloc(52);
|
|
734
1072
|
SNAPSHOT_MAGIC.copy(header, 0);
|
|
@@ -815,6 +1153,35 @@ function writeDurableTempThenRename(dir, destination, bytes) {
|
|
|
815
1153
|
throw new Error(`state-store: snapshot write/rename failed for ${destination}`, { cause: error });
|
|
816
1154
|
}
|
|
817
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
|
+
}
|
|
818
1185
|
function writeCurrentPointer(dir, generation) {
|
|
819
1186
|
const pointer = path.join(dir, "current");
|
|
820
1187
|
writeDurableTempThenRename(dir, pointer, Buffer.from(`${generation}\n`, "ascii"));
|
|
@@ -828,6 +1195,18 @@ function writeAllSync(fd, bytes) {
|
|
|
828
1195
|
offset += written;
|
|
829
1196
|
}
|
|
830
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
|
+
}
|
|
831
1210
|
function readIntoSync(fd, target, position) {
|
|
832
1211
|
let offset = 0;
|
|
833
1212
|
while (offset < target.length) {
|
|
@@ -881,6 +1260,73 @@ function withScopeLock(dir, action) {
|
|
|
881
1260
|
throw actionError;
|
|
882
1261
|
return result;
|
|
883
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
|
+
}
|
|
884
1330
|
/**
|
|
885
1331
|
* Acquire the same inter-process lock without parking the Node event loop
|
|
886
1332
|
* while another writer owns it. This is deliberately additive: pass-side
|
|
@@ -938,7 +1384,7 @@ function removeDeadLock(lockPath) {
|
|
|
938
1384
|
return isMissing(error);
|
|
939
1385
|
}
|
|
940
1386
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
941
|
-
return
|
|
1387
|
+
return removeMalformedLock(lockPath);
|
|
942
1388
|
try {
|
|
943
1389
|
process.kill(pid, 0);
|
|
944
1390
|
return false;
|
|
@@ -966,7 +1412,7 @@ async function removeDeadLockAsync(lockPath) {
|
|
|
966
1412
|
return false;
|
|
967
1413
|
}
|
|
968
1414
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
969
|
-
return
|
|
1415
|
+
return removeMalformedLockAsync(lockPath);
|
|
970
1416
|
try {
|
|
971
1417
|
process.kill(pid, 0);
|
|
972
1418
|
return false;
|
|
@@ -985,6 +1431,24 @@ async function removeDeadLockAsync(lockPath) {
|
|
|
985
1431
|
return false;
|
|
986
1432
|
}
|
|
987
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
|
+
}
|
|
988
1452
|
function truncateWal(filePath, boundary) {
|
|
989
1453
|
let fd;
|
|
990
1454
|
try {
|