sliftutils 1.7.106 → 1.7.107

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.
@@ -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 {
@@ -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
47
  private runSourceSync;
46
- private scanSource;
47
- private reconcileSource;
48
+ private pullSource;
49
+ private pushSource;
48
50
  private updateScanIndex;
49
51
  private pollChanges;
50
52
  private copySourceFiles;
@@ -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 },
@@ -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. */
@@ -272,7 +286,6 @@ export class StoreSync {
272
286
  logSyncEvent({ event: "sourceSyncStart", store: this.store.folder, source: source.getDebugName(), sourceIndex, url: this.store.sources[sourceIndex].url, intermediate: this.store.sources[sourceIndex].intermediate });
273
287
  // Read live for every pass, not captured - the store's source list can change while loops run
274
288
  let noFullSync = () => this.store.sources[sourceIndex].noFullSync;
275
- let listing: Map<string, number> | undefined;
276
289
  // 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
290
  if (this.store.sources[sourceIndex].intermediate) {
278
291
  state.scanComplete = true;
@@ -283,19 +296,17 @@ export class StoreSync {
283
296
  try {
284
297
  let config = await source.getConfig();
285
298
  state.supportsChangesAfter = !!config.supportsChangesAfter;
286
- listing = await this.scanSource(sourceIndex);
299
+ await this.syncSource(sourceIndex, "push");
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
  }
306
+ // The loop can also exit by the stop tokens, and nothing may wait forever on a stopped source's initial scan
293
307
  state.scanComplete = true;
294
308
  state.initialScan.resolve(undefined);
295
309
  if (this.store.stopped.stop || state.stopped.stop) return;
296
- if (listing) {
297
- await this.reconcileSource(sourceIndex, listing);
298
- }
299
310
  if (!noFullSync()) {
300
311
  try {
301
312
  await this.copySourceFiles(sourceIndex);
@@ -305,25 +316,22 @@ export class StoreSync {
305
316
  }
306
317
  if (state.supportsChangesAfter) {
307
318
  runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
319
+ // 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
+ if (!state.scanSucceeded) {
321
+ await this.syncSource(sourceIndex, "push");
322
+ }
308
323
  await this.pollChanges(sourceIndex);
309
324
  if (!noFullSync()) await this.copySourceFiles(sourceIndex);
310
325
  }, 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
326
  }
327
+ runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
328
+ await this.syncSource(sourceIndex, "push");
329
+ if (!noFullSync()) await this.copySourceFiles(sourceIndex);
330
+ }, state.stopped);
323
331
  }
324
332
 
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>> {
333
+ // 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.
334
+ private async pullSource(sourceIndex: number): Promise<Map<string, number>> {
327
335
  let { source, route } = this.store.sources[sourceIndex];
328
336
  let state = this.states[sourceIndex];
329
337
  let scanStart = Date.now();
@@ -393,11 +401,12 @@ export class StoreSync {
393
401
  }
394
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 });
395
403
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
404
+ state.scanSucceeded = true;
396
405
  return seen;
397
406
  }
398
407
 
399
408
  // 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> {
409
+ private async pushSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
401
410
  let { source, validWindows, route } = this.store.sources[sourceIndex];
402
411
  let state = this.states[sourceIndex];
403
412
  let acceptsWrites = windowsAcceptWrites(validWindows);
@@ -458,9 +467,7 @@ export class StoreSync {
458
467
  if (failed) {
459
468
  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
469
  }
461
- if (pushed || failed) {
462
- logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
463
- }
470
+ logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
464
471
  }
465
472
 
466
473
  private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
@@ -558,7 +565,6 @@ export class StoreSync {
558
565
  try {
559
566
  let nextIndex = 0;
560
567
  let consecutiveFailures = 0;
561
- let errors: string[] = [];
562
568
  let copyWorker = async () => {
563
569
  while (!aborted && !this.store.stopped.stop && !state.stopped.stop) {
564
570
  let index = nextIndex++;
@@ -570,22 +576,31 @@ export class StoreSync {
570
576
  copiedFiles++;
571
577
  copiedBytes += copied.size;
572
578
  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
579
+ // 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
580
  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`);
581
+ logSyncEvent({ event: "fullSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
576
582
  }
577
583
  } else {
578
584
  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()})`);
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
+ }
580
597
  }
581
598
  consecutiveFailures = 0;
582
599
  } catch (e) {
583
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
584
601
  failed++;
585
602
  consecutiveFailures++;
586
- if (errors.length < SYNC_ERROR_LOG_LIMIT) {
587
- errors.push(`${key}: ${(e as Error).stack ?? e}`);
588
- }
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) });
589
604
  if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
590
605
  aborted = true;
591
606
  return;
@@ -602,9 +617,6 @@ export class StoreSync {
602
617
  workers.push(copyWorker());
603
618
  }
604
619
  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
620
  } finally {
609
621
  clearInterval(progressTimer);
610
622
  clearInterval(slowErrorTimer);