sliftutils 1.7.95 → 1.7.96

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.
@@ -1,4 +1,4 @@
1
- import { isNode, sort, timeoutToUndefinedSilent, watchSlowPromise } from "socket-function/src/misc";
1
+ import { sort, watchSlowPromise } from "socket-function/src/misc";
2
2
  import { delay } from "socket-function/src/batching";
3
3
  import {
4
4
  IArchives, RemoteConfig, RemoteConfigBase, SourceConfig,
@@ -7,27 +7,22 @@ import {
7
7
  } from "../IArchives";
8
8
  import { copyArchiveFile } from "../archiveHelpers";
9
9
  import {
10
- ROUTING_FILE, getConfigVersion, parseHostedUrl, parseBackblazeUrl, parseRoutingData, assertValidRemoteConfig,
11
- normalizeRemoteConfig, normalizeSource, serializeRemoteConfig,
10
+ ROUTING_FILE, parseHostedUrl, parseBackblazeUrl, parseRoutingData, assertValidRemoteConfig,
11
+ normalizeRemoteConfig, normalizeSource,
12
12
  getRoute, routeContains, parseVariableRoute, getBucketBaseUrl, buildFileUrl,
13
13
  } from "./remoteConfig";
14
14
  import { ArchivesUrl } from "./ArchivesUrl";
15
- import { resolveIntermediateSources } from "./intermediateSources";
16
15
  import { SocketFunction } from "socket-function/SocketFunction";
17
16
  import { ArchivesRemote, parseStorageUrl, authenticateStorage } from "./ArchivesRemote";
18
- import { onServerRoutingChanged } from "./storageClientController";
19
- import { ArchivesBackblaze } from "../backblaze";
20
- import { ArchivesDisk } from "../ArchivesDisk";
21
17
  import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
22
18
  import { RemoteStorageController, STORAGE_NOT_AUTHENTICATED } from "./storageController";
23
- import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
19
+ import { SourceWrapper } from "./sourceWrapper";
20
+ import { ChainState, ChainStateManager } from "./chainStartup";
24
21
  import { formatTime } from "socket-function/src/formatting/format";
25
22
 
26
- const CONFIG_POLL_INTERVAL = 5 * 60 * 1000;
27
23
  const WRONG_TARGET_BOUNDARY_WINDOW = 30 * 1000;
28
24
  const WRONG_TARGET_BOUNDARY_RETRY_DELAY = 15 * 1000;
29
25
  const CONFIG_REFRESH_THROTTLE = 30 * 1000;
30
- const AVAILABILITY_RECHECK_THROTTLE = 5 * 1000;
31
26
  const PRIMARY_RETRY_TIMEOUT = 30 * 1000;
32
27
  const PRIMARY_RETRY_DELAY = 2 * 1000;
33
28
  const COVERING_RETRY_TIMEOUT = 30 * 1000;
@@ -54,11 +49,6 @@ export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteCon
54
49
  /** A client for ONE source - see storeSources.ts. Re-exported here because a chain is built out of them. */
55
50
  export { createApiArchives } from "./storeSources";
56
51
 
57
- type ChainState = {
58
- config: RemoteConfig;
59
- sources: SourceWrapper[];
60
- };
61
-
62
52
  export type ArchivesChainOptions = {
63
53
  /** Outside of node we default to read-only downloads over the public URLs (no API connection) when the config has public sources. Set this to connect to the API anyway - needed for writing, listing, and any other operation the plain URL form cannot serve. */
64
54
  directConnect?: boolean;
@@ -96,306 +86,22 @@ function preferUsable(sources: SourceWrapper[]): SourceWrapper[] {
96
86
  }
97
87
 
98
88
  export class ArchivesChain implements IArchives {
99
- private configured: RemoteConfig;
100
- private activeConfig: RemoteConfig;
101
- private statePromise: Promise<ChainState> | undefined;
102
- // The resolved state, for synchronous access (getGetURLs) - always the newest adopted config, where statePromise may briefly lag during a rebuild
103
- private latestState: ChainState | undefined;
104
- private initRetryDelay = RETRY_START_DELAY;
105
- private initRetryTimer: ReturnType<typeof setTimeout> | undefined;
106
- private pollTimer: ReturnType<typeof setInterval> | undefined;
107
- private disposed = false;
108
- private unsubscribeRoutingPush: (() => void) | undefined;
109
-
110
- constructor(config: RemoteConfig | RemoteConfigBase, private options?: ArchivesChainOptions) {
111
- this.configured = normalizeRemoteConfig(config);
112
- this.activeConfig = this.configured;
113
- this.unsubscribeRoutingPush = onServerRoutingChanged(() => {
114
- void this.refreshActiveConfig().catch((e: Error) => console.error(`Config refresh failed for ${this.getDebugName()}: ${e.stack ?? e}`));
89
+ // The chain's state, and everything about having one (init + its retry, config polling/adoption, availability rechecks, the config rewrite loop), lives in the manager - what stays in this class is dispatch over that state
90
+ private state: ChainStateManager;
91
+
92
+ constructor(config: RemoteConfig | RemoteConfigBase, options?: ArchivesChainOptions) {
93
+ this.state = new ChainStateManager({
94
+ configured: normalizeRemoteConfig(config),
95
+ debugName: () => this.getDebugName(),
96
+ directConnect: options?.directConnect,
115
97
  });
116
98
  }
117
99
 
118
100
  public getDebugName() {
119
- let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as SourceConfig).url);
101
+ let urls = this.state.activeConfig.sources.map(x => typeof x === "string" && x || (x as SourceConfig).url);
120
102
  return `chain ${urls.join(", ")}`;
121
103
  }
122
104
 
123
- private getState(): Promise<ChainState> {
124
- if (this.disposed) {
125
- return Promise.reject(new Error(`ArchivesChain ${this.getDebugName()} has been disposed`));
126
- }
127
- if (!this.statePromise) {
128
- let promise = this.init();
129
- this.statePromise = promise;
130
- promise.then(() => {
131
- this.initRetryDelay = RETRY_START_DELAY;
132
- }, (e: Error) => {
133
- if (this.disposed || this.initRetryTimer) return;
134
- console.error(`Storage init failed for ${this.getDebugName()}, retrying in ${Math.round(this.initRetryDelay / 1000)}s. ${e.stack ?? e}`);
135
- this.initRetryTimer = setTimeout(() => {
136
- this.initRetryTimer = undefined;
137
- if (this.disposed) return;
138
- if (this.statePromise === promise) {
139
- this.statePromise = undefined;
140
- }
141
- this.getState().catch(() => { });
142
- }, this.initRetryDelay);
143
- (this.initRetryTimer as { unref?: () => void }).unref?.();
144
- this.initRetryDelay = Math.min(RETRY_MAX_DELAY, this.initRetryDelay * RETRY_GROWTH);
145
- });
146
- }
147
- return this.statePromise;
148
- }
149
-
150
- private async init(): Promise<ChainState> {
151
- let configs = this.configured.sources.map(normalizeSource);
152
- let readOnly = this.isReadOnly(this.configured);
153
- let fetches = await Promise.all(configs.map(async sourceConfig => {
154
- let probe = await SourceWrapper.create(sourceConfig, { background: false, readOnly });
155
- let start = Date.now();
156
- try {
157
- let existing = await probe.readRoutingConfig();
158
- return { probe, sourceConfig, responded: true, latency: Date.now() - start, existing, error: "" };
159
- } catch (e) {
160
- return { probe, sourceConfig, responded: false, latency: 0, existing: undefined, error: `${sourceConfig.url}: ${(e as Error).stack ?? e}` };
161
- }
162
- }));
163
- try {
164
- let found = fetches.find(x => x.responded);
165
- if (!found) {
166
- throw new Error(`Cannot initialize storage for ${this.getDebugName()}: no source answered. ${fetches.map(x => x.error).join(" | ")}`);
167
- }
168
- let existing = found.existing;
169
- let active = this.configured;
170
- let needsWrite = true;
171
- if (existing && getConfigVersion(existing) >= getConfigVersion(this.configured)) {
172
- if (getConfigVersion(existing) === getConfigVersion(this.configured) && JSON.stringify(existing) !== JSON.stringify(this.configured)) {
173
- console.error(`Archives configuration updated without updating the version, for ${found.sourceConfig.url}. Updates will be ignored until you increase the version. Using: ${JSON.stringify(existing)}, ignoring: ${JSON.stringify(this.configured)}`);
174
- }
175
- active = existing;
176
- needsWrite = false;
177
- }
178
- if (needsWrite) {
179
- let best: RemoteConfig | undefined;
180
- let conflictUrl: string | undefined;
181
- for (let fetch of fetches) {
182
- let stored = fetch.existing;
183
- if (!stored) continue;
184
- if (!best || getConfigVersion(stored) > getConfigVersion(best)) {
185
- best = stored;
186
- }
187
- if (getConfigVersion(stored) === getConfigVersion(this.configured) && JSON.stringify(stored) !== JSON.stringify(this.configured)) {
188
- conflictUrl = fetch.sourceConfig.url;
189
- }
190
- }
191
- if (best && getConfigVersion(best) >= getConfigVersion(this.configured)) {
192
- if (conflictUrl && getConfigVersion(best) === getConfigVersion(this.configured)) {
193
- console.error(`Archives configuration updated without updating the version, for ${conflictUrl}. Updates will be ignored until you increase the version. Using: ${JSON.stringify(best)}, ignoring: ${JSON.stringify(this.configured)}`);
194
- }
195
- active = best;
196
- needsWrite = false;
197
- }
198
- }
199
- let sources = await this.buildSources(active);
200
- if (needsWrite) {
201
- let routingData = serializeRemoteConfig(this.configured);
202
- let routingWriteTime = Date.now();
203
- let targets: SourceWrapper[] = [];
204
- let seen = new Set<string>();
205
- for (let source of sources) {
206
- if (seen.has(source.config.url)) continue;
207
- seen.add(source.config.url);
208
- targets.push(source);
209
- }
210
- console.log(`Storage routing config for ${this.getDebugName()} is out of date (stored version ${existing && getConfigVersion(existing) || "none"}, ours ${getConfigVersion(this.configured)}): writing ours to all ${targets.length} nodes (write time ${new Date(routingWriteTime).toISOString()}): ${targets.map(x => x.config.url).join(", ")}`);
211
- // All in parallel, every failure tolerated (no-write-access included - the attempt classifies it, nothing pre-checks it). A down node must not stop the others from getting the config - they would then reject writes as unconfigured precisely BECAUSE it never arrived - and it must never stop the chain from starting. A node that missed it pulls it off its peers, and the next startup writes it again.
212
- let failures: string[] = [];
213
- await Promise.all(targets.map(async source => {
214
- try {
215
- await source.write(archives => archives.set(ROUTING_FILE, routingData, { lastModified: routingWriteTime }));
216
- console.log(`Wrote storage routing config version ${getConfigVersion(this.configured)} to ${source.config.url}`);
217
- } catch (e) {
218
- failures.push(`${source.config.url}: ${(e as Error).stack ?? e}`);
219
- }
220
- }));
221
- if (failures.length === targets.length) {
222
- // Nobody has our config, so running on it would just disagree with every server. The stored one (when there is one) is what the servers actually run.
223
- console.error(`The storage routing config for ${this.getDebugName()} (version ${getConfigVersion(this.configured)}) could not be written to ANY of the ${targets.length} nodes. ${existing && `Running on the stored config (version ${getConfigVersion(existing)}) until a write succeeds.` || `No stored config exists either, so servers will reject writes until this succeeds.`} Failures: ${failures.join(" | ")}`);
224
- if (existing) {
225
- active = existing;
226
- for (let source of sources) {
227
- source.dispose();
228
- }
229
- sources = await this.buildSources(active);
230
- }
231
- } else if (failures.length) {
232
- console.error(`The storage routing config for ${this.getDebugName()} (version ${getConfigVersion(this.configured)}) was written to ${targets.length - failures.length} of ${targets.length} nodes; the rest pull it from the ones that have it. Failures: ${failures.join(" | ")}`);
233
- }
234
- }
235
- for (let source of sources) {
236
- let fetch = fetches.find(x => x.responded && x.sourceConfig.url === source.config.url);
237
- if (fetch) {
238
- source.seedLatency(fetch.latency);
239
- }
240
- }
241
- this.activeConfig = active;
242
- this.startConfigPoll();
243
- let state: ChainState = { config: active, sources };
244
- this.latestState = state;
245
- return state;
246
- } finally {
247
- for (let fetch of fetches) {
248
- fetch.probe.dispose();
249
- }
250
- }
251
- }
252
-
253
- /** Clientside, a config with public sources is served entirely over plain URL downloads - no API connection, no access grant, and no writing. directConnect opts out of that. */
254
- private isReadOnly(config: RemoteConfig): boolean {
255
- if (this.options?.directConnect || isNode()) return false;
256
- return config.sources.map(normalizeSource).some(x => x.public);
257
- }
258
-
259
- private async createChainSource(sourceConfig: SourceConfig, readOnly: boolean): Promise<SourceWrapper> {
260
- let source = await SourceWrapper.create(sourceConfig, { readOnly });
261
- source.startPinging();
262
- return source;
263
- }
264
-
265
- private async buildSources(config: RemoteConfig): Promise<SourceWrapper[]> {
266
- let readOnly = this.isReadOnly(config);
267
- let sources: SourceWrapper[] = [];
268
- for (let sourceConfig of config.sources.map(normalizeSource)) {
269
- sources.push(await this.createChainSource(sourceConfig, readOnly));
270
- }
271
- return sources;
272
- }
273
-
274
- private startConfigPoll(): void {
275
- if (this.pollTimer || this.disposed) return;
276
- this.pollTimer = setInterval(() => {
277
- void this.refreshActiveConfig().catch((e: Error) => {
278
- console.error(`Checking for a new storage routing config failed for ${this.getDebugName()}: ${e.stack ?? e}`);
279
- });
280
- }, CONFIG_POLL_INTERVAL);
281
- (this.pollTimer as { unref?: () => void }).unref?.();
282
- }
283
-
284
- // Deduplicates concurrent refreshes (the poll timer and wrong-target write retries share this)
285
- private configRefreshInFlight: Promise<void> | undefined;
286
- private refreshActiveConfig(): Promise<void> {
287
- if (!this.configRefreshInFlight) {
288
- this.configRefreshInFlight = this.checkForNewConfig().finally(() => {
289
- this.configRefreshInFlight = undefined;
290
- });
291
- }
292
- return this.configRefreshInFlight;
293
- }
294
-
295
- // The latest config, as the server INTERPRETS it: getConfig carries in-memory overlays (deploy
296
- private async fetchLatestConfig(state: ChainState): Promise<RemoteConfig | undefined> {
297
- let errors: string[] = [];
298
- for (let source of state.sources) {
299
- try {
300
- let latest = await source.readRoutingConfig();
301
- if (latest) return normalizeRemoteConfig(latest);
302
- } catch (e) {
303
- errors.push(`${source.config.url}: ${(e as Error).stack ?? e}`);
304
- }
305
- }
306
- if (errors.length === state.sources.length) {
307
- throw new Error(`No storage source could give us the routing config for ${this.getDebugName()}: ${errors.join(" | ")}`);
308
- }
309
- return undefined;
310
- }
311
-
312
- private async checkForNewConfig(): Promise<void> {
313
- if (this.disposed || !this.statePromise) return;
314
- let state: ChainState;
315
- try {
316
- state = await this.statePromise;
317
- } catch {
318
- return;
319
- }
320
- let latest = await this.fetchLatestConfig(state);
321
- if (!latest) return;
322
- await this.adoptNewConfig(state, latest);
323
- }
324
-
325
- private async adoptNewConfig(state: ChainState, latest: RemoteConfig): Promise<void> {
326
- if (JSON.stringify(latest) === JSON.stringify(state.config)) return;
327
- let received = new Date().toISOString();
328
- let onlyIntermediatesChanged = JSON.stringify(resolveIntermediateSources(latest)) === JSON.stringify(resolveIntermediateSources(state.config));
329
- if (onlyIntermediatesChanged) {
330
- console.log(`Storage routing switchover windows changed (version ${getConfigVersion(state.config)} -> ${getConfigVersion(latest)}), received ${received}, rebuilding sources. New config: ${JSON.stringify(latest)}`);
331
- } else {
332
- console.log(`Storage routing config changed (version ${getConfigVersion(state.config)} -> ${getConfigVersion(latest)}), received ${received}, rebuilding sources. New config: ${JSON.stringify(latest)}`);
333
- }
334
- let strippedKey = (config: SourceConfig) => JSON.stringify({ ...config, validWindow: undefined });
335
- let oldByConfig = new Map<string, SourceWrapper[]>();
336
- for (let source of state.sources) {
337
- let key = strippedKey(source.config);
338
- let list = oldByConfig.get(key);
339
- if (!list) {
340
- list = [];
341
- oldByConfig.set(key, list);
342
- }
343
- list.push(source);
344
- }
345
- let readOnly = this.isReadOnly(latest);
346
- let sources: SourceWrapper[] = [];
347
- for (let sourceConfig of latest.sources.map(normalizeSource)) {
348
- let old = oldByConfig.get(strippedKey(sourceConfig))?.shift();
349
- if (old) {
350
- old.updateValidWindow(sourceConfig.validWindow);
351
- sources.push(old);
352
- } else {
353
- sources.push(await this.createChainSource(sourceConfig, readOnly));
354
- }
355
- }
356
- for (let leftovers of oldByConfig.values()) {
357
- for (let leftover of leftovers) {
358
- leftover.dispose();
359
- }
360
- }
361
- this.activeConfig = latest;
362
- let newState: ChainState = { config: latest, sources };
363
- this.latestState = newState;
364
- this.statePromise = Promise.resolve(newState);
365
- }
366
-
367
- private lastAvailabilityRecheck = 0;
368
- private availabilityRecheckInFlight: Promise<void> | undefined;
369
- private recheckAvailability(): Promise<void> {
370
- if (this.availabilityRecheckInFlight) return this.availabilityRecheckInFlight;
371
- if (Date.now() - this.lastAvailabilityRecheck < AVAILABILITY_RECHECK_THROTTLE) return Promise.resolve();
372
- this.lastAvailabilityRecheck = Date.now();
373
- this.availabilityRecheckInFlight = this.recheckAvailabilityNow().finally(() => {
374
- this.availabilityRecheckInFlight = undefined;
375
- });
376
- return this.availabilityRecheckInFlight;
377
- }
378
- private async recheckAvailabilityNow(): Promise<void> {
379
- if (this.disposed || !this.statePromise) return;
380
- let state: ChainState;
381
- try {
382
- state = await this.statePromise;
383
- } catch {
384
- return;
385
- }
386
- console.log(`Every storage source failed for ${this.getDebugName()}; re-contacting all ${state.sources.length} sources (routing re-read + connection re-attempt)`);
387
- let results = await Promise.all(state.sources.map(async source => {
388
- try {
389
- return await source.readRoutingConfig();
390
- } catch {
391
- return undefined;
392
- }
393
- }));
394
- let latest = results.find(x => x);
395
- if (!latest) return;
396
- await this.adoptNewConfig(state, latest);
397
- }
398
-
399
105
  // The ONE dispatch for every operation: no fallbacks (all writes by default, and noFallbacks reads) -> the primary node only, via runPrimary; everything else -> the shared fallback loop, trying sources in config order (or latency order for fast reads) and only moving on when one fails. Writes in the loop differ from reads only in calling source.write.
400
106
  private async run<T>(state: ChainState, config: { apiOnly?: boolean; write?: boolean; route?: number; noFallbacks?: boolean; fallbacks?: boolean; fast?: boolean; timeout?: SmartTimeout }, run: (archives: IArchives, sourceUrl: string) => Promise<T>): Promise<T> {
401
107
  if (config.fast && config.noFallbacks) {
@@ -461,13 +167,13 @@ export class ArchivesChain implements IArchives {
461
167
  }
462
168
  }
463
169
  if (wrongTarget) {
464
- state = await this.getState();
170
+ state = await this.state.getState();
465
171
  continue;
466
172
  }
467
173
  if (!recheckedAvailability) {
468
174
  recheckedAvailability = true;
469
- await this.recheckAvailability();
470
- state = await this.getState();
175
+ await this.state.recheckAvailability();
176
+ state = await this.state.getState();
471
177
  continue;
472
178
  }
473
179
  throw new Error(`All sources failed for ${this.getDebugName()}${config.route !== undefined && ` (route ${config.route})` || ""}: ${errors.join(" | ") || "no sources available"}`);
@@ -484,7 +190,7 @@ export class ArchivesChain implements IArchives {
484
190
  while (true) {
485
191
  attempt++;
486
192
  let attemptStart = Date.now();
487
- let state = await this.getState();
193
+ let state = await this.state.getState();
488
194
  let target = state.sources.find(x => configWindowCurrent(x.config) && (config.route === undefined || routeContains(x.config.route, config.route)));
489
195
  try {
490
196
  if (!target) {
@@ -525,7 +231,7 @@ export class ArchivesChain implements IArchives {
525
231
  }
526
232
  // At most one attempt per interval, in case the failure is fast
527
233
  await delay(Math.max(0, attemptStart + PRIMARY_RETRY_DELAY - Date.now()));
528
- await this.recheckAvailability();
234
+ await this.state.recheckAvailability();
529
235
  }
530
236
  }
531
237
 
@@ -587,16 +293,16 @@ export class ArchivesChain implements IArchives {
587
293
  this.lastConfigRefresh = Date.now();
588
294
  let reason = kind === "window" && "wrong valid window" || kind === "route" && "wrong route" || "the store has no configuration entry on that server";
589
295
  console.log(`Write rejected by ${this.getDebugName()} (${reason}): our config disagrees with the server's (likely stale, or a deploy switchover); refreshing it and retrying`);
590
- await this.refreshActiveConfig();
296
+ await this.state.refreshActiveConfig();
591
297
  }
592
298
 
593
299
  private async request<T>(config: { apiOnly?: boolean; write?: boolean; route?: number; noFallbacks?: boolean; fallbacks?: boolean; fast?: boolean; timeout?: SmartTimeout }, run: (archives: IArchives, sourceUrl: string) => Promise<T>): Promise<T> {
594
- let state = await this.getState();
300
+ let state = await this.state.getState();
595
301
  return await this.run(state, config, run);
596
302
  }
597
303
 
598
304
  public async waitingForAccess(): Promise<{ link: string; machineId: string; ip: string } | undefined> {
599
- let state = await this.getState();
305
+ let state = await this.state.getState();
600
306
  for (let source of state.sources) {
601
307
  // 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.
602
308
  if (source.config.validWindow[1] <= Date.now()) continue;
@@ -662,7 +368,7 @@ export class ArchivesChain implements IArchives {
662
368
  let tries = 0;
663
369
  while (true) {
664
370
  tries++;
665
- let state = await this.getState();
371
+ let state = await this.state.getState();
666
372
  // Errors name the OPERATION and the SPECIFIC sources that failed - "the find failed because source X is unavailable", never an anonymous failure attributed to the whole chain
667
373
  let outcome = await (async (): Promise<{ values: T[] } | { error: Error }> => {
668
374
  let covering: SourceWrapper[];
@@ -715,7 +421,7 @@ export class ArchivesChain implements IArchives {
715
421
  failed.clear();
716
422
  }
717
423
  console.error(`${error.message}. Retrying in ${COVERING_RETRY_DELAY / 1000}s (giving up at ${new Date(deadline).toISOString()}).`);
718
- void this.recheckAvailability();
424
+ void this.state.recheckAvailability();
719
425
  await delay(COVERING_RETRY_DELAY);
720
426
  }
721
427
  }
@@ -767,13 +473,13 @@ export class ArchivesChain implements IArchives {
767
473
  };
768
474
  }
769
475
  public async getConfig(): Promise<ArchivesConfig> {
770
- let state = await this.getState();
476
+ let state = await this.state.getState();
771
477
  if (!state.sources.some(x => x.api)) return { remoteConfig: state.config };
772
478
  let config = await this.run(state, { apiOnly: true }, archives => archives.getConfig());
773
479
  return { ...config, remoteConfig: state.config };
774
480
  }
775
481
  public async hasWriteAccess(): Promise<boolean> {
776
- let state = await this.getState();
482
+ let state = await this.state.getState();
777
483
  for (let source of state.sources) {
778
484
  if (!configWindowCurrent(source.config)) continue;
779
485
  if (!await source.hasWriteAccess()) return false;
@@ -803,26 +509,33 @@ export class ArchivesChain implements IArchives {
803
509
  private async setRoutingConfig(data: Buffer, config?: { lastModified?: number }): Promise<string> {
804
510
  // Checked before the first node is written to, not just by each node as it arrives: every server would reject it anyway, and finding that out one node at a time is how a config write ends up half-applied
805
511
  assertValidRemoteConfig(parseRoutingData(data));
806
- let state = await this.getState();
512
+ let state = await this.state.getState();
807
513
  let writeTime = config?.lastModified || Date.now();
808
514
  let written: string[] = [];
809
515
  let errors: string[] = [];
516
+ // One write per STORE, not per server: the write lands in the store the entry NAMES, so two entries sharing a URL but naming different stores are two separate deliveries - deduping by URL alone leaves the second store unconfigured forever
517
+ let targets: SourceWrapper[] = [];
810
518
  let seen = new Set<string>();
811
- console.log(`Writing storage routing config for ${this.getDebugName()} to every node (write time ${new Date(writeTime).toISOString()}): ${data.toString("utf8").slice(0, 2000)}`);
812
519
  for (let source of state.sources) {
813
- if (seen.has(source.config.url)) continue;
814
- seen.add(source.config.url);
520
+ let key = `${source.config.url}|${source.config.name}`;
521
+ if (seen.has(key)) continue;
522
+ seen.add(key);
523
+ targets.push(source);
524
+ }
525
+ console.log(`Writing storage routing config for ${this.getDebugName()} to all ${targets.length} stores (write time ${new Date(writeTime).toISOString()}): ${data.toString("utf8").slice(0, 2000)}`);
526
+ await Promise.all(targets.map(async source => {
527
+ let label = `${source.config.url} (store ${JSON.stringify(source.config.name)})`;
815
528
  try {
816
529
  await source.write(archives => archives.set(ROUTING_FILE, data, { lastModified: writeTime }));
817
- written.push(source.config.url);
818
- console.log(`Wrote the storage routing config to ${source.config.url}`);
530
+ written.push(label);
531
+ console.log(`Wrote the storage routing config to ${label}`);
819
532
  } catch (e) {
820
- errors.push(`${source.config.url}: ${(e as Error).stack ?? e}`);
533
+ errors.push(`${label}: ${(e as Error).stack ?? e}`);
821
534
  }
822
- }
823
- await this.refreshActiveConfig();
535
+ }));
536
+ await this.state.refreshActiveConfig();
824
537
  if (errors.length) {
825
- throw new Error(`Storage routing config write for ${this.getDebugName()} failed on ${errors.length} of ${seen.size} nodes (succeeded on: ${written.join(", ") || "none"}): ${errors.join(" | ")}`);
538
+ throw new Error(`Storage routing config write for ${this.getDebugName()} failed on ${errors.length} of ${targets.length} stores (succeeded on: ${written.join(", ") || "none"}): ${errors.join(" | ")}`);
826
539
  }
827
540
  return ROUTING_FILE;
828
541
  }
@@ -838,7 +551,7 @@ export class ArchivesChain implements IArchives {
838
551
  }
839
552
  let fromRoute = getRoute(config.fromPath);
840
553
  let toRoute = getRoute(config.toPath);
841
- let state = await this.getState();
554
+ let state = await this.state.getState();
842
555
  let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, fromRoute));
843
556
  if (target && routeContains(target.config.route, toRoute)) {
844
557
  await this.request({ write: true, route: fromRoute }, async archives => {
@@ -884,7 +597,7 @@ export class ArchivesChain implements IArchives {
884
597
  if (!key.includes(VARIABLE_SHARD) || parseVariableRoute(key) !== undefined) {
885
598
  throw new Error(`getShardKey requires a key containing an unmaterialized ${JSON.stringify(VARIABLE_SHARD)}, got ${JSON.stringify(key)}`);
886
599
  }
887
- let state = await this.getState();
600
+ let state = await this.state.getState();
888
601
  let target = this.getVariableShardTargets(state, { connectedOnly: true })[0]
889
602
  || this.getVariableShardTargets(state, { connectedOnly: false })[0];
890
603
  if (!target) {
@@ -898,7 +611,7 @@ export class ArchivesChain implements IArchives {
898
611
  // There's no point talking to a shard whose node was fast but is now disconnected - only when no connected shard works do we recheck availability and try every shard
899
612
  let connectedOnly = true;
900
613
  while (true) {
901
- let state = await this.getState();
614
+ let state = await this.state.getState();
902
615
  let targets = this.getVariableShardTargets(state, { connectedOnly });
903
616
  let errors: string[] = [];
904
617
  for (let target of targets) {
@@ -928,7 +641,7 @@ export class ArchivesChain implements IArchives {
928
641
  if (!recheckedAvailability) {
929
642
  recheckedAvailability = true;
930
643
  connectedOnly = false;
931
- await this.recheckAvailability();
644
+ await this.state.recheckAvailability();
932
645
  continue;
933
646
  }
934
647
  throw new Error(`Every variable-shard write target failed for ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
@@ -959,12 +672,12 @@ export class ArchivesChain implements IArchives {
959
672
  private async setLargeFileOnce(config: SetLargeFileConfig, route: number): Promise<void> {
960
673
  let recheckedAvailability = false;
961
674
  while (true) {
962
- let state = await this.getState();
675
+ let state = await this.state.getState();
963
676
  let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, route));
964
677
  if (!target) {
965
678
  if (!recheckedAvailability) {
966
679
  recheckedAvailability = true;
967
- await this.recheckAvailability();
680
+ await this.state.recheckAvailability();
968
681
  continue;
969
682
  }
970
683
  throw new Error(`No source accepts writes for setLargeFile on ${this.getDebugName()} (route ${route})`);
@@ -996,19 +709,19 @@ export class ArchivesChain implements IArchives {
996
709
 
997
710
  /** getURLs, but after the one await (initialization) the returned function is synchronous: everything underneath - route hashing, window checks, latencies, URL building - is synchronous, and the closure always reads the newest adopted config, so it stays correct across config refreshes. */
998
711
  public async getGetURLs(): Promise<(path: string) => string[]> {
999
- let initialState = await this.getState();
712
+ let initialState = await this.state.getState();
1000
713
  return this.makeGetURLs(initialState, { writeNodeFirst: true });
1001
714
  }
1002
715
 
1003
716
  /** getGetURLs, but sorted purely by latency - the write node gets no special first position. For read-only consumers that just want the fastest host. */
1004
717
  public async getGetFastURLs(): Promise<(path: string) => string[]> {
1005
- let initialState = await this.getState();
718
+ let initialState = await this.state.getState();
1006
719
  return this.makeGetURLs(initialState, { writeNodeFirst: false });
1007
720
  }
1008
721
 
1009
722
  private makeGetURLs(initialState: ChainState, config: { writeNodeFirst: boolean }): (path: string) => string[] {
1010
723
  return (path: string) => {
1011
- let state = this.latestState || initialState;
724
+ let state = this.state.latest() || initialState;
1012
725
  let route = getRoute(path);
1013
726
  let sources: SourceWrapper[] = [];
1014
727
  for (let source of state.sources) {
@@ -1032,22 +745,7 @@ export class ArchivesChain implements IArchives {
1032
745
  }
1033
746
 
1034
747
  public dispose(): void {
1035
- this.disposed = true;
1036
- this.unsubscribeRoutingPush?.();
1037
- if (this.pollTimer) {
1038
- clearInterval(this.pollTimer);
1039
- }
1040
- if (this.initRetryTimer) {
1041
- clearTimeout(this.initRetryTimer);
1042
- }
1043
- let statePromise = this.statePromise;
1044
- if (statePromise) {
1045
- void statePromise.then(state => {
1046
- for (let source of state.sources) {
1047
- source.dispose();
1048
- }
1049
- }, () => { });
1050
- }
748
+ this.state.dispose();
1051
749
  }
1052
750
  }
1053
751
 
@@ -34,7 +34,7 @@ export function getStore(account: string, bucketName: string, name: string): Blo
34
34
  let folder = getBucketFolder(name, account, bucketName);
35
35
  let existing = stores.get(folder);
36
36
  if (existing) return existing;
37
- console.log(`Opening store ${JSON.stringify(name)} of bucket ${account}/${bucketName} (folder ${folder}) - created on this first use if it never existed`);
37
+ console.log(`Opening store ${JSON.stringify(name)} of bucket ${account}/${bucketName} (folder ${folder})`);
38
38
  let store = new BlobStore(folder, name, {
39
39
  // Which entries in ITS routing config are this server's copy of it
40
40
  isSelf: source => isSelfSource(source, account, bucketName),