@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.
@@ -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 opening it once and refreshing it
129
- * a second time. The entire recovery and two-copy publication happens under
130
- * the scope lock, so an appender cannot race the migration.
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
- return withScopeLock(dir, () => {
203
+ const plan = withScopeLock(dir, () => {
139
204
  cleanupUnpublishedInitialGeneration(dir);
140
205
  const newestGeneration = highestSnapshotGenerationOnDisk(dir);
141
206
  if (newestGeneration === undefined)
142
- return { status: "missing" };
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 { status: "not-needed" };
225
+ return undefined;
161
226
  }
162
227
  const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce, maintenance.onProgress);
163
228
  if (!recovered)
164
- return { status: "missing" };
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
- status: "repaired",
176
- recoveredGeneration,
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
- return withScopeLock(this.stateDir, () => this.appendUnlocked(type, payload, flags));
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
- return withScopeLockAsync(this.stateDir, () => this.appendUnlocked(type, payload, flags));
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
- withScopeLock(this.stateDir, () => {
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 = this.compactGeneration(highestGenerationOnDisk(this.stateDir) + 1, nextState);
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 = this.compactGeneration(first.generation + 1, nextState);
412
+ current = { ...first, generation: first.generation + 1 };
268
413
  retainedGeneration = first.generation;
269
414
  }
270
- writeCurrentPointer(this.stateDir, current.generation);
271
- pruneGenerations(this.stateDir, [retainedGeneration, current.generation]);
272
- this.installGeneration(current);
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
- withScopeLock(this.stateDir, () => {
278
- this.refreshFromDisk();
279
- this.compactUnlocked();
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
- compactUnlocked() {
310
- const previousGeneration = this.generation;
311
- const next = this.compactGeneration(highestGenerationOnDisk(this.stateDir) + 1);
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
- compactGeneration(generation, state = this.state) {
317
- const next = {
318
- generation,
319
- state,
320
- lastRecordSequence: this.lastRecordSequence,
321
- recordCount: 0,
322
- walBytes: WAL_HEADER_BYTES,
323
- maxReadBytes: 0,
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
- writeGeneration(this.stateDir, this.scopeDigest, next);
326
- return next;
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 false;
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 false;
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 {