sliftutils 1.7.106 → 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;
@@ -9,7 +9,10 @@ import { getOwnThreadId } from "../../misc/https/certs";
9
9
 
10
10
  export const LOGS_FOLDER_NAME = "logs";
11
11
 
12
- const getLogs = lazy((): StreamingLogs | undefined => {
12
+ // NOT lazy: a failure to resolve the folder (the server config may simply not be SET yet this early in startup) must be retried on the next log call, not cached as "no logs ever"
13
+ let logsInstance: StreamingLogs | undefined;
14
+ function getLogs(): StreamingLogs | undefined {
15
+ if (logsInstance) return logsInstance;
13
16
  let folder: string;
14
17
  try {
15
18
  folder = path.join(getStorageFolder(), LOGS_FOLDER_NAME);
@@ -17,8 +20,9 @@ const getLogs = lazy((): StreamingLogs | undefined => {
17
20
  return undefined;
18
21
  }
19
22
  hookErrorLogging();
20
- return new StreamingLogs({ folder, threadId: getThreadIdSafe() });
21
- });
23
+ logsInstance = new StreamingLogs({ folder, threadId: getThreadIdSafe() });
24
+ return logsInstance;
25
+ }
22
26
 
23
27
  function firstExternalIPv4(): string | undefined {
24
28
  for (let addresses of Object.values(os.networkInterfaces())) {
@@ -29,22 +33,28 @@ function firstExternalIPv4(): string | undefined {
29
33
  return undefined;
30
34
  }
31
35
 
36
+ // Memoized on SUCCESS only: the thread cert may not be loaded yet when the first entries are written, and that must not permanently strip the thread id from every later entry
37
+ let cachedThreadId: string | undefined;
32
38
  function getThreadIdSafe(): string | undefined {
39
+ if (cachedThreadId) return cachedThreadId;
33
40
  try {
34
- return getOwnThreadId(getStorageServerConfigOptional()?.rootDomain || "");
35
- } catch {
36
- return undefined;
37
- }
41
+ cachedThreadId = getOwnThreadId(getStorageServerConfigOptional()?.rootDomain || "");
42
+ } catch { }
43
+ return cachedThreadId;
38
44
  }
39
45
 
40
- // Computed once: none of it changes over a process's lifetime
41
- const baseFields = lazy(() => ({
42
- pid: process.pid,
43
- threadId: getThreadIdSafe(),
44
- entryPoint: process.argv[1],
45
- ip: firstExternalIPv4(),
46
- domain: getStorageServerConfigOptional()?.domain,
47
- }));
46
+ const cachedIp = lazy(() => firstExternalIPv4());
47
+
48
+ // threadId and domain are re-resolved until known (early entries may predate the config/certs); the rest never changes
49
+ function baseFields() {
50
+ return {
51
+ pid: process.pid,
52
+ threadId: getThreadIdSafe(),
53
+ entryPoint: process.argv[1],
54
+ ip: cachedIp(),
55
+ domain: getStorageServerConfigOptional()?.domain,
56
+ };
57
+ }
48
58
 
49
59
  // Everything logged here goes BOTH to the storage log stream and the console - one call, never two. The console line carries only the entry (the base fields are constant per process, so they would just be noise there); the stream gets everything.
50
60
  function write(kind: string, entry: { [key: string]: unknown }, alsoConsole: boolean): void {
@@ -56,9 +66,9 @@ function write(kind: string, entry: { [key: string]: unknown }, alsoConsole: boo
56
66
  logs.log({ kind, time: Date.now(), ...baseFields(), ...entry });
57
67
  }
58
68
 
59
- /** 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. */
60
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 {
61
- write("mutation", entry, true);
71
+ write("mutation", entry, false);
62
72
  }
63
73
 
64
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,16 @@ 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. */
41
+ private syncSource;
40
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. */
41
43
  boundaryScanRemote(source: IArchives, config: {
42
44
  since: number;
43
45
  route?: [number, number];
44
46
  }): Promise<void>;
45
- private runSourceSync;
46
- private scanSource;
47
- private reconcileSource;
47
+ private startSourceSyncLoops;
48
+ private pullSource;
49
+ private pushSource;
48
50
  private updateScanIndex;
49
51
  private pollChanges;
50
52
  private copySourceFiles;
@@ -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;
@@ -48,6 +48,8 @@ type SourceState = {
48
48
  supportsChangesAfter: boolean;
49
49
  initialScan: ReturnType<typeof promiseObj>;
50
50
  scanComplete: boolean;
51
+ // Whether a scan of this source has ever actually SUCCEEDED - what full syncs gate on: without one, the index's view of this source can be arbitrarily stale (e.g. it was restarting when we first tried), and syncing from stale information churns forever
52
+ scanSucceeded: boolean;
51
53
  // Files seen in this source's scans / change polls so far
52
54
  scannedCount: number;
53
55
  // Watermark for getChangesAfter2 polls
@@ -63,6 +65,7 @@ function newSourceState(): SourceState {
63
65
  supportsChangesAfter: false,
64
66
  initialScan: promiseObj(),
65
67
  scanComplete: false,
68
+ scanSucceeded: false,
66
69
  scannedCount: 0,
67
70
  changesAfterTime: 0,
68
71
  stopped: { stop: false },
@@ -94,7 +97,7 @@ export class StoreSync {
94
97
  public start(): void {
95
98
  for (let i = 0; i < this.store.sources.length; i++) {
96
99
  if (!this.isLive(i)) continue;
97
- void this.runSourceSync(i);
100
+ void this.startSourceSyncLoops(i);
98
101
  }
99
102
  runInfinitePoll(CONFIG_POLL_INTERVAL, () => this.pollRoutingConfig(), this.store.stopped);
100
103
  runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.store.stopped);
@@ -144,7 +147,7 @@ export class StoreSync {
144
147
  public addSource(slot: number): void {
145
148
  this.states[slot] = newSourceState();
146
149
  if (this.store.syncStarted) {
147
- void this.runSourceSync(slot);
150
+ void this.startSourceSyncLoops(slot);
148
151
  }
149
152
  }
150
153
 
@@ -216,7 +219,18 @@ export class StoreSync {
216
219
 
217
220
  /** 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. */
218
221
  public async rescanBase(): Promise<void> {
219
- await this.scanSource(0);
222
+ await this.syncSource(0);
223
+ }
224
+
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> {
227
+ let listing = await this.pullSource(sourceIndex);
228
+ let state = this.states[sourceIndex];
229
+ state.scanComplete = true;
230
+ state.initialScan.resolve(undefined);
231
+ if (push && !this.store.stopped.stop && !state.stopped.stop) {
232
+ await this.pushSource(sourceIndex, listing);
233
+ }
220
234
  }
221
235
 
222
236
  /** 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. */
@@ -241,7 +255,7 @@ export class StoreSync {
241
255
  tally.tombstone++;
242
256
  continue;
243
257
  }
244
- 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 });
245
259
  if (!copied) {
246
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`);
247
261
  continue;
@@ -264,15 +278,15 @@ export class StoreSync {
264
278
 
265
279
  // ── per-source loops ──
266
280
 
267
- private async runSourceSync(sourceIndex: number): Promise<void> {
281
+ private async startSourceSyncLoops(sourceIndex: number): Promise<void> {
268
282
  await this.store.registerSlot(sourceIndex);
269
- let { source } = this.store.sources[sourceIndex];
283
+ let sourceObj = this.store.sources[sourceIndex];
284
+ let source = sourceObj.source;
270
285
  let state = this.states[sourceIndex];
271
286
  // If a slot ever gets TWO of these, its loops are doubled - every poll and full sync runs twice
272
287
  logSyncEvent({ event: "sourceSyncStart", store: this.store.folder, source: source.getDebugName(), sourceIndex, url: this.store.sources[sourceIndex].url, intermediate: this.store.sources[sourceIndex].intermediate });
273
288
  // Read live for every pass, not captured - the store's source list can change while loops run
274
289
  let noFullSync = () => this.store.sources[sourceIndex].noFullSync;
275
- let listing: Map<string, number> | undefined;
276
290
  // An intermediate is a deploy switchover's temporary alternate PORT onto a source we already have: the same bucket, reachable another way for a few minutes. Scanning it would list exactly what scanning that source lists, against a port that is about to disappear - so it is never scanned, and the source it was split out of covers it for as long as it exists and after it is gone.
277
291
  if (this.store.sources[sourceIndex].intermediate) {
278
292
  state.scanComplete = true;
@@ -283,47 +297,43 @@ export class StoreSync {
283
297
  try {
284
298
  let config = await source.getConfig();
285
299
  state.supportsChangesAfter = !!config.supportsChangesAfter;
286
- listing = await this.scanSource(sourceIndex);
287
300
  break;
288
301
  } catch (e) {
289
- console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
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) });
290
303
  await delay(SCAN_RETRY_DELAY);
291
304
  }
292
305
  }
293
- state.scanComplete = true;
294
- state.initialScan.resolve(undefined);
295
306
  if (this.store.stopped.stop || state.stopped.stop) return;
296
- if (listing) {
297
- await this.reconcileSource(sourceIndex, listing);
298
- }
299
- if (!noFullSync()) {
300
- try {
301
- await this.copySourceFiles(sourceIndex);
302
- } catch (e) {
303
- 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;
304
321
  }
305
- }
322
+ }, state.stopped);
306
323
  if (state.supportsChangesAfter) {
307
324
  runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
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
326
+ if (!state.scanSucceeded) {
327
+ await this.syncSource(sourceIndex, "push");
328
+ }
308
329
  await this.pollChanges(sourceIndex);
309
330
  if (!noFullSync()) await this.copySourceFiles(sourceIndex);
310
331
  }, state.stopped);
311
- // Change polls only show what the source HAS, never what it's missing, so pushes run on the full-rescan cadence (findInfo on an index-backed source is cheap)
312
- runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
313
- let files = await source.findInfo("");
314
- await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
315
- }, state.stopped);
316
- } else {
317
- runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
318
- let rescan = await this.scanSource(sourceIndex);
319
- await this.reconcileSource(sourceIndex, rescan);
320
- if (!noFullSync()) await this.copySourceFiles(sourceIndex);
321
- }, state.stopped);
322
332
  }
323
333
  }
324
334
 
325
- // Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the source's listing (path -> write time), which reconcileSource uses for the push direction.
326
- private async scanSource(sourceIndex: number): Promise<Map<string, number>> {
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.
336
+ private async pullSource(sourceIndex: number): Promise<Map<string, number>> {
327
337
  let { source, route } = this.store.sources[sourceIndex];
328
338
  let state = this.states[sourceIndex];
329
339
  let scanStart = Date.now();
@@ -375,13 +385,22 @@ export class StoreSync {
375
385
  tally[this.updateScanIndex(sourceIndex, file)]++;
376
386
  }
377
387
  state.scannedCount = files.length;
378
- // 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.
379
389
  let removedFromIndex = 0;
390
+ let repointedToLocal = 0;
380
391
  let missingOnSource = 0;
381
392
  let scannedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
382
393
  for (let [key, entry] of this.store.indexEntries()) {
383
394
  if (seen.has(key)) continue;
384
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
+ }
385
404
  this.store.purgeIndexEntry(key);
386
405
  removedFromIndex++;
387
406
  continue;
@@ -391,13 +410,14 @@ export class StoreSync {
391
410
  if (!routeContains(route, getRoute(key))) continue;
392
411
  missingOnSource++;
393
412
  }
394
- 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 });
395
414
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
415
+ state.scanSucceeded = true;
396
416
  return seen;
397
417
  }
398
418
 
399
419
  // The push direction of synchronization: everything we know that the source is missing (or holds an older copy of) is written to it — including deletions, as tombstone writes. This is what heals a source whose background writes failed (e.g. it was down): the next scan sees what's missing and re-sends it. A failing file is skipped, not fatal (immutable targets are handled by forceSetImmutable, and one unreadable value must not stop the rest of the pass) - only a run of consecutive failures (the source itself is down) aborts until the next scan cycle.
400
- private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
420
+ private async pushSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
401
421
  let { source, validWindows, route } = this.store.sources[sourceIndex];
402
422
  let state = this.states[sourceIndex];
403
423
  let acceptsWrites = windowsAcceptWrites(validWindows);
@@ -436,7 +456,7 @@ export class StoreSync {
436
456
  }
437
457
  let holder = await this.store.getEntryHolder(entry);
438
458
  if (!holder) continue;
439
- 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 });
440
460
  if (!copied) continue;
441
461
  this.store.noteSyncTransfer("sync set", key, copied.size);
442
462
  pushed++;
@@ -458,9 +478,7 @@ export class StoreSync {
458
478
  if (failed) {
459
479
  console.error(`Reconciling sync source ${source.getDebugName()} (store ${this.store.folder}): pushed ${pushed} files, ${failed} failed${aborted && ` before aborting the pass (${consecutiveFailures} consecutive failures - the source looks down; the next scan cycle retries)` || ""}. First errors: ${errors.join(" | ")}`);
460
480
  }
461
- if (pushed || failed) {
462
- logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
463
- }
481
+ logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
464
482
  }
465
483
 
466
484
  private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
@@ -555,36 +573,54 @@ export class StoreSync {
555
573
  let copiedBytes = 0;
556
574
  let missingOnSource = 0;
557
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
+ };
558
593
  try {
559
594
  let nextIndex = 0;
560
595
  let consecutiveFailures = 0;
561
- let errors: string[] = [];
562
596
  let copyWorker = async () => {
563
597
  while (!aborted && !this.store.stopped.stop && !state.stopped.stop) {
564
598
  let index = nextIndex++;
565
599
  if (index >= pending.length) return;
566
600
  let { key, entry } = pending[index];
567
601
  try {
568
- 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 });
569
603
  if (copied) {
570
604
  copiedFiles++;
571
605
  copiedBytes += copied.size;
572
606
  this.store.noteSyncTransfer("sync get", key, copied.size);
573
- // The copy carries the source's write time, and the index commits it under the normal ordering rule (>= the current time wins) - setIndexEntry refusing means a NEWER write landed while we copied, which must be said, not swallowed
607
+ // 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
574
608
  if (!this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) })) {
575
- console.warn(`Full sync copied ${key} from ${source.getDebugName()} (store ${this.store.folder}) with writeTime ${copied.writeTime} (${new Date(copied.writeTime).toISOString()}), but the index now has ${this.store.currentWriteTime(key)} (${new Date(this.store.currentWriteTime(key)).toISOString()}) - something wrote it while we were syncing; keeping the newer entry`);
609
+ logSyncEvent({ event: "fullSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
576
610
  }
577
611
  } else {
578
612
  missingOnSource++;
579
- console.warn(`Full sync could not copy ${key} from ${source.getDebugName()} (store ${this.store.folder}): the source answered but no longer has it (expected ${entry.size} bytes, writeTime ${new Date(entry.writeTime).toISOString()})`);
613
+ await resolveUnavailable("fullSyncMissingOnSource", key, entry, {});
580
614
  }
581
615
  consecutiveFailures = 0;
582
616
  } catch (e) {
583
- // 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
584
618
  failed++;
585
619
  consecutiveFailures++;
586
- if (errors.length < SYNC_ERROR_LOG_LIMIT) {
587
- errors.push(`${key}: ${(e as Error).stack ?? e}`);
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) });
588
624
  }
589
625
  if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
590
626
  aborted = true;
@@ -602,9 +638,6 @@ export class StoreSync {
602
638
  workers.push(copyWorker());
603
639
  }
604
640
  await Promise.all(workers);
605
- if (failed) {
606
- console.error(`Full sync from ${source.getDebugName()} (store ${this.store.folder}): ${failed} of ${pending.length} files failed to copy${aborted && ` before aborting the pass (${SYNC_MAX_CONSECUTIVE_FAILURES} consecutive failures - the source looks down; the next sync pass retries)` || " (they stay on their source, and the next sync pass retries them)"}. First errors: ${errors.join(" | ")}`);
607
- }
608
641
  } finally {
609
642
  clearInterval(progressTimer);
610
643
  clearInterval(slowErrorTimer);