@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.
@@ -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 opening it once and refreshing it
153
- * a second time. The entire recovery and two-copy publication happens under
154
- * 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.
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
- return withScopeLock(dir, () => {
203
+ const plan = withScopeLock(dir, () => {
163
204
  cleanupUnpublishedInitialGeneration(dir);
164
205
  const newestGeneration = highestSnapshotGenerationOnDisk(dir);
165
206
  if (newestGeneration === undefined)
166
- return { status: "missing" };
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 { status: "not-needed" };
225
+ return undefined;
185
226
  }
186
227
  const recovered = readNewestGeneration(dir, scopeDigest, normalized.reduce, maintenance.onProgress);
187
228
  if (!recovered)
188
- return { status: "missing" };
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
- status: "repaired",
200
- recoveredGeneration,
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
- 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;
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
- 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;
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
- return withScopeLockAsync(this.stateDir, () => shouldAppend() ? this.appendUnlocked(type, payload, flags) : undefined);
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
- 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, () => {
290
398
  this.refreshFromDisk();
291
399
  const previousGeneration = this.generation;
292
400
  const previousWalHasRecords = this.walBytes > WAL_HEADER_BYTES;
293
- 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
+ };
294
409
  let retainedGeneration = previousGeneration;
295
410
  let current = first;
296
411
  if (previousWalHasRecords) {
297
- current = this.compactGeneration(first.generation + 1, nextState);
412
+ current = { ...first, generation: first.generation + 1 };
298
413
  retainedGeneration = first.generation;
299
414
  }
300
- writeCurrentPointer(this.stateDir, current.generation);
301
- pruneGenerations(this.stateDir, [retainedGeneration, current.generation]);
302
- this.installGeneration(current);
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
- withScopeLock(this.stateDir, () => {
308
- this.refreshFromDisk();
309
- 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
+ }
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
- compactUnlocked() {
340
- const previousGeneration = this.generation;
341
- const next = this.compactGeneration(highestGenerationOnDisk(this.stateDir) + 1);
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
- compactGeneration(generation, state = this.state) {
347
- const next = {
348
- generation,
349
- state,
350
- lastRecordSequence: this.lastRecordSequence,
351
- recordCount: 0,
352
- walBytes: WAL_HEADER_BYTES,
353
- 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
+ },
354
517
  };
355
- writeGeneration(this.stateDir, this.scopeDigest, next);
356
- 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
+ }
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 false;
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 false;
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 {