sliftutils 1.7.107 → 1.7.109

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,7 +2,7 @@
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. Also printed to the console. */
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. */
6
6
  export declare function logMutation(entry: {
7
7
  op: string;
8
8
  account: string;
@@ -66,9 +66,9 @@ 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. Also printed to the console. */
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
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 {
71
- write("mutation", entry, true);
71
+ write("mutation", entry, false);
72
72
  }
73
73
 
74
74
  /** A synchronization key point: scans and full syncs starting/finishing, reconciles, boundary scans - what an operator greps for to see whether the fleet is converging. Also printed to the console. */
@@ -37,14 +37,14 @@ 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: {
44
44
  since: number;
45
45
  route?: [number, number];
46
46
  }): Promise<void>;
47
- private runSourceSync;
47
+ private startSourceSyncLoops;
48
48
  private pullSource;
49
49
  private pushSource;
50
50
  private updateScanIndex;
@@ -1,4 +1,5 @@
1
- import { runInfinitePoll, delay } 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 {
@@ -21,7 +22,7 @@ const CHANGES_POLL_INTERVAL = 1000 * 60;
21
22
  const CONFIG_POLL_INTERVAL = 1000 * 60 * 5;
22
23
  // Full metadata rescans. supportsChangesAfter is the heuristic for "one of our own storage servers": their index-backed listings are cheap, so hourly is fine. Everything else (backblaze, plain disk) pays the full listing cost, so it rescans much less often.
23
24
  const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
24
- const FULL_RESCAN_UNINDEXED_INTERVAL = 1000 * 60 * 60 * 6;
25
+ const FULL_RESCAN_NON_REMOTE_INTERVAL = 1000 * 60 * 60 * 6;
25
26
  // Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
26
27
  const CHANGES_POLL_OVERLAP = timeInMinute;
27
28
  const SCAN_RETRY_DELAY = 1000 * 30;
@@ -97,7 +98,7 @@ export class StoreSync {
97
98
  public start(): void {
98
99
  for (let i = 0; i < this.store.sources.length; i++) {
99
100
  if (!this.isLive(i)) continue;
100
- void this.runSourceSync(i);
101
+ void this.startSourceSyncLoops(i);
101
102
  }
102
103
  runInfinitePoll(CONFIG_POLL_INTERVAL, () => this.pollRoutingConfig(), this.store.stopped);
103
104
  runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.store.stopped);
@@ -147,7 +148,7 @@ export class StoreSync {
147
148
  public addSource(slot: number): void {
148
149
  this.states[slot] = newSourceState();
149
150
  if (this.store.syncStarted) {
150
- void this.runSourceSync(slot);
151
+ void this.startSourceSyncLoops(slot);
151
152
  }
152
153
  }
153
154
 
@@ -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> {
@@ -255,7 +256,7 @@ export class StoreSync {
255
256
  tally.tombstone++;
256
257
  continue;
257
258
  }
258
- let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, size: file.size, writeTime: file.createTime, forceSetImmutable: true, noChecks: true, internal: true });
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) {
260
261
  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
262
  continue;
@@ -278,9 +279,10 @@ export class StoreSync {
278
279
 
279
280
  // ── per-source loops ──
280
281
 
281
- private async runSourceSync(sourceIndex: number): Promise<void> {
282
+ private async startSourceSyncLoops(sourceIndex: number): Promise<void> {
282
283
  await this.store.registerSlot(sourceIndex);
283
- let { source } = this.store.sources[sourceIndex];
284
+ let sourceObj = this.store.sources[sourceIndex];
285
+ let source = sourceObj.source;
284
286
  let state = this.states[sourceIndex];
285
287
  // If a slot ever gets TWO of these, its loops are doubled - every poll and full sync runs twice
286
288
  logSyncEvent({ event: "sourceSyncStart", store: this.store.folder, source: source.getDebugName(), sourceIndex, url: this.store.sources[sourceIndex].url, intermediate: this.store.sources[sourceIndex].intermediate });
@@ -296,38 +298,42 @@ export class StoreSync {
296
298
  try {
297
299
  let config = await source.getConfig();
298
300
  state.supportsChangesAfter = !!config.supportsChangesAfter;
299
- await this.syncSource(sourceIndex, "push");
300
301
  break;
301
302
  } catch (e) {
302
303
  logSyncEvent({ event: "initialScanFailed", store: this.store.folder, source: source.getDebugName(), retryInMs: SCAN_RETRY_DELAY, error: String((e as Error).stack ?? e).slice(0, 2000) });
303
304
  await delay(SCAN_RETRY_DELAY);
304
305
  }
305
306
  }
306
- // The loop can also exit by the stop tokens, and nothing may wait forever on a stopped source's initial scan
307
- state.scanComplete = true;
308
- state.initialScan.resolve(undefined);
309
307
  if (this.store.stopped.stop || state.stopped.stop) return;
310
- if (!noFullSync()) {
311
- try {
312
- await this.copySourceFiles(sourceIndex);
313
- } catch (e) {
314
- console.error(`Copying files from sync source ${source.getDebugName()} failed:`, e);
308
+ // 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)
309
+ let pollInterval = (!sourceObj.sourceConfig || sourceObj.sourceConfig.type === "remote") && FULL_RESCAN_INTERVAL || FULL_RESCAN_NON_REMOTE_INTERVAL;
310
+ // 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
+ let serial = runInSerial(async (fnc: () => Promise<void>) => await fnc());
312
+ await runInfinitePollCallAtStart(pollInterval, () => serial(async () => {
313
+ while (!this.store.stopped.stop && !state.stopped.stop) {
314
+ try {
315
+ await this.syncSource(sourceIndex)("push");
316
+ if (!noFullSync()) await this.copySourceFiles(sourceIndex)();
317
+ } catch (e) {
318
+ logSyncEvent({ event: "scanFailed", store: this.store.folder, source: source.getDebugName(), retryInMs: SCAN_RETRY_DELAY, error: String((e as Error).stack ?? e).slice(0, 2000) });
319
+ await delay(SCAN_RETRY_DELAY);
320
+ continue;
321
+ }
322
+ state.scanComplete = true;
323
+ state.initialScan.resolve(undefined);
324
+ break;
315
325
  }
316
- }
326
+ }), state.stopped);
317
327
  if (state.supportsChangesAfter) {
318
- runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
328
+ runInfinitePoll(CHANGES_POLL_INTERVAL, () => serial(async () => {
319
329
  // 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
320
330
  if (!state.scanSucceeded) {
321
- await this.syncSource(sourceIndex, "push");
331
+ await this.syncSource(sourceIndex)("push");
322
332
  }
323
333
  await this.pollChanges(sourceIndex);
324
- if (!noFullSync()) await this.copySourceFiles(sourceIndex);
325
- }, state.stopped);
334
+ if (!noFullSync()) await this.copySourceFiles(sourceIndex)();
335
+ }), state.stopped);
326
336
  }
327
- runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
328
- await this.syncSource(sourceIndex, "push");
329
- if (!noFullSync()) await this.copySourceFiles(sourceIndex);
330
- }, state.stopped);
331
337
  }
332
338
 
333
339
  // The PULL direction: a full metadata scan (size, writeTime, path) of one source, applied to our index. Returns the source's listing (path -> write time), which pushSource uses for the opposite direction.
@@ -383,13 +389,22 @@ export class StoreSync {
383
389
  tally[this.updateScanIndex(sourceIndex, file)]++;
384
390
  }
385
391
  state.scannedCount = files.length;
386
- // Entries this source was the holder of, but that its listing did not mention, are forgotten - we were wrong about where they are, which is not the same as them having been deleted. Entries changed after the scan started are kept: the listing may simply predate them. Tombstones are not walked here at all, because they are not files a listing could vouch for.
392
+ // Entries this source was the holder of, but that its listing did not mention: our own disk takes over as holder when it has a copy, and only with no local copy either is the entry forgotten - we were wrong about where the file is, which is not the same as it having been deleted. Entries changed after the scan started are kept: the listing may simply predate them. Tombstones are not walked here at all, because they are not files a listing could vouch for.
387
393
  let removedFromIndex = 0;
394
+ let repointedToLocal = 0;
388
395
  let missingOnSource = 0;
389
396
  let scannedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
390
397
  for (let [key, entry] of this.store.indexEntries()) {
391
398
  if (seen.has(key)) continue;
392
399
  if (entry.sourcesListIndex === scannedSourcesListIndex && entry.changedAt < scanStart) {
400
+ if (sourceIndex !== 0) {
401
+ let local = await this.store.sources[0].source.getInfo(key);
402
+ if (local) {
403
+ this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
404
+ repointedToLocal++;
405
+ continue;
406
+ }
407
+ }
393
408
  this.store.purgeIndexEntry(key);
394
409
  removedFromIndex++;
395
410
  continue;
@@ -399,7 +414,7 @@ export class StoreSync {
399
414
  if (!routeContains(route, getRoute(key))) continue;
400
415
  missingOnSource++;
401
416
  }
402
- logSyncEvent({ event: "scanFinish", store: this.store.folder, source: source.getDebugName(), durationMs: Date.now() - scanStart, listed: files.length, indexedBefore: indexSizeBefore, newPaths: tally.new, updated: tally.updated, tombstones: tally.tombstone, unchanged: tally.unchanged, outsideRoute: tally.filtered, missingOnSource, removedFromIndex });
417
+ logSyncEvent({ event: "scanFinish", store: this.store.folder, source: source.getDebugName(), durationMs: Date.now() - scanStart, listed: files.length, indexedBefore: indexSizeBefore, newPaths: tally.new, updated: tally.updated, tombstones: tally.tombstone, unchanged: tally.unchanged, outsideRoute: tally.filtered, missingOnSource, repointedToLocal, removedFromIndex });
403
418
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
404
419
  state.scanSucceeded = true;
405
420
  return seen;
@@ -445,7 +460,7 @@ export class StoreSync {
445
460
  }
446
461
  let holder = await this.store.getEntryHolder(entry);
447
462
  if (!holder) continue;
448
- let copied = await copyArchiveFile({ from: holder, to: source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
463
+ let copied = await copyArchiveFile({ from: holder, to: source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
449
464
  if (!copied) continue;
450
465
  this.store.noteSyncTransfer("sync set", key, copied.size);
451
466
  pushed++;
@@ -511,8 +526,8 @@ export class StoreSync {
511
526
  state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
512
527
  }
513
528
 
514
- // 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.
515
- private async copySourceFiles(sourceIndex: number): Promise<void> {
529
+ // 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.
530
+ private copySourceFiles = cache((sourceIndex: number) => runInSerial(async (): Promise<void> => {
516
531
  if (sourceIndex === 0) return;
517
532
  let { source } = this.store.sources[sourceIndex];
518
533
  let state = this.states[sourceIndex];
@@ -562,6 +577,23 @@ export class StoreSync {
562
577
  let copiedBytes = 0;
563
578
  let missingOnSource = 0;
564
579
  let aborted = false;
580
+ // The source cannot give us the file (its read had nothing, or it errored). Retrying the same entry forever is not an answer: if our own disk holds a copy, IT becomes the holder; with no local copy either, the entry is purged - forgotten, not deleted, exactly like a read that comes up empty everywhere - and the next full pull re-finds it if it exists anywhere.
581
+ let resolveUnavailable = async (event: string, key: string, entry: IndexEntry, extra: { [key: string]: unknown }) => {
582
+ let base = { event, store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, ...extra };
583
+ let local = await this.store.sources[0].source.getInfo(key);
584
+ 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 });
587
+ return;
588
+ }
589
+ if (this.store.currentWriteTime(key) <= entry.writeTime) {
590
+ this.store.purgeIndexEntry(key);
591
+ logSyncEvent({ ...base, resolution: "purged - neither the source nor our disk has it, so it does not exist" });
592
+ return;
593
+ }
594
+ // A newer write landed while we were checking - the entry is no longer the one we found unavailable, so it is left alone
595
+ logSyncEvent({ ...base, resolution: "kept - a newer write appeared while checking", currentWriteTime: this.store.currentWriteTime(key) });
596
+ };
565
597
  try {
566
598
  let nextIndex = 0;
567
599
  let consecutiveFailures = 0;
@@ -571,7 +603,7 @@ export class StoreSync {
571
603
  if (index >= pending.length) return;
572
604
  let { key, entry } = pending[index];
573
605
  try {
574
- let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
606
+ let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
575
607
  if (copied) {
576
608
  copiedFiles++;
577
609
  copiedBytes += copied.size;
@@ -582,25 +614,18 @@ export class StoreSync {
582
614
  }
583
615
  } else {
584
616
  missingOnSource++;
585
- // The source's listing vouched for the file but its read has nothing - and asking again forever is not an answer. If our own disk holds a copy, IT is the real holder, so the entry repoints there; if neither side has bytes, the entry was simply wrong, and it is purged - forgotten, not deleted, exactly like a read that comes up empty everywhere (a scan re-finds the file if it exists anywhere else).
586
- let local = await this.store.sources[0].source.getInfo(key);
587
- if (local) {
588
- this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
589
- logSyncEvent({ event: "fullSyncMissingOnSource", store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, resolution: "repointed to our local copy", localSize: local.size });
590
- } else if (this.store.currentWriteTime(key) <= entry.writeTime) {
591
- this.store.purgeIndexEntry(key);
592
- logSyncEvent({ event: "fullSyncMissingOnSource", store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, resolution: "purged - neither the source nor our disk has it, so it does not exist" });
593
- } else {
594
- // A newer write landed while we were checking - the entry is no longer the one we found missing, so it is left alone
595
- logSyncEvent({ event: "fullSyncMissingOnSource", store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, resolution: "kept - a newer write appeared while checking", currentWriteTime: this.store.currentWriteTime(key) });
596
- }
617
+ await resolveUnavailable("fullSyncMissingOnSource", key, entry, {});
597
618
  }
598
619
  consecutiveFailures = 0;
599
620
  } catch (e) {
600
- // One failing file must not abort the sync: it stays on its source (reads still reach it there), and the next sync pass retries it
621
+ // A failing file resolves exactly like a missing one (the source cannot give it to us either way), so it never wedges the pass on retries forever - and the next full pull re-finds it if the source recovers
601
622
  failed++;
602
623
  consecutiveFailures++;
603
- logSyncEvent({ event: "fullSyncCopyFailed", store: this.store.folder, source: source.getDebugName(), path: key, size: entry.size, error: String((e as Error).stack ?? e).slice(0, 2000) });
624
+ try {
625
+ await resolveUnavailable("fullSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
626
+ } 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) });
628
+ }
604
629
  if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
605
630
  aborted = true;
606
631
  return;
@@ -627,7 +652,7 @@ export class StoreSync {
627
652
  }
628
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 });
629
654
  }
630
- }
655
+ }));
631
656
 
632
657
  // ── maintenance ──
633
658