sliftutils 1.7.109 → 1.7.111

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.
@@ -2,12 +2,13 @@
2
2
  /// <reference types="node" />
3
3
  import { LogFileInfo } from "../StreamingLogs";
4
4
  export declare const LOGS_FOLDER_NAME = "logs";
5
- /** One mutation the server performed: set/del/move/undelete/setLarge/routingConfig, plus the per-file synchronization writes ("sync get"/"sync set"). Sizes and times, never the data. internal marks writes pushed by a peer's synchronization rather than a client. Stream-only - one entry per write is exactly what the console does NOT need. */
5
+ /** One mutation the server performed: set/del/move/undelete/setLarge/routingConfig, plus the per-file synchronization writes ("sync get"/"sync set"). Sizes and times, never the data. internal marks writes pushed by a peer's synchronization rather than a client. Logged DELIBERATELY at two layers: the controller (which knows the account/bucket and the caller) AND BlobStore itself (which knows the folder, and sees the writes that never pass through the controller) - the redundancy is the point, because a write that only one layer saw is exactly the kind of masked issue these logs exist to expose. Stream-only - one entry per write is exactly what the console does NOT need. */
6
6
  export declare function logMutation(entry: {
7
7
  op: string;
8
- account: string;
9
- bucketName: string;
8
+ account?: string;
9
+ bucketName?: string;
10
10
  store?: string;
11
+ folder?: string;
11
12
  path: string;
12
13
  toPath?: string;
13
14
  size?: number;
@@ -24,6 +25,8 @@ export declare function logSyncEvent(entry: {
24
25
  }): void;
25
26
  /** One console.error is all an error takes (console.error and console.warn are HOOKED to feed the stream) - this just guarantees the hook is installed first, for very-early callers. */
26
27
  export declare function logStorageError(message: string): void;
28
+ /** logStorageError at warn level: guarantees the console.warn hook is installed before warning, so warnings from before the first logged mutation still reach the stream. */
29
+ export declare function logStorageWarn(message: string): void;
27
30
  /** The log files this server holds - see StreamingLogs.listFiles. Empty on processes with no storage folder. */
28
31
  export declare function listStorageLogFiles(): Promise<LogFileInfo[]>;
29
32
  /** One log file's bytes, always LZ4-compressed - see StreamingLogs.readFileCompressed (decode with decodeLogFile). */
@@ -66,8 +66,8 @@ function write(kind: string, entry: { [key: string]: unknown }, alsoConsole: boo
66
66
  logs.log({ kind, time: Date.now(), ...baseFields(), ...entry });
67
67
  }
68
68
 
69
- /** One mutation the server performed: set/del/move/undelete/setLarge/routingConfig, plus the per-file synchronization writes ("sync get"/"sync set"). Sizes and times, never the data. internal marks writes pushed by a peer's synchronization rather than a client. Stream-only - one entry per write is exactly what the console does NOT need. */
70
- export function logMutation(entry: { op: string; account: string; bucketName: string; store?: string; path: string; toPath?: string; size?: number; writeTime?: number; callerId?: string; internal?: boolean }): void {
69
+ /** One mutation the server performed: set/del/move/undelete/setLarge/routingConfig, plus the per-file synchronization writes ("sync get"/"sync set"). Sizes and times, never the data. internal marks writes pushed by a peer's synchronization rather than a client. Logged DELIBERATELY at two layers: the controller (which knows the account/bucket and the caller) AND BlobStore itself (which knows the folder, and sees the writes that never pass through the controller) - the redundancy is the point, because a write that only one layer saw is exactly the kind of masked issue these logs exist to expose. Stream-only - one entry per write is exactly what the console does NOT need. */
70
+ export function logMutation(entry: { op: string; account?: string; bucketName?: string; store?: string; folder?: string; path: string; toPath?: string; size?: number; writeTime?: number; callerId?: string; internal?: boolean }): void {
71
71
  write("mutation", entry, false);
72
72
  }
73
73
 
@@ -82,6 +82,12 @@ export function logStorageError(message: string): void {
82
82
  console.error(message);
83
83
  }
84
84
 
85
+ /** logStorageError at warn level: guarantees the console.warn hook is installed before warning, so warnings from before the first logged mutation still reach the stream. */
86
+ export function logStorageWarn(message: string): void {
87
+ getLogs();
88
+ console.warn(message);
89
+ }
90
+
85
91
  // Everything console.errored or console.warned also lands in the log stream (with a reentrancy guard: a failure INSIDE logging must not log itself forever)
86
92
  let writingHooked = false;
87
93
  const hookErrorLogging = lazy(() => {
@@ -44,6 +44,7 @@ export declare class StoreSync {
44
44
  since: number;
45
45
  route?: [number, number];
46
46
  }): Promise<void>;
47
+ private windowsAllowScanning;
47
48
  private startSourceSyncLoops;
48
49
  private pullSource;
49
50
  private pushSource;
@@ -44,6 +44,8 @@ const SYNC_FAILURE_DELAY = 1000 * 15;
44
44
  // A full listing that comes back EMPTY - or under half of what we know the source holds - is treated as the other end being briefly broken, not as the truth: it is retried this many times, this far apart, before being believed. Believing a wrongly-shrunken listing purges every index entry the source held, which is how sync progress "goes backwards".
45
45
  const SUSPICIOUS_SCAN_RETRIES = 3;
46
46
  const SUSPICIOUS_SCAN_RETRY_DELAY = 1000 * 60;
47
+ // A source whose every window ended this long ago (and has none in the future) can never receive writes again - the window gates them, deletions included. Scanning such a source can only re-ingest history it will never be told to delete, which after tombstone expiry IS resurrection - so it is not scanned at all. The grace covers handoff stragglers around a window's end.
48
+ const SCAN_STALE_WINDOW_GRACE = 1000 * 60 * 60;
47
49
 
48
50
  type SourceState = {
49
51
  supportsChangesAfter: boolean;
@@ -258,6 +260,14 @@ export class StoreSync {
258
260
  }
259
261
  let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, forceSetImmutable: true, noChecks: true, internal: true });
260
262
  if (!copied) {
263
+ // Undefined is two cases (see copyArchiveFile): our own disk already holding something newer is the boundary scan working as intended (our writes since the boundary outrank the neighbor's), only a genuinely unreadable file is worth a warning
264
+ let local = await this.store.sources[0].source.getInfo(file.path);
265
+ if (local && local.writeTime > file.createTime) {
266
+ logSyncEvent({ event: "boundaryScanLocalNewer", store: this.store.folder, source: source.getDebugName(), path: file.path, theirWriteTime: new Date(file.createTime).toISOString(), theirSize: file.size, localWriteTime: new Date(local.writeTime).toISOString(), localSize: local.size });
267
+ this.store.setIndexEntry(file.path, { writeTime: local.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
268
+ tally.unchanged++;
269
+ continue;
270
+ }
261
271
  console.warn(`Boundary scan could not copy ${file.path} from ${source.getDebugName()} (store ${this.store.folder}): its change feed listed it (${file.size} bytes, writeTime ${new Date(file.createTime).toISOString()}) but the read found nothing`);
262
272
  continue;
263
273
  }
@@ -279,6 +289,12 @@ export class StoreSync {
279
289
 
280
290
  // ── per-source loops ──
281
291
 
292
+ // Slot 0 is exempt: our own disk answers for every window this store EVER held, and scanning it is what keeps the index honest about them. Read live each call - updateSources moves the windows on running slots, so a source goes stale (or comes back) while its loops run.
293
+ private windowsAllowScanning(sourceIndex: number): boolean {
294
+ if (sourceIndex === 0) return true;
295
+ return this.store.sources[sourceIndex].validWindows.some(w => w[1] > Date.now() - SCAN_STALE_WINDOW_GRACE);
296
+ }
297
+
282
298
  private async startSourceSyncLoops(sourceIndex: number): Promise<void> {
283
299
  await this.store.registerSlot(sourceIndex);
284
300
  let sourceObj = this.store.sources[sourceIndex];
@@ -294,7 +310,23 @@ export class StoreSync {
294
310
  state.initialScan.resolve(undefined);
295
311
  return;
296
312
  }
297
- while (!this.store.stopped.stop && !state.stopped.stop) {
313
+ // Checked per tick, never decided once: the windows move on running slots (see updateSources), so a source goes stale mid-life and can come back if a config extends them. Logged only on each transition into staleness.
314
+ let loggedStale = false;
315
+ let skipStale = () => {
316
+ if (this.windowsAllowScanning(sourceIndex)) {
317
+ loggedStale = false;
318
+ return false;
319
+ }
320
+ state.scanComplete = true;
321
+ state.initialScan.resolve(undefined);
322
+ if (!loggedStale) {
323
+ loggedStale = true;
324
+ logSyncEvent({ event: "scanSkippedStaleWindows", store: this.store.folder, source: source.getDebugName(), validWindows: this.store.sources[sourceIndex].validWindows.map(w => [new Date(w[0]).toISOString(), new Date(w[1]).toISOString()]), graceMs: SCAN_STALE_WINDOW_GRACE });
325
+ }
326
+ return true;
327
+ };
328
+ // An already-stale source skips getConfig entirely: its endpoint is often long gone, and retrying that every 30s forever is noise about a source we would not scan anyway (if its windows are later extended, the full-round poll below picks it up - just without change polling until a restart)
329
+ while (!skipStale() && !this.store.stopped.stop && !state.stopped.stop) {
298
330
  try {
299
331
  let config = await source.getConfig();
300
332
  state.supportsChangesAfter = !!config.supportsChangesAfter;
@@ -310,6 +342,7 @@ export class StoreSync {
310
342
  // Both loops below run one at a time: a change-poll tick landing mid full round would otherwise start a second copy pass over the same pending list, downloading everything twice
311
343
  let serial = runInSerial(async (fnc: () => Promise<void>) => await fnc());
312
344
  await runInfinitePollCallAtStart(pollInterval, () => serial(async () => {
345
+ if (skipStale()) return;
313
346
  while (!this.store.stopped.stop && !state.stopped.stop) {
314
347
  try {
315
348
  await this.syncSource(sourceIndex)("push");
@@ -326,6 +359,7 @@ export class StoreSync {
326
359
  }), state.stopped);
327
360
  if (state.supportsChangesAfter) {
328
361
  runInfinitePoll(CHANGES_POLL_INTERVAL, () => serial(async () => {
362
+ if (skipStale()) return;
329
363
  // A scan that has not succeeded yet is retried HERE, before anything else - polling changes and copying against a never-scanned source would run on arbitrarily stale information
330
364
  if (!state.scanSucceeded) {
331
365
  await this.syncSource(sourceIndex)("push");
@@ -461,7 +495,17 @@ export class StoreSync {
461
495
  let holder = await this.store.getEntryHolder(entry);
462
496
  if (!holder) continue;
463
497
  let copied = await copyArchiveFile({ from: holder, to: source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
464
- if (!copied) continue;
498
+ if (!copied) {
499
+ // Undefined is two cases (see copyArchiveFile). The source having a NEWER file than our index (the listing we pushed from was stale) is adopted exactly the way the pull direction adopts a listing entry - and pushing our stale copy stops. Otherwise the HOLDER could not produce the file, which the next round's pull re-resolves; either way, silence here was the bug.
500
+ let theirs = await source.getInfo(key);
501
+ if (theirs && theirs.writeTime > writeTime) {
502
+ this.store.setIndexEntry(key, { writeTime: theirs.writeTime, size: theirs.size, sourcesListIndex: targetSourcesListIndex });
503
+ logSyncEvent({ event: "pushFoundNewerOnSource", store: this.store.folder, source: source.getDebugName(), path: key, ourWriteTime: new Date(writeTime).toISOString(), theirWriteTime: new Date(theirs.writeTime).toISOString(), ourSize: entry.size, theirSize: theirs.size });
504
+ continue;
505
+ }
506
+ logSyncEvent({ event: "pushCopyUnavailable", store: this.store.folder, source: source.getDebugName(), path: key, holder: holder.getDebugName(), expectedSize: entry.size, expectedWriteTime: new Date(writeTime).toISOString(), sourceHas: theirs && `${theirs.size} bytes at ${new Date(theirs.writeTime).toISOString()}` || "nothing" });
507
+ continue;
508
+ }
465
509
  this.store.noteSyncTransfer("sync set", key, copied.size);
466
510
  pushed++;
467
511
  consecutiveFailures = 0;
@@ -550,11 +594,11 @@ export class StoreSync {
550
594
  totalBytes,
551
595
  };
552
596
  this.activities.add(activity);
553
- logSyncEvent({ event: "fullSyncStart", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, files: pending.length, bytes: totalBytes });
597
+ logSyncEvent({ event: "deltaSyncStart", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, files: pending.length, bytes: totalBytes });
554
598
  let progressLogged = false;
555
599
  let logProgress = () => {
556
600
  progressLogged = true;
557
- console.log(magenta(`Full sync from ${source.getDebugName()} (store ${this.store.folder}): ${activity.doneFiles}/${pending.length} files (${((activity.doneFiles || 0) / pending.length * 100).toFixed(1)}%), ${formatNumber(activity.doneBytes || 0)}B/${formatNumber(totalBytes)}B (${(totalBytes && (activity.doneBytes || 0) / totalBytes * 100 || 100).toFixed(1)}%)`));
601
+ console.log(magenta(`Delta sync from ${source.getDebugName()} (store ${this.store.folder}): ${activity.doneFiles}/${pending.length} files (${((activity.doneFiles || 0) / pending.length * 100).toFixed(1)}%), ${formatNumber(activity.doneBytes || 0)}B/${formatNumber(totalBytes)}B (${(totalBytes && (activity.doneBytes || 0) / totalBytes * 100 || 100).toFixed(1)}%)`));
558
602
  };
559
603
  let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
560
604
  (progressTimer as { unref?: () => void }).unref?.();
@@ -569,7 +613,7 @@ export class StoreSync {
569
613
  let remainingMs = remainingBytes / bytesPerSecond * 1000;
570
614
  etaText = `${formatTime(remainingMs)} remaining, completing around ${new Date(Date.now() + remainingMs).toISOString()}`;
571
615
  }
572
- console.warn(`Full sync from ${source.getDebugName()} (store ${this.store.folder}) has been running for ${formatTime(elapsed)}: ${doneFiles}/${pending.length} files (${(doneFiles / pending.length * 100).toFixed(1)}%), ${formatNumber(doneBytes)}B/${formatNumber(totalBytes)}B (${(totalBytes && doneBytes / totalBytes * 100 || 100).toFixed(1)}%), ${formatNumber(bytesPerSecond)}B/s. Estimated ${etaText}.`);
616
+ console.warn(`Delta sync from ${source.getDebugName()} (store ${this.store.folder}) has been running for ${formatTime(elapsed)}: ${doneFiles}/${pending.length} files (${(doneFiles / pending.length * 100).toFixed(1)}%), ${formatNumber(doneBytes)}B/${formatNumber(totalBytes)}B (${(totalBytes && doneBytes / totalBytes * 100 || 100).toFixed(1)}%), ${formatNumber(bytesPerSecond)}B/s. Estimated ${etaText}.`);
573
617
  }, FULL_SYNC_SLOW_ERROR_INTERVAL);
574
618
  (slowErrorTimer as { unref?: () => void }).unref?.();
575
619
  let failed = 0;
@@ -582,8 +626,9 @@ export class StoreSync {
582
626
  let base = { event, store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, ...extra };
583
627
  let local = await this.store.sources[0].source.getInfo(key);
584
628
  if (local) {
585
- this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
586
- logSyncEvent({ ...base, resolution: "repointed to our local copy", localSize: local.size });
629
+ // The local copy can be NEWER than the entry (which is exactly why copyArchiveFile refuses to overwrite it - see its destination check), so the repoint keeps the newer of the two times rather than rolling the index back
630
+ this.store.setIndexEntry(key, { writeTime: Math.max(entry.writeTime, local.writeTime), size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
631
+ logSyncEvent({ ...base, resolution: "repointed to our local copy", localSize: local.size, localWriteTime: new Date(local.writeTime).toISOString() });
587
632
  return;
588
633
  }
589
634
  if (this.store.currentWriteTime(key) <= entry.writeTime) {
@@ -610,11 +655,11 @@ export class StoreSync {
610
655
  this.store.noteSyncTransfer("sync get", key, copied.size);
611
656
  // The copy carries the source's write time, and the index commits it under the normal ordering rule (>= the current time wins) - it refusing means a NEWER write landed while we copied, which must be said, not swallowed
612
657
  if (!this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) })) {
613
- logSyncEvent({ event: "fullSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
658
+ logSyncEvent({ event: "deltaSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
614
659
  }
615
660
  } else {
616
661
  missingOnSource++;
617
- await resolveUnavailable("fullSyncMissingOnSource", key, entry, {});
662
+ await resolveUnavailable("deltaSyncMissingOnSource", key, entry, {});
618
663
  }
619
664
  consecutiveFailures = 0;
620
665
  } catch (e) {
@@ -622,9 +667,9 @@ export class StoreSync {
622
667
  failed++;
623
668
  consecutiveFailures++;
624
669
  try {
625
- await resolveUnavailable("fullSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
670
+ await resolveUnavailable("deltaSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
626
671
  } catch (resolveError) {
627
- logSyncEvent({ event: "fullSyncCopyFailed", store: this.store.folder, source: source.getDebugName(), path: key, error: String((e as Error).stack ?? e).slice(0, 2000), resolveError: String((resolveError as Error).stack ?? resolveError).slice(0, 2000) });
672
+ logSyncEvent({ event: "deltaSyncCopyFailed", store: this.store.folder, source: source.getDebugName(), path: key, error: String((e as Error).stack ?? e).slice(0, 2000), resolveError: String((resolveError as Error).stack ?? resolveError).slice(0, 2000) });
628
673
  }
629
674
  if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
630
675
  aborted = true;
@@ -650,7 +695,7 @@ export class StoreSync {
650
695
  if (progressLogged) {
651
696
  logProgress();
652
697
  }
653
- logSyncEvent({ event: "fullSyncFinish", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, durationMs: Date.now() - activity.startTime, totalFiles: pending.length, totalBytes, processedFiles: activity.doneFiles, copiedFiles, copiedBytes, missingOnSource, failed, aborted });
698
+ logSyncEvent({ event: "deltaSyncFinish", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, durationMs: Date.now() - activity.startTime, totalFiles: pending.length, totalBytes, processedFiles: activity.doneFiles, copiedFiles, copiedBytes, missingOnSource, failed, aborted });
654
699
  }
655
700
  }));
656
701