sliftutils 1.7.107 → 1.7.108

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. */
@@ -44,7 +44,7 @@ export declare class StoreSync {
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,4 @@
1
- import { runInfinitePoll, delay } from "socket-function/src/batching";
1
+ import { runInfinitePoll, delay, runInfinitePollCallAtStart } from "socket-function/src/batching";
2
2
  import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
3
3
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
4
4
  import {
@@ -21,7 +21,7 @@ const CHANGES_POLL_INTERVAL = 1000 * 60;
21
21
  const CONFIG_POLL_INTERVAL = 1000 * 60 * 5;
22
22
  // 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
23
  const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
24
- const FULL_RESCAN_UNINDEXED_INTERVAL = 1000 * 60 * 60 * 6;
24
+ const FULL_RESCAN_NON_REMOTE_INTERVAL = 1000 * 60 * 60 * 6;
25
25
  // Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
26
26
  const CHANGES_POLL_OVERLAP = timeInMinute;
27
27
  const SCAN_RETRY_DELAY = 1000 * 30;
@@ -97,7 +97,7 @@ export class StoreSync {
97
97
  public start(): void {
98
98
  for (let i = 0; i < this.store.sources.length; i++) {
99
99
  if (!this.isLive(i)) continue;
100
- void this.runSourceSync(i);
100
+ void this.startSourceSyncLoops(i);
101
101
  }
102
102
  runInfinitePoll(CONFIG_POLL_INTERVAL, () => this.pollRoutingConfig(), this.store.stopped);
103
103
  runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.store.stopped);
@@ -147,7 +147,7 @@ export class StoreSync {
147
147
  public addSource(slot: number): void {
148
148
  this.states[slot] = newSourceState();
149
149
  if (this.store.syncStarted) {
150
- void this.runSourceSync(slot);
150
+ void this.startSourceSyncLoops(slot);
151
151
  }
152
152
  }
153
153
 
@@ -255,7 +255,7 @@ export class StoreSync {
255
255
  tally.tombstone++;
256
256
  continue;
257
257
  }
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 });
258
+ let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, forceSetImmutable: true, noChecks: true, internal: true });
259
259
  if (!copied) {
260
260
  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
261
  continue;
@@ -278,9 +278,10 @@ export class StoreSync {
278
278
 
279
279
  // ── per-source loops ──
280
280
 
281
- private async runSourceSync(sourceIndex: number): Promise<void> {
281
+ private async startSourceSyncLoops(sourceIndex: number): Promise<void> {
282
282
  await this.store.registerSlot(sourceIndex);
283
- let { source } = this.store.sources[sourceIndex];
283
+ let sourceObj = this.store.sources[sourceIndex];
284
+ let source = sourceObj.source;
284
285
  let state = this.states[sourceIndex];
285
286
  // If a slot ever gets TWO of these, its loops are doubled - every poll and full sync runs twice
286
287
  logSyncEvent({ event: "sourceSyncStart", store: this.store.folder, source: source.getDebugName(), sourceIndex, url: this.store.sources[sourceIndex].url, intermediate: this.store.sources[sourceIndex].intermediate });
@@ -296,24 +297,29 @@ export class StoreSync {
296
297
  try {
297
298
  let config = await source.getConfig();
298
299
  state.supportsChangesAfter = !!config.supportsChangesAfter;
299
- await this.syncSource(sourceIndex, "push");
300
300
  break;
301
301
  } catch (e) {
302
302
  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
303
  await delay(SCAN_RETRY_DELAY);
304
304
  }
305
305
  }
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
306
  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);
307
+ let pollInterval = sourceObj.sourceConfig?.type === "remote" ? FULL_RESCAN_INTERVAL : FULL_RESCAN_NON_REMOTE_INTERVAL;
308
+ await runInfinitePollCallAtStart(pollInterval, async () => {
309
+ while (!this.store.stopped.stop && !state.stopped.stop) {
310
+ try {
311
+ await this.syncSource(sourceIndex, "push");
312
+ if (!noFullSync()) await this.copySourceFiles(sourceIndex);
313
+ } catch (e) {
314
+ 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
+ await delay(SCAN_RETRY_DELAY);
316
+ continue;
317
+ }
318
+ state.scanComplete = true;
319
+ state.initialScan.resolve(undefined);
320
+ break;
315
321
  }
316
- }
322
+ }, state.stopped);
317
323
  if (state.supportsChangesAfter) {
318
324
  runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
319
325
  // 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
@@ -324,10 +330,6 @@ export class StoreSync {
324
330
  if (!noFullSync()) await this.copySourceFiles(sourceIndex);
325
331
  }, state.stopped);
326
332
  }
327
- runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
328
- await this.syncSource(sourceIndex, "push");
329
- if (!noFullSync()) await this.copySourceFiles(sourceIndex);
330
- }, state.stopped);
331
333
  }
332
334
 
333
335
  // 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 +385,22 @@ export class StoreSync {
383
385
  tally[this.updateScanIndex(sourceIndex, file)]++;
384
386
  }
385
387
  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.
388
+ // 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
389
  let removedFromIndex = 0;
390
+ let repointedToLocal = 0;
388
391
  let missingOnSource = 0;
389
392
  let scannedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
390
393
  for (let [key, entry] of this.store.indexEntries()) {
391
394
  if (seen.has(key)) continue;
392
395
  if (entry.sourcesListIndex === scannedSourcesListIndex && entry.changedAt < scanStart) {
396
+ if (sourceIndex !== 0) {
397
+ let local = await this.store.sources[0].source.getInfo(key);
398
+ if (local) {
399
+ this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
400
+ repointedToLocal++;
401
+ continue;
402
+ }
403
+ }
393
404
  this.store.purgeIndexEntry(key);
394
405
  removedFromIndex++;
395
406
  continue;
@@ -399,7 +410,7 @@ export class StoreSync {
399
410
  if (!routeContains(route, getRoute(key))) continue;
400
411
  missingOnSource++;
401
412
  }
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 });
413
+ 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
414
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
404
415
  state.scanSucceeded = true;
405
416
  return seen;
@@ -445,7 +456,7 @@ export class StoreSync {
445
456
  }
446
457
  let holder = await this.store.getEntryHolder(entry);
447
458
  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 });
459
+ let copied = await copyArchiveFile({ from: holder, to: source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
449
460
  if (!copied) continue;
450
461
  this.store.noteSyncTransfer("sync set", key, copied.size);
451
462
  pushed++;
@@ -562,6 +573,23 @@ export class StoreSync {
562
573
  let copiedBytes = 0;
563
574
  let missingOnSource = 0;
564
575
  let aborted = false;
576
+ // 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.
577
+ let resolveUnavailable = async (event: string, key: string, entry: IndexEntry, extra: { [key: string]: unknown }) => {
578
+ let base = { event, store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, ...extra };
579
+ let local = await this.store.sources[0].source.getInfo(key);
580
+ 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 });
583
+ return;
584
+ }
585
+ if (this.store.currentWriteTime(key) <= entry.writeTime) {
586
+ this.store.purgeIndexEntry(key);
587
+ logSyncEvent({ ...base, resolution: "purged - neither the source nor our disk has it, so it does not exist" });
588
+ return;
589
+ }
590
+ // A newer write landed while we were checking - the entry is no longer the one we found unavailable, so it is left alone
591
+ logSyncEvent({ ...base, resolution: "kept - a newer write appeared while checking", currentWriteTime: this.store.currentWriteTime(key) });
592
+ };
565
593
  try {
566
594
  let nextIndex = 0;
567
595
  let consecutiveFailures = 0;
@@ -571,7 +599,7 @@ export class StoreSync {
571
599
  if (index >= pending.length) return;
572
600
  let { key, entry } = pending[index];
573
601
  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 });
602
+ let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
575
603
  if (copied) {
576
604
  copiedFiles++;
577
605
  copiedBytes += copied.size;
@@ -582,25 +610,18 @@ export class StoreSync {
582
610
  }
583
611
  } else {
584
612
  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
- }
613
+ await resolveUnavailable("fullSyncMissingOnSource", key, entry, {});
597
614
  }
598
615
  consecutiveFailures = 0;
599
616
  } 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
617
+ // 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
618
  failed++;
602
619
  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) });
620
+ try {
621
+ await resolveUnavailable("fullSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
622
+ } 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) });
624
+ }
604
625
  if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
605
626
  aborted = true;
606
627
  return;