sliftutils 1.7.108 → 1.7.110

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(() => {
@@ -37,7 +37,7 @@ export declare class StoreSync {
37
37
  waitForRequiredScans(): Promise<void>;
38
38
  /** Rescans our own disk's metadata into the index - used around valid window handoffs, where another process wrote files to the shared folder that our index hasn't seen. */
39
39
  rescanBase(): Promise<void>;
40
- /** One synchronization round of a source: the PULL direction always (its listing, applied to our index), and with "push" the push direction too (what our index says the source is missing, written to it). Push is an argument rather than a separate call because it cannot run without the pull's listing - the index alone cannot say what the source already holds. Listings unblock (initialScan) between the halves, so they never wait behind a push. */
40
+ /** One synchronization round of a source: the PULL direction always (its listing, applied to our index), and with "push" the push direction too (what our index says the source is missing, written to it). Push is an argument rather than a separate call because it cannot run without the pull's listing - the index alone cannot say what the source already holds. Listings unblock (initialScan) between the halves, so they never wait behind a push. Only one round per SOURCE runs at a time (cache keys the serializer by source index). */
41
41
  private syncSource;
42
42
  /** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes): just its changes since the boundary neighborhood, with matching values pulled onto our own disk. */
43
43
  boundaryScanRemote(source: IArchives, config: {
@@ -1,4 +1,5 @@
1
- import { runInfinitePoll, delay, runInfinitePollCallAtStart } from "socket-function/src/batching";
1
+ import { runInfinitePoll, delay, runInfinitePollCallAtStart, runInSerial } from "socket-function/src/batching";
2
+ import { cache } from "socket-function/src/caching";
2
3
  import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
3
4
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
4
5
  import {
@@ -219,11 +220,11 @@ export class StoreSync {
219
220
 
220
221
  /** Rescans our own disk's metadata into the index - used around valid window handoffs, where another process wrote files to the shared folder that our index hasn't seen. */
221
222
  public async rescanBase(): Promise<void> {
222
- await this.syncSource(0);
223
+ await this.syncSource(0)();
223
224
  }
224
225
 
225
- /** One synchronization round of a source: the PULL direction always (its listing, applied to our index), and with "push" the push direction too (what our index says the source is missing, written to it). Push is an argument rather than a separate call because it cannot run without the pull's listing - the index alone cannot say what the source already holds. Listings unblock (initialScan) between the halves, so they never wait behind a push. */
226
- private async syncSource(sourceIndex: number, push?: "push"): Promise<void> {
226
+ /** One synchronization round of a source: the PULL direction always (its listing, applied to our index), and with "push" the push direction too (what our index says the source is missing, written to it). Push is an argument rather than a separate call because it cannot run without the pull's listing - the index alone cannot say what the source already holds. Listings unblock (initialScan) between the halves, so they never wait behind a push. Only one round per SOURCE runs at a time (cache keys the serializer by source index). */
227
+ private syncSource = cache((sourceIndex: number) => runInSerial(async (push?: "push"): Promise<void> => {
227
228
  let listing = await this.pullSource(sourceIndex);
228
229
  let state = this.states[sourceIndex];
229
230
  state.scanComplete = true;
@@ -231,7 +232,7 @@ export class StoreSync {
231
232
  if (push && !this.store.stopped.stop && !state.stopped.stop) {
232
233
  await this.pushSource(sourceIndex, listing);
233
234
  }
234
- }
235
+ }));
235
236
 
236
237
  /** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes): just its changes since the boundary neighborhood, with matching values pulled onto our own disk. */
237
238
  public async boundaryScanRemote(source: IArchives, config: { since: number; route?: [number, number] }): Promise<void> {
@@ -257,6 +258,14 @@ export class StoreSync {
257
258
  }
258
259
  let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, forceSetImmutable: true, noChecks: true, internal: true });
259
260
  if (!copied) {
261
+ // 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
262
+ let local = await this.store.sources[0].source.getInfo(file.path);
263
+ if (local && local.writeTime > file.createTime) {
264
+ 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 });
265
+ this.store.setIndexEntry(file.path, { writeTime: local.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
266
+ tally.unchanged++;
267
+ continue;
268
+ }
260
269
  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`);
261
270
  continue;
262
271
  }
@@ -304,12 +313,15 @@ export class StoreSync {
304
313
  }
305
314
  }
306
315
  if (this.store.stopped.stop || state.stopped.stop) return;
307
- let pollInterval = sourceObj.sourceConfig?.type === "remote" ? FULL_RESCAN_INTERVAL : FULL_RESCAN_NON_REMOTE_INTERVAL;
308
- await runInfinitePollCallAtStart(pollInterval, async () => {
316
+ // Hourly for our own disk (no sourceConfig - a cheap local walk, and how a sibling process's writes into the shared folder are found) and for remote peers (index-backed, cheap listings); the slow interval is only for sources where a full listing is genuinely expensive (backblaze)
317
+ let pollInterval = (!sourceObj.sourceConfig || sourceObj.sourceConfig.type === "remote") && FULL_RESCAN_INTERVAL || FULL_RESCAN_NON_REMOTE_INTERVAL;
318
+ // 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
319
+ let serial = runInSerial(async (fnc: () => Promise<void>) => await fnc());
320
+ await runInfinitePollCallAtStart(pollInterval, () => serial(async () => {
309
321
  while (!this.store.stopped.stop && !state.stopped.stop) {
310
322
  try {
311
- await this.syncSource(sourceIndex, "push");
312
- if (!noFullSync()) await this.copySourceFiles(sourceIndex);
323
+ await this.syncSource(sourceIndex)("push");
324
+ if (!noFullSync()) await this.copySourceFiles(sourceIndex)();
313
325
  } catch (e) {
314
326
  logSyncEvent({ event: "scanFailed", store: this.store.folder, source: source.getDebugName(), retryInMs: SCAN_RETRY_DELAY, error: String((e as Error).stack ?? e).slice(0, 2000) });
315
327
  await delay(SCAN_RETRY_DELAY);
@@ -319,16 +331,16 @@ export class StoreSync {
319
331
  state.initialScan.resolve(undefined);
320
332
  break;
321
333
  }
322
- }, state.stopped);
334
+ }), state.stopped);
323
335
  if (state.supportsChangesAfter) {
324
- runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
336
+ runInfinitePoll(CHANGES_POLL_INTERVAL, () => serial(async () => {
325
337
  // 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
326
338
  if (!state.scanSucceeded) {
327
- await this.syncSource(sourceIndex, "push");
339
+ await this.syncSource(sourceIndex)("push");
328
340
  }
329
341
  await this.pollChanges(sourceIndex);
330
- if (!noFullSync()) await this.copySourceFiles(sourceIndex);
331
- }, state.stopped);
342
+ if (!noFullSync()) await this.copySourceFiles(sourceIndex)();
343
+ }), state.stopped);
332
344
  }
333
345
  }
334
346
 
@@ -457,7 +469,17 @@ export class StoreSync {
457
469
  let holder = await this.store.getEntryHolder(entry);
458
470
  if (!holder) continue;
459
471
  let copied = await copyArchiveFile({ from: holder, to: source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
460
- if (!copied) continue;
472
+ if (!copied) {
473
+ // 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.
474
+ let theirs = await source.getInfo(key);
475
+ if (theirs && theirs.writeTime > writeTime) {
476
+ this.store.setIndexEntry(key, { writeTime: theirs.writeTime, size: theirs.size, sourcesListIndex: targetSourcesListIndex });
477
+ 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 });
478
+ continue;
479
+ }
480
+ 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" });
481
+ continue;
482
+ }
461
483
  this.store.noteSyncTransfer("sync set", key, copied.size);
462
484
  pushed++;
463
485
  consecutiveFailures = 0;
@@ -522,8 +544,8 @@ export class StoreSync {
522
544
  state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
523
545
  }
524
546
 
525
- // Downloads the files a source currently holds onto our own base source (the local disk), preserving their modified times — so a newer local write always wins. Skipped for noFullSync sources (fronting a large database without copying it); reads still down-cache lazily.
526
- private async copySourceFiles(sourceIndex: number): Promise<void> {
547
+ // Downloads the files a source currently holds onto our own base source (the local disk), preserving their modified times — so a newer local write always wins. Skipped for noFullSync sources (fronting a large database without copying it); reads still down-cache lazily. Only one pass per SOURCE runs at a time (cache keys the serializer by source index) - two passes over the same pending list would download everything twice.
548
+ private copySourceFiles = cache((sourceIndex: number) => runInSerial(async (): Promise<void> => {
527
549
  if (sourceIndex === 0) return;
528
550
  let { source } = this.store.sources[sourceIndex];
529
551
  let state = this.states[sourceIndex];
@@ -546,11 +568,11 @@ export class StoreSync {
546
568
  totalBytes,
547
569
  };
548
570
  this.activities.add(activity);
549
- logSyncEvent({ event: "fullSyncStart", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, files: pending.length, bytes: totalBytes });
571
+ logSyncEvent({ event: "deltaSyncStart", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, files: pending.length, bytes: totalBytes });
550
572
  let progressLogged = false;
551
573
  let logProgress = () => {
552
574
  progressLogged = true;
553
- 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)}%)`));
575
+ 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)}%)`));
554
576
  };
555
577
  let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
556
578
  (progressTimer as { unref?: () => void }).unref?.();
@@ -565,7 +587,7 @@ export class StoreSync {
565
587
  let remainingMs = remainingBytes / bytesPerSecond * 1000;
566
588
  etaText = `${formatTime(remainingMs)} remaining, completing around ${new Date(Date.now() + remainingMs).toISOString()}`;
567
589
  }
568
- 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}.`);
590
+ 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}.`);
569
591
  }, FULL_SYNC_SLOW_ERROR_INTERVAL);
570
592
  (slowErrorTimer as { unref?: () => void }).unref?.();
571
593
  let failed = 0;
@@ -578,8 +600,9 @@ export class StoreSync {
578
600
  let base = { event, store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, ...extra };
579
601
  let local = await this.store.sources[0].source.getInfo(key);
580
602
  if (local) {
581
- this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
582
- logSyncEvent({ ...base, resolution: "repointed to our local copy", localSize: local.size });
603
+ // 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
604
+ this.store.setIndexEntry(key, { writeTime: Math.max(entry.writeTime, local.writeTime), size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
605
+ logSyncEvent({ ...base, resolution: "repointed to our local copy", localSize: local.size, localWriteTime: new Date(local.writeTime).toISOString() });
583
606
  return;
584
607
  }
585
608
  if (this.store.currentWriteTime(key) <= entry.writeTime) {
@@ -606,11 +629,11 @@ export class StoreSync {
606
629
  this.store.noteSyncTransfer("sync get", key, copied.size);
607
630
  // 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
608
631
  if (!this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) })) {
609
- logSyncEvent({ event: "fullSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
632
+ logSyncEvent({ event: "deltaSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
610
633
  }
611
634
  } else {
612
635
  missingOnSource++;
613
- await resolveUnavailable("fullSyncMissingOnSource", key, entry, {});
636
+ await resolveUnavailable("deltaSyncMissingOnSource", key, entry, {});
614
637
  }
615
638
  consecutiveFailures = 0;
616
639
  } catch (e) {
@@ -618,9 +641,9 @@ export class StoreSync {
618
641
  failed++;
619
642
  consecutiveFailures++;
620
643
  try {
621
- await resolveUnavailable("fullSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
644
+ await resolveUnavailable("deltaSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
622
645
  } catch (resolveError) {
623
- 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) });
646
+ 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) });
624
647
  }
625
648
  if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
626
649
  aborted = true;
@@ -646,9 +669,9 @@ export class StoreSync {
646
669
  if (progressLogged) {
647
670
  logProgress();
648
671
  }
649
- 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 });
672
+ 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 });
650
673
  }
651
- }
674
+ }));
652
675
 
653
676
  // ── maintenance ──
654
677