sliftutils 1.7.95 → 1.7.97

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[];
@@ -698,9 +404,7 @@ export class ArchivesChain implements IArchives {
698
404
  })();
699
405
  if ("values" in outcome) return outcome.values;
700
406
  let error = outcome.error;
701
- if (Date.now() >= deadline) {
702
- throw error;
703
- }
407
+ // Substitution comes BEFORE the deadline check: a single connect timeout can eat the entire deadline, and giving up then - without ever trying the substitute that fallbacks exist for - throws exactly when falling back matters most. Substitution cannot loop past the deadline forever: every pass adds its failures to `failed`, so the substitutes run out with the sources.
704
408
  if (config?.fallbacks && failed.size) {
705
409
  let substitutable = false;
706
410
  try {
@@ -714,8 +418,11 @@ export class ArchivesChain implements IArchives {
714
418
  // No substitute covers the failed routes - retry the failed sources themselves after the delay
715
419
  failed.clear();
716
420
  }
421
+ if (Date.now() >= deadline) {
422
+ throw error;
423
+ }
717
424
  console.error(`${error.message}. Retrying in ${COVERING_RETRY_DELAY / 1000}s (giving up at ${new Date(deadline).toISOString()}).`);
718
- void this.recheckAvailability();
425
+ void this.state.recheckAvailability();
719
426
  await delay(COVERING_RETRY_DELAY);
720
427
  }
721
428
  }
@@ -767,13 +474,13 @@ export class ArchivesChain implements IArchives {
767
474
  };
768
475
  }
769
476
  public async getConfig(): Promise<ArchivesConfig> {
770
- let state = await this.getState();
477
+ let state = await this.state.getState();
771
478
  if (!state.sources.some(x => x.api)) return { remoteConfig: state.config };
772
479
  let config = await this.run(state, { apiOnly: true }, archives => archives.getConfig());
773
480
  return { ...config, remoteConfig: state.config };
774
481
  }
775
482
  public async hasWriteAccess(): Promise<boolean> {
776
- let state = await this.getState();
483
+ let state = await this.state.getState();
777
484
  for (let source of state.sources) {
778
485
  if (!configWindowCurrent(source.config)) continue;
779
486
  if (!await source.hasWriteAccess()) return false;
@@ -803,26 +510,33 @@ export class ArchivesChain implements IArchives {
803
510
  private async setRoutingConfig(data: Buffer, config?: { lastModified?: number }): Promise<string> {
804
511
  // 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
512
  assertValidRemoteConfig(parseRoutingData(data));
806
- let state = await this.getState();
513
+ let state = await this.state.getState();
807
514
  let writeTime = config?.lastModified || Date.now();
808
515
  let written: string[] = [];
809
516
  let errors: string[] = [];
517
+ // 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
518
+ let targets: SourceWrapper[] = [];
810
519
  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
520
  for (let source of state.sources) {
813
- if (seen.has(source.config.url)) continue;
814
- seen.add(source.config.url);
521
+ let key = `${source.config.url}|${source.config.name}`;
522
+ if (seen.has(key)) continue;
523
+ seen.add(key);
524
+ targets.push(source);
525
+ }
526
+ 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)}`);
527
+ await Promise.all(targets.map(async source => {
528
+ let label = `${source.config.url} (store ${JSON.stringify(source.config.name)})`;
815
529
  try {
816
530
  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}`);
531
+ written.push(label);
532
+ console.log(`Wrote the storage routing config to ${label}`);
819
533
  } catch (e) {
820
- errors.push(`${source.config.url}: ${(e as Error).stack ?? e}`);
534
+ errors.push(`${label}: ${(e as Error).stack ?? e}`);
821
535
  }
822
- }
823
- await this.refreshActiveConfig();
536
+ }));
537
+ await this.state.refreshActiveConfig();
824
538
  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(" | ")}`);
539
+ 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
540
  }
827
541
  return ROUTING_FILE;
828
542
  }
@@ -838,7 +552,7 @@ export class ArchivesChain implements IArchives {
838
552
  }
839
553
  let fromRoute = getRoute(config.fromPath);
840
554
  let toRoute = getRoute(config.toPath);
841
- let state = await this.getState();
555
+ let state = await this.state.getState();
842
556
  let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, fromRoute));
843
557
  if (target && routeContains(target.config.route, toRoute)) {
844
558
  await this.request({ write: true, route: fromRoute }, async archives => {
@@ -884,7 +598,7 @@ export class ArchivesChain implements IArchives {
884
598
  if (!key.includes(VARIABLE_SHARD) || parseVariableRoute(key) !== undefined) {
885
599
  throw new Error(`getShardKey requires a key containing an unmaterialized ${JSON.stringify(VARIABLE_SHARD)}, got ${JSON.stringify(key)}`);
886
600
  }
887
- let state = await this.getState();
601
+ let state = await this.state.getState();
888
602
  let target = this.getVariableShardTargets(state, { connectedOnly: true })[0]
889
603
  || this.getVariableShardTargets(state, { connectedOnly: false })[0];
890
604
  if (!target) {
@@ -898,7 +612,7 @@ export class ArchivesChain implements IArchives {
898
612
  // 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
613
  let connectedOnly = true;
900
614
  while (true) {
901
- let state = await this.getState();
615
+ let state = await this.state.getState();
902
616
  let targets = this.getVariableShardTargets(state, { connectedOnly });
903
617
  let errors: string[] = [];
904
618
  for (let target of targets) {
@@ -928,7 +642,7 @@ export class ArchivesChain implements IArchives {
928
642
  if (!recheckedAvailability) {
929
643
  recheckedAvailability = true;
930
644
  connectedOnly = false;
931
- await this.recheckAvailability();
645
+ await this.state.recheckAvailability();
932
646
  continue;
933
647
  }
934
648
  throw new Error(`Every variable-shard write target failed for ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
@@ -959,12 +673,12 @@ export class ArchivesChain implements IArchives {
959
673
  private async setLargeFileOnce(config: SetLargeFileConfig, route: number): Promise<void> {
960
674
  let recheckedAvailability = false;
961
675
  while (true) {
962
- let state = await this.getState();
676
+ let state = await this.state.getState();
963
677
  let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, route));
964
678
  if (!target) {
965
679
  if (!recheckedAvailability) {
966
680
  recheckedAvailability = true;
967
- await this.recheckAvailability();
681
+ await this.state.recheckAvailability();
968
682
  continue;
969
683
  }
970
684
  throw new Error(`No source accepts writes for setLargeFile on ${this.getDebugName()} (route ${route})`);
@@ -996,19 +710,19 @@ export class ArchivesChain implements IArchives {
996
710
 
997
711
  /** 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
712
  public async getGetURLs(): Promise<(path: string) => string[]> {
999
- let initialState = await this.getState();
713
+ let initialState = await this.state.getState();
1000
714
  return this.makeGetURLs(initialState, { writeNodeFirst: true });
1001
715
  }
1002
716
 
1003
717
  /** 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
718
  public async getGetFastURLs(): Promise<(path: string) => string[]> {
1005
- let initialState = await this.getState();
719
+ let initialState = await this.state.getState();
1006
720
  return this.makeGetURLs(initialState, { writeNodeFirst: false });
1007
721
  }
1008
722
 
1009
723
  private makeGetURLs(initialState: ChainState, config: { writeNodeFirst: boolean }): (path: string) => string[] {
1010
724
  return (path: string) => {
1011
- let state = this.latestState || initialState;
725
+ let state = this.state.latest() || initialState;
1012
726
  let route = getRoute(path);
1013
727
  let sources: SourceWrapper[] = [];
1014
728
  for (let source of state.sources) {
@@ -1032,22 +746,7 @@ export class ArchivesChain implements IArchives {
1032
746
  }
1033
747
 
1034
748
  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
- }
749
+ this.state.dispose();
1051
750
  }
1052
751
  }
1053
752