@indigoai-us/hq-cloud 6.15.74 → 6.15.76

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