sliftutils 1.7.55 → 1.7.57

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.
@@ -126,6 +126,8 @@ export type BlobSourceSpec = {
126
126
  validWindow: [number, number];
127
127
  route?: [number, number];
128
128
  noFullSync?: boolean;
129
+ // See ArchivesSource.intermediate
130
+ intermediate?: boolean;
129
131
  // Only called for sources that don't match an existing live slot
130
132
  create: () => IArchives;
131
133
  };
@@ -138,7 +140,7 @@ function newScanTally(): ScanTally {
138
140
  }
139
141
  function formatScanTally(tally: ScanTally, total: number): string {
140
142
  let pct = (n: number) => `${Math.round(n / Math.max(total, 1) * 1000) / 10}%`;
141
- return `${tally.new} new paths (${pct(tally.new)}), ${tally.updated} newer writes (${pct(tally.updated)}), ${tally.tombstone} deletions (${pct(tally.tombstone)}), ${tally.unchanged} unchanged (${pct(tally.unchanged)}), ${tally.filtered} outside window/route (${pct(tally.filtered)})`;
143
+ return `${tally.new} new paths (${pct(tally.new)}), ${tally.updated} newer writes (${pct(tally.updated)}), ${tally.tombstone} deletions (${pct(tally.tombstone)}), ${tally.unchanged} unchanged (${pct(tally.unchanged)}), ${tally.filtered} outside route (${pct(tally.filtered)})`;
142
144
  }
143
145
 
144
146
  function newSourceState(): SourceState {
@@ -359,7 +361,7 @@ export class BlobStore implements IBucketStore {
359
361
  continue;
360
362
  }
361
363
  let source = spec.create();
362
- this.sources.push({ source, url: spec.url, validWindow: spec.validWindow, route: spec.route, noFullSync: spec.noFullSync, identity: spec.identity });
364
+ this.sources.push({ source, url: spec.url, validWindow: spec.validWindow, route: spec.route, noFullSync: spec.noFullSync, intermediate: spec.intermediate, identity: spec.identity });
363
365
  this.sourceStates.push(newSourceState());
364
366
  this.sourceFileCounts.push(0);
365
367
  this.sourceByteCounts.push(0);
@@ -525,14 +527,23 @@ export class BlobStore implements IBucketStore {
525
527
  let state = this.sourceStates[sourceIndex];
526
528
  // Read live for every pass, not captured - updateSources can change it while loops run
527
529
  let noFullSync = () => this.sources[sourceIndex].noFullSync;
530
+ let intermediate = this.sources[sourceIndex].intermediate;
528
531
  let listing: Map<string, number> | undefined;
529
532
  while (!this.stopped.stop && !state.stopped.stop) {
533
+ if (this.isDeadIntermediate(sourceIndex)) {
534
+ console.log(`Not scanning sync source ${source.getDebugName()} (store ${this.folder}): it is an intermediate whose window already ended`);
535
+ break;
536
+ }
530
537
  try {
531
538
  let config = await source.getConfig();
532
539
  state.supportsChangesAfter = !!config.supportsChangesAfter;
533
540
  listing = await this.scanSource(sourceIndex);
534
541
  break;
535
542
  } catch (e) {
543
+ if (intermediate) {
544
+ console.error(`Initial scan of intermediate sync source ${source.getDebugName()} (store ${this.folder}) failed; not retrying (intermediates are temporary switchover ports): ${(e as Error).stack ?? e}`);
545
+ break;
546
+ }
536
547
  console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
537
548
  await delay(SCAN_RETRY_DELAY);
538
549
  }
@@ -540,6 +551,8 @@ export class BlobStore implements IBucketStore {
540
551
  state.scanComplete = true;
541
552
  state.initialScan.resolve(undefined);
542
553
  if (this.stopped.stop || state.stopped.stop) return;
554
+ // An intermediate that never produced a listing is dead or dying - polling and copying from it would only log errors
555
+ if (intermediate && !listing) return;
543
556
  if (listing) {
544
557
  await this.reconcileSource(sourceIndex, listing);
545
558
  }
@@ -552,16 +565,19 @@ export class BlobStore implements IBucketStore {
552
565
  }
553
566
  if (state.supportsChangesAfter) {
554
567
  runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
568
+ if (this.isDeadIntermediate(sourceIndex)) return;
555
569
  await this.pollChanges(sourceIndex);
556
570
  if (!noFullSync()) await this.copySourceFiles(sourceIndex);
557
571
  }, state.stopped);
558
572
  // 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)
559
573
  runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
574
+ if (this.isDeadIntermediate(sourceIndex)) return;
560
575
  let files = await source.findInfo("");
561
576
  await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
562
577
  }, state.stopped);
563
578
  } else {
564
579
  runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
580
+ if (this.isDeadIntermediate(sourceIndex)) return;
565
581
  let rescan = await this.scanSource(sourceIndex);
566
582
  await this.reconcileSource(sourceIndex, rescan);
567
583
  if (!noFullSync()) await this.copySourceFiles(sourceIndex);
@@ -569,9 +585,15 @@ export class BlobStore implements IBucketStore {
569
585
  }
570
586
  }
571
587
 
588
+ // An intermediate is a deploy switchover's temporary alternate port: once its window is past, the port is gone for good, so scanning it (or retrying a failed scan) can never succeed - it would just log errors forever
589
+ private isDeadIntermediate(sourceIndex: number): boolean {
590
+ let { intermediate, validWindow } = this.sources[sourceIndex];
591
+ return !!intermediate && validWindow[1] <= Date.now();
592
+ }
593
+
572
594
  // 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.
573
595
  private async scanSource(sourceIndex: number): Promise<Map<string, number>> {
574
- let { source, validWindow, route } = this.sources[sourceIndex];
596
+ let { source, route } = this.sources[sourceIndex];
575
597
  let state = this.sourceStates[sourceIndex];
576
598
  let scanStart = Date.now();
577
599
  let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
@@ -614,9 +636,8 @@ export class BlobStore implements IBucketStore {
614
636
  removedFromIndex++;
615
637
  continue;
616
638
  }
617
- // Counted only when the source SHOULD hold the entry (its window/route match) - these are what the reconcile pass pushes to it
639
+ // Counted only when the source SHOULD hold the entry (its route matches) - these are what the reconcile pass pushes to it (which also ignores the valid window: synchronization moves existing values, the window only routes fresh writes)
618
640
  if (entry.size === 0 || key === ROUTING_FILE) continue;
619
- if (entry.writeTime < validWindow[0] || entry.writeTime > validWindow[1]) continue;
620
641
  if (!routeContains(route, getRoute(key))) continue;
621
642
  missingOnSource++;
622
643
  }
@@ -689,10 +710,8 @@ export class BlobStore implements IBucketStore {
689
710
  // The routing config is NEVER pulled from other sources - it only ever arrives as an explicit, version-validated write, and is only ever read off our own disk. Route and valid-window filters can't possibly apply to it either: it is the file DEFINING them, so filtering it would mean certain sources could never have their routing config updated, ever.
690
711
  if (sourceIndex !== 0) return "filtered";
691
712
  } else {
692
- let { validWindow, route } = this.sources[sourceIndex];
693
- let [windowStart, windowEnd] = validWindow;
694
- if (file.createTime < windowStart || file.createTime > windowEnd) return "filtered";
695
- // A partially-overlapping shard's listing includes keys outside our route; ignore them
713
+ // The valid window is deliberately NOT applied here: it decides where WRITES route, but a scan is us asking a source what it already holds - existing values synchronize regardless of the window (the same reasoning that lets synchronization ignore the immutable flag). Only the route filters: a partially-overlapping shard's listing legitimately includes keys that aren't ours.
714
+ let { route } = this.sources[sourceIndex];
696
715
  if (!routeContains(route, getRoute(file.path))) return "filtered";
697
716
  }
698
717
  let existing = this.mem.get(file.path);
@@ -86,7 +86,7 @@ export declare class ArchivesChain implements IArchives {
86
86
  getNextData(): Promise<Buffer | undefined>;
87
87
  }): Promise<void>;
88
88
  getURL(path: string): Promise<string>;
89
- /** Every URL that could serve this path, in source order. Empty when no public source covers the path's route. */
89
+ /** Every URL that could serve this path: public sources matching both the path's route and the current valid window. The first is the write node's (first matching source in config order, see runWrite - the one guaranteed current); the rest are ranked fastest-first by measured latency. Empty when none qualify. */
90
90
  getURLs(path: string): Promise<string[]>;
91
91
  dispose(): void;
92
92
  }
@@ -541,6 +541,8 @@ export class ArchivesChain implements IArchives {
541
541
  public async waitingForAccess(): Promise<{ link: string; machineId: string; ip: string } | undefined> {
542
542
  let state = await this.getState();
543
543
  for (let source of state.sources) {
544
+ // A source whose window has passed is never read or written again, so its access state is irrelevant - and asking a dead intermediate would just hang or throw. Future windows DO matter: access should be granted before their window starts.
545
+ if (source.config.validWindow[1] <= Date.now()) continue;
544
546
  if (source.api instanceof ArchivesRemote) {
545
547
  let waiting = await source.api.waitingForAccess();
546
548
  if (waiting) return waiting;
@@ -800,19 +802,22 @@ export class ArchivesChain implements IArchives {
800
802
  return urls[0];
801
803
  }
802
804
 
803
- /** Every URL that could serve this path, in source order. Empty when no public source covers the path's route. */
805
+ /** Every URL that could serve this path: public sources matching both the path's route and the current valid window. The first is the write node's (first matching source in config order, see runWrite - the one guaranteed current); the rest are ranked fastest-first by measured latency. Empty when none qualify. */
804
806
  public async getURLs(path: string): Promise<string[]> {
805
807
  let state = await this.getState();
806
808
  let route = getRoute(path);
807
- let providers: IArchives[] = [];
809
+ let candidates: { source: SourceWrapper; provider: IArchives }[] = [];
808
810
  for (let source of state.sources) {
809
811
  if (!(source.config.public ?? true)) continue;
810
812
  if (!routeContains(source.config.route, route)) continue;
813
+ if (!configWindowCurrent(source.config)) continue;
811
814
  let provider = source.url || source.api;
812
815
  if (!provider) continue;
813
- providers.push(provider);
816
+ candidates.push({ source, provider });
814
817
  }
815
- let urls = await Promise.all(providers.map(provider => provider.getURL(path)));
818
+ let rest = candidates.slice(1);
819
+ sort(rest, x => x.source.getLatency());
820
+ let urls = await Promise.all([...candidates.slice(0, 1), ...rest].map(x => x.provider.getURL(path)));
816
821
  return [...new Set(urls)];
817
822
  }
818
823