sliftutils 1.7.94 → 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.
Files changed (51) hide show
  1. package/dist/treeSummary.ts.cache +2 -2
  2. package/index.d.ts +129 -27
  3. package/misc/dist/environment.ts.cache +2 -2
  4. package/misc/dist/getSecret.ts.cache +2 -2
  5. package/misc/dist/strings.ts.cache +2 -2
  6. package/misc/dist/zip.ts.cache +2 -2
  7. package/misc/https/dist/certs.ts.cache +2 -2
  8. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  9. package/package.json +1 -1
  10. package/render-utils/dist/observer.tsx.cache +2 -2
  11. package/storage/dist/ArchivesDisk.ts.cache +2 -2
  12. package/storage/dist/FileFolderAPI.tsx.cache +2 -2
  13. package/storage/dist/IArchives.ts.cache +2 -2
  14. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  15. package/storage/dist/JSONStorage.ts.cache +2 -2
  16. package/storage/dist/LogMap.ts.cache +2 -2
  17. package/storage/dist/PendingManager.tsx.cache +2 -2
  18. package/storage/dist/TransactionStorage.ts.cache +2 -2
  19. package/storage/dist/archiveHelpers.ts.cache +2 -2
  20. package/storage/dist/backblaze.ts.cache +2 -2
  21. package/storage/dist/fileSystemPointer.ts.cache +2 -2
  22. package/storage/dist/remoteFileStorage.ts.cache +2 -2
  23. package/storage/remoteStorage/chainStartup.d.ts +123 -0
  24. package/storage/remoteStorage/chainStartup.ts +482 -0
  25. package/storage/remoteStorage/createArchives.d.ts +2 -27
  26. package/storage/remoteStorage/createArchives.ts +53 -366
  27. package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +2 -2
  28. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +2 -2
  29. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +2 -2
  30. package/storage/remoteStorage/dist/accessStats.ts.cache +2 -2
  31. package/storage/remoteStorage/dist/blobStore.ts.cache +2 -2
  32. package/storage/remoteStorage/dist/bucketDisk.ts.cache +2 -2
  33. package/storage/remoteStorage/dist/certTrustModal.tsx.cache +2 -2
  34. package/storage/remoteStorage/dist/cliArgs.ts.cache +2 -2
  35. package/storage/remoteStorage/dist/createArchives.ts.cache +34 -38
  36. package/storage/remoteStorage/dist/deployTakeover.ts.cache +2 -2
  37. package/storage/remoteStorage/dist/intermediateManagement.ts.cache +2 -2
  38. package/storage/remoteStorage/dist/intermediateSources.ts.cache +2 -2
  39. package/storage/remoteStorage/dist/remoteConfig.ts.cache +2 -2
  40. package/storage/remoteStorage/dist/serverConfig.ts.cache +2 -2
  41. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +2 -2
  42. package/storage/remoteStorage/dist/sourcesList.ts.cache +2 -2
  43. package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
  44. package/storage/remoteStorage/dist/storageController.ts.cache +2 -2
  45. package/storage/remoteStorage/dist/storageServerState.ts.cache +2 -2
  46. package/storage/remoteStorage/dist/storeConfig.ts.cache +2 -2
  47. package/storage/remoteStorage/dist/storePlan.ts.cache +2 -2
  48. package/storage/remoteStorage/dist/storeSources.ts.cache +2 -2
  49. package/storage/remoteStorage/dist/storeSync.ts.cache +2 -2
  50. package/storage/remoteStorage/dist/validation.ts.cache +2 -2
  51. package/storage/remoteStorage/storageServerState.ts +1 -1
@@ -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,317 +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 missing: string[] = [];
202
- for (let source of sources) {
203
- try {
204
- if (!await source.hasWriteAccess()) {
205
- missing.push(source.config.url);
206
- }
207
- } catch (e) {
208
- missing.push(`${source.config.url} (check failed: ${(e as Error).stack ?? e})`);
209
- }
210
- }
211
- if (missing.length) {
212
- console.error(`Not writing the storage routing config for ${this.getDebugName()} (version ${getConfigVersion(this.configured)}): no write access to ${missing.join(", ")}. ${existing && `Running on the stored config (version ${getConfigVersion(existing)}) instead.` || `No stored config exists, so the bucket cannot be created until write access is granted.`}`);
213
- if (existing) {
214
- active = existing;
215
- for (let source of sources) {
216
- source.dispose();
217
- }
218
- sources = await this.buildSources(active);
219
- }
220
- } else {
221
- try {
222
- let routingData = serializeRemoteConfig(this.configured);
223
- let routingWriteTime = Date.now();
224
- let writtenUrls = new Set<string>();
225
- for (let source of sources) {
226
- if (writtenUrls.has(source.config.url)) continue;
227
- writtenUrls.add(source.config.url);
228
- }
229
- 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 ${writtenUrls.size} nodes (write time ${new Date(routingWriteTime).toISOString()}): ${[...writtenUrls].join(", ")}`);
230
- writtenUrls.clear();
231
- // We have to write to all the sources, of course. Otherwise, we get into weird situations where a broken source prevents us from writing to a source that would accept the write, and the source is broken because we didn't write to the other source.
232
- await Promise.allSettled(sources.map(async source => {
233
- if (writtenUrls.has(source.config.url)) return;
234
- writtenUrls.add(source.config.url);
235
- await source.write(archives => archives.set(ROUTING_FILE, routingData, { lastModified: routingWriteTime }));
236
- console.log(`Wrote storage routing config version ${getConfigVersion(this.configured)} to ${source.config.url}`);
237
- }));
238
- } catch (e) {
239
- for (let source of sources) {
240
- source.dispose();
241
- }
242
- throw e;
243
- }
244
- }
245
- }
246
- for (let source of sources) {
247
- let fetch = fetches.find(x => x.responded && x.sourceConfig.url === source.config.url);
248
- if (fetch) {
249
- source.seedLatency(fetch.latency);
250
- }
251
- }
252
- this.activeConfig = active;
253
- this.startConfigPoll();
254
- let state: ChainState = { config: active, sources };
255
- this.latestState = state;
256
- return state;
257
- } finally {
258
- for (let fetch of fetches) {
259
- fetch.probe.dispose();
260
- }
261
- }
262
- }
263
-
264
- /** 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. */
265
- private isReadOnly(config: RemoteConfig): boolean {
266
- if (this.options?.directConnect || isNode()) return false;
267
- return config.sources.map(normalizeSource).some(x => x.public);
268
- }
269
-
270
- private async createChainSource(sourceConfig: SourceConfig, readOnly: boolean): Promise<SourceWrapper> {
271
- let source = await SourceWrapper.create(sourceConfig, { readOnly });
272
- source.startPinging();
273
- return source;
274
- }
275
-
276
- private async buildSources(config: RemoteConfig): Promise<SourceWrapper[]> {
277
- let readOnly = this.isReadOnly(config);
278
- let sources: SourceWrapper[] = [];
279
- for (let sourceConfig of config.sources.map(normalizeSource)) {
280
- sources.push(await this.createChainSource(sourceConfig, readOnly));
281
- }
282
- return sources;
283
- }
284
-
285
- private startConfigPoll(): void {
286
- if (this.pollTimer || this.disposed) return;
287
- this.pollTimer = setInterval(() => {
288
- void this.refreshActiveConfig().catch((e: Error) => {
289
- console.error(`Checking for a new storage routing config failed for ${this.getDebugName()}: ${e.stack ?? e}`);
290
- });
291
- }, CONFIG_POLL_INTERVAL);
292
- (this.pollTimer as { unref?: () => void }).unref?.();
293
- }
294
-
295
- // Deduplicates concurrent refreshes (the poll timer and wrong-target write retries share this)
296
- private configRefreshInFlight: Promise<void> | undefined;
297
- private refreshActiveConfig(): Promise<void> {
298
- if (!this.configRefreshInFlight) {
299
- this.configRefreshInFlight = this.checkForNewConfig().finally(() => {
300
- this.configRefreshInFlight = undefined;
301
- });
302
- }
303
- return this.configRefreshInFlight;
304
- }
305
-
306
- // The latest config, as the server INTERPRETS it: getConfig carries in-memory overlays (deploy
307
- private async fetchLatestConfig(state: ChainState): Promise<RemoteConfig | undefined> {
308
- let errors: string[] = [];
309
- for (let source of state.sources) {
310
- try {
311
- let latest = await source.readRoutingConfig();
312
- if (latest) return normalizeRemoteConfig(latest);
313
- } catch (e) {
314
- errors.push(`${source.config.url}: ${(e as Error).stack ?? e}`);
315
- }
316
- }
317
- if (errors.length === state.sources.length) {
318
- throw new Error(`No storage source could give us the routing config for ${this.getDebugName()}: ${errors.join(" | ")}`);
319
- }
320
- return undefined;
321
- }
322
-
323
- private async checkForNewConfig(): Promise<void> {
324
- if (this.disposed || !this.statePromise) return;
325
- let state: ChainState;
326
- try {
327
- state = await this.statePromise;
328
- } catch {
329
- return;
330
- }
331
- let latest = await this.fetchLatestConfig(state);
332
- if (!latest) return;
333
- await this.adoptNewConfig(state, latest);
334
- }
335
-
336
- private async adoptNewConfig(state: ChainState, latest: RemoteConfig): Promise<void> {
337
- if (JSON.stringify(latest) === JSON.stringify(state.config)) return;
338
- let received = new Date().toISOString();
339
- let onlyIntermediatesChanged = JSON.stringify(resolveIntermediateSources(latest)) === JSON.stringify(resolveIntermediateSources(state.config));
340
- if (onlyIntermediatesChanged) {
341
- console.log(`Storage routing switchover windows changed (version ${getConfigVersion(state.config)} -> ${getConfigVersion(latest)}), received ${received}, rebuilding sources. New config: ${JSON.stringify(latest)}`);
342
- } else {
343
- console.log(`Storage routing config changed (version ${getConfigVersion(state.config)} -> ${getConfigVersion(latest)}), received ${received}, rebuilding sources. New config: ${JSON.stringify(latest)}`);
344
- }
345
- let strippedKey = (config: SourceConfig) => JSON.stringify({ ...config, validWindow: undefined });
346
- let oldByConfig = new Map<string, SourceWrapper[]>();
347
- for (let source of state.sources) {
348
- let key = strippedKey(source.config);
349
- let list = oldByConfig.get(key);
350
- if (!list) {
351
- list = [];
352
- oldByConfig.set(key, list);
353
- }
354
- list.push(source);
355
- }
356
- let readOnly = this.isReadOnly(latest);
357
- let sources: SourceWrapper[] = [];
358
- for (let sourceConfig of latest.sources.map(normalizeSource)) {
359
- let old = oldByConfig.get(strippedKey(sourceConfig))?.shift();
360
- if (old) {
361
- old.updateValidWindow(sourceConfig.validWindow);
362
- sources.push(old);
363
- } else {
364
- sources.push(await this.createChainSource(sourceConfig, readOnly));
365
- }
366
- }
367
- for (let leftovers of oldByConfig.values()) {
368
- for (let leftover of leftovers) {
369
- leftover.dispose();
370
- }
371
- }
372
- this.activeConfig = latest;
373
- let newState: ChainState = { config: latest, sources };
374
- this.latestState = newState;
375
- this.statePromise = Promise.resolve(newState);
376
- }
377
-
378
- private lastAvailabilityRecheck = 0;
379
- private availabilityRecheckInFlight: Promise<void> | undefined;
380
- private recheckAvailability(): Promise<void> {
381
- if (this.availabilityRecheckInFlight) return this.availabilityRecheckInFlight;
382
- if (Date.now() - this.lastAvailabilityRecheck < AVAILABILITY_RECHECK_THROTTLE) return Promise.resolve();
383
- this.lastAvailabilityRecheck = Date.now();
384
- this.availabilityRecheckInFlight = this.recheckAvailabilityNow().finally(() => {
385
- this.availabilityRecheckInFlight = undefined;
386
- });
387
- return this.availabilityRecheckInFlight;
388
- }
389
- private async recheckAvailabilityNow(): Promise<void> {
390
- if (this.disposed || !this.statePromise) return;
391
- let state: ChainState;
392
- try {
393
- state = await this.statePromise;
394
- } catch {
395
- return;
396
- }
397
- console.log(`Every storage source failed for ${this.getDebugName()}; re-contacting all ${state.sources.length} sources (routing re-read + connection re-attempt)`);
398
- let results = await Promise.all(state.sources.map(async source => {
399
- try {
400
- return await source.readRoutingConfig();
401
- } catch {
402
- return undefined;
403
- }
404
- }));
405
- let latest = results.find(x => x);
406
- if (!latest) return;
407
- await this.adoptNewConfig(state, latest);
408
- }
409
-
410
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.
411
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> {
412
107
  if (config.fast && config.noFallbacks) {
@@ -472,13 +167,13 @@ export class ArchivesChain implements IArchives {
472
167
  }
473
168
  }
474
169
  if (wrongTarget) {
475
- state = await this.getState();
170
+ state = await this.state.getState();
476
171
  continue;
477
172
  }
478
173
  if (!recheckedAvailability) {
479
174
  recheckedAvailability = true;
480
- await this.recheckAvailability();
481
- state = await this.getState();
175
+ await this.state.recheckAvailability();
176
+ state = await this.state.getState();
482
177
  continue;
483
178
  }
484
179
  throw new Error(`All sources failed for ${this.getDebugName()}${config.route !== undefined && ` (route ${config.route})` || ""}: ${errors.join(" | ") || "no sources available"}`);
@@ -495,7 +190,7 @@ export class ArchivesChain implements IArchives {
495
190
  while (true) {
496
191
  attempt++;
497
192
  let attemptStart = Date.now();
498
- let state = await this.getState();
193
+ let state = await this.state.getState();
499
194
  let target = state.sources.find(x => configWindowCurrent(x.config) && (config.route === undefined || routeContains(x.config.route, config.route)));
500
195
  try {
501
196
  if (!target) {
@@ -536,7 +231,7 @@ export class ArchivesChain implements IArchives {
536
231
  }
537
232
  // At most one attempt per interval, in case the failure is fast
538
233
  await delay(Math.max(0, attemptStart + PRIMARY_RETRY_DELAY - Date.now()));
539
- await this.recheckAvailability();
234
+ await this.state.recheckAvailability();
540
235
  }
541
236
  }
542
237
 
@@ -598,16 +293,16 @@ export class ArchivesChain implements IArchives {
598
293
  this.lastConfigRefresh = Date.now();
599
294
  let reason = kind === "window" && "wrong valid window" || kind === "route" && "wrong route" || "the store has no configuration entry on that server";
600
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`);
601
- await this.refreshActiveConfig();
296
+ await this.state.refreshActiveConfig();
602
297
  }
603
298
 
604
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> {
605
- let state = await this.getState();
300
+ let state = await this.state.getState();
606
301
  return await this.run(state, config, run);
607
302
  }
608
303
 
609
304
  public async waitingForAccess(): Promise<{ link: string; machineId: string; ip: string } | undefined> {
610
- let state = await this.getState();
305
+ let state = await this.state.getState();
611
306
  for (let source of state.sources) {
612
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.
613
308
  if (source.config.validWindow[1] <= Date.now()) continue;
@@ -673,7 +368,7 @@ export class ArchivesChain implements IArchives {
673
368
  let tries = 0;
674
369
  while (true) {
675
370
  tries++;
676
- let state = await this.getState();
371
+ let state = await this.state.getState();
677
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
678
373
  let outcome = await (async (): Promise<{ values: T[] } | { error: Error }> => {
679
374
  let covering: SourceWrapper[];
@@ -726,7 +421,7 @@ export class ArchivesChain implements IArchives {
726
421
  failed.clear();
727
422
  }
728
423
  console.error(`${error.message}. Retrying in ${COVERING_RETRY_DELAY / 1000}s (giving up at ${new Date(deadline).toISOString()}).`);
729
- void this.recheckAvailability();
424
+ void this.state.recheckAvailability();
730
425
  await delay(COVERING_RETRY_DELAY);
731
426
  }
732
427
  }
@@ -778,13 +473,13 @@ export class ArchivesChain implements IArchives {
778
473
  };
779
474
  }
780
475
  public async getConfig(): Promise<ArchivesConfig> {
781
- let state = await this.getState();
476
+ let state = await this.state.getState();
782
477
  if (!state.sources.some(x => x.api)) return { remoteConfig: state.config };
783
478
  let config = await this.run(state, { apiOnly: true }, archives => archives.getConfig());
784
479
  return { ...config, remoteConfig: state.config };
785
480
  }
786
481
  public async hasWriteAccess(): Promise<boolean> {
787
- let state = await this.getState();
482
+ let state = await this.state.getState();
788
483
  for (let source of state.sources) {
789
484
  if (!configWindowCurrent(source.config)) continue;
790
485
  if (!await source.hasWriteAccess()) return false;
@@ -814,26 +509,33 @@ export class ArchivesChain implements IArchives {
814
509
  private async setRoutingConfig(data: Buffer, config?: { lastModified?: number }): Promise<string> {
815
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
816
511
  assertValidRemoteConfig(parseRoutingData(data));
817
- let state = await this.getState();
512
+ let state = await this.state.getState();
818
513
  let writeTime = config?.lastModified || Date.now();
819
514
  let written: string[] = [];
820
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[] = [];
821
518
  let seen = new Set<string>();
822
- console.log(`Writing storage routing config for ${this.getDebugName()} to every node (write time ${new Date(writeTime).toISOString()}): ${data.toString("utf8").slice(0, 2000)}`);
823
519
  for (let source of state.sources) {
824
- if (seen.has(source.config.url)) continue;
825
- 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)})`;
826
528
  try {
827
529
  await source.write(archives => archives.set(ROUTING_FILE, data, { lastModified: writeTime }));
828
- written.push(source.config.url);
829
- 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}`);
830
532
  } catch (e) {
831
- errors.push(`${source.config.url}: ${(e as Error).stack ?? e}`);
533
+ errors.push(`${label}: ${(e as Error).stack ?? e}`);
832
534
  }
833
- }
834
- await this.refreshActiveConfig();
535
+ }));
536
+ await this.state.refreshActiveConfig();
835
537
  if (errors.length) {
836
- 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(" | ")}`);
837
539
  }
838
540
  return ROUTING_FILE;
839
541
  }
@@ -849,7 +551,7 @@ export class ArchivesChain implements IArchives {
849
551
  }
850
552
  let fromRoute = getRoute(config.fromPath);
851
553
  let toRoute = getRoute(config.toPath);
852
- let state = await this.getState();
554
+ let state = await this.state.getState();
853
555
  let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, fromRoute));
854
556
  if (target && routeContains(target.config.route, toRoute)) {
855
557
  await this.request({ write: true, route: fromRoute }, async archives => {
@@ -895,7 +597,7 @@ export class ArchivesChain implements IArchives {
895
597
  if (!key.includes(VARIABLE_SHARD) || parseVariableRoute(key) !== undefined) {
896
598
  throw new Error(`getShardKey requires a key containing an unmaterialized ${JSON.stringify(VARIABLE_SHARD)}, got ${JSON.stringify(key)}`);
897
599
  }
898
- let state = await this.getState();
600
+ let state = await this.state.getState();
899
601
  let target = this.getVariableShardTargets(state, { connectedOnly: true })[0]
900
602
  || this.getVariableShardTargets(state, { connectedOnly: false })[0];
901
603
  if (!target) {
@@ -909,7 +611,7 @@ export class ArchivesChain implements IArchives {
909
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
910
612
  let connectedOnly = true;
911
613
  while (true) {
912
- let state = await this.getState();
614
+ let state = await this.state.getState();
913
615
  let targets = this.getVariableShardTargets(state, { connectedOnly });
914
616
  let errors: string[] = [];
915
617
  for (let target of targets) {
@@ -939,7 +641,7 @@ export class ArchivesChain implements IArchives {
939
641
  if (!recheckedAvailability) {
940
642
  recheckedAvailability = true;
941
643
  connectedOnly = false;
942
- await this.recheckAvailability();
644
+ await this.state.recheckAvailability();
943
645
  continue;
944
646
  }
945
647
  throw new Error(`Every variable-shard write target failed for ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
@@ -970,12 +672,12 @@ export class ArchivesChain implements IArchives {
970
672
  private async setLargeFileOnce(config: SetLargeFileConfig, route: number): Promise<void> {
971
673
  let recheckedAvailability = false;
972
674
  while (true) {
973
- let state = await this.getState();
675
+ let state = await this.state.getState();
974
676
  let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, route));
975
677
  if (!target) {
976
678
  if (!recheckedAvailability) {
977
679
  recheckedAvailability = true;
978
- await this.recheckAvailability();
680
+ await this.state.recheckAvailability();
979
681
  continue;
980
682
  }
981
683
  throw new Error(`No source accepts writes for setLargeFile on ${this.getDebugName()} (route ${route})`);
@@ -1007,19 +709,19 @@ export class ArchivesChain implements IArchives {
1007
709
 
1008
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. */
1009
711
  public async getGetURLs(): Promise<(path: string) => string[]> {
1010
- let initialState = await this.getState();
712
+ let initialState = await this.state.getState();
1011
713
  return this.makeGetURLs(initialState, { writeNodeFirst: true });
1012
714
  }
1013
715
 
1014
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. */
1015
717
  public async getGetFastURLs(): Promise<(path: string) => string[]> {
1016
- let initialState = await this.getState();
718
+ let initialState = await this.state.getState();
1017
719
  return this.makeGetURLs(initialState, { writeNodeFirst: false });
1018
720
  }
1019
721
 
1020
722
  private makeGetURLs(initialState: ChainState, config: { writeNodeFirst: boolean }): (path: string) => string[] {
1021
723
  return (path: string) => {
1022
- let state = this.latestState || initialState;
724
+ let state = this.state.latest() || initialState;
1023
725
  let route = getRoute(path);
1024
726
  let sources: SourceWrapper[] = [];
1025
727
  for (let source of state.sources) {
@@ -1043,22 +745,7 @@ export class ArchivesChain implements IArchives {
1043
745
  }
1044
746
 
1045
747
  public dispose(): void {
1046
- this.disposed = true;
1047
- this.unsubscribeRoutingPush?.();
1048
- if (this.pollTimer) {
1049
- clearInterval(this.pollTimer);
1050
- }
1051
- if (this.initRetryTimer) {
1052
- clearTimeout(this.initRetryTimer);
1053
- }
1054
- let statePromise = this.statePromise;
1055
- if (statePromise) {
1056
- void statePromise.then(state => {
1057
- for (let source of state.sources) {
1058
- source.dispose();
1059
- }
1060
- }, () => { });
1061
- }
748
+ this.state.dispose();
1062
749
  }
1063
750
  }
1064
751