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.
@@ -0,0 +1,482 @@
1
+ import { isNode } from "socket-function/src/misc";
2
+ import { RemoteConfig, SourceConfig } from "../IArchives";
3
+ import { ROUTING_FILE, getConfigVersion, serializeRemoteConfig, normalizeRemoteConfig, normalizeSource } from "./remoteConfig";
4
+ import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
5
+ import { resolveIntermediateSources } from "./intermediateSources";
6
+ import { onServerRoutingChanged } from "./storageClientController";
7
+
8
+ // The LIFECYCLE of an ArchivesChain's state, and nothing else: initializing it (probing every configured source, deciding which routing config to run, writing ours out when it is the newest), the retry when initialization fails, the poll that adopts newer configs, the availability recheck, and the loop that keeps re-writing our config. None of this is request dispatch - the chain (createArchives.ts) asks the ChainStateManager for the current state and dispatches over it.
9
+
10
+ const CONFIG_POLL_INTERVAL = 5 * 60 * 1000;
11
+ const AVAILABILITY_RECHECK_THROTTLE = 5 * 1000;
12
+ // The routing config write is retried on this cadence while it is failing. A store that never received the config rejects every write aimed at it, so this not landing is a big deal - each failed attempt is logged.
13
+ export const CONFIG_WRITE_RETRY_INTERVAL = 5 * 60 * 1000;
14
+ // ...and even after success it is re-written on this cadence, in case a store lost it or a server appeared late
15
+ export const CONFIG_WRITE_REFRESH_INTERVAL = 60 * 60 * 1000;
16
+
17
+ export type ChainState = {
18
+ config: RemoteConfig;
19
+ sources: SourceWrapper[];
20
+ };
21
+
22
+ /** The chain's state and everything about HAVING one: init (with its retry), the config poll, availability rechecks, and the routing rewrite loop. The chain constructs one, asks it getState() on every call, and disposes it. */
23
+ export class ChainStateManager {
24
+ public readonly configured: RemoteConfig;
25
+ /** The newest adopted config - what getDebugName and dispatch decisions read. Starts as the configured one and moves with every adoption. */
26
+ public activeConfig: RemoteConfig;
27
+ private statePromise: Promise<ChainState> | undefined;
28
+ // The resolved state, for synchronous access (getGetURLs) - always the newest adopted config, where statePromise may briefly lag during a rebuild
29
+ private latestState: ChainState | undefined;
30
+ private initRetryDelay = RETRY_START_DELAY;
31
+ private initRetryTimer: ReturnType<typeof setTimeout> | undefined;
32
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
33
+ private disposed = false;
34
+ private unsubscribeRoutingPush: (() => void) | undefined;
35
+ private routingRewriter: RoutingRewriteLoop;
36
+
37
+ constructor(private config: {
38
+ configured: RemoteConfig;
39
+ debugName: () => string;
40
+ /** See ArchivesChainOptions.directConnect. */
41
+ directConnect?: boolean;
42
+ }) {
43
+ this.configured = config.configured;
44
+ this.activeConfig = config.configured;
45
+ this.routingRewriter = new RoutingRewriteLoop({
46
+ configured: config.configured,
47
+ debugName: config.debugName,
48
+ latest: () => this.latestState,
49
+ });
50
+ this.unsubscribeRoutingPush = onServerRoutingChanged(() => {
51
+ void this.refreshActiveConfig().catch((e: Error) => console.error(`Config refresh failed for ${this.config.debugName()}: ${e.stack ?? e}`));
52
+ });
53
+ }
54
+
55
+ /** The newest adopted state, synchronously - undefined until the first init finishes. */
56
+ public latest(): ChainState | undefined {
57
+ return this.latestState;
58
+ }
59
+
60
+ public getState(): Promise<ChainState> {
61
+ if (this.disposed) {
62
+ return Promise.reject(new Error(`ArchivesChain ${this.config.debugName()} has been disposed`));
63
+ }
64
+ if (!this.statePromise) {
65
+ let promise = this.init();
66
+ this.statePromise = promise;
67
+ promise.then(() => {
68
+ this.initRetryDelay = RETRY_START_DELAY;
69
+ }, (e: Error) => {
70
+ if (this.disposed || this.initRetryTimer) return;
71
+ console.error(`Storage init failed for ${this.config.debugName()}, retrying in ${Math.round(this.initRetryDelay / 1000)}s. ${e.stack ?? e}`);
72
+ this.initRetryTimer = setTimeout(() => {
73
+ this.initRetryTimer = undefined;
74
+ if (this.disposed) return;
75
+ if (this.statePromise === promise) {
76
+ this.statePromise = undefined;
77
+ }
78
+ this.getState().catch(() => { });
79
+ }, this.initRetryDelay);
80
+ (this.initRetryTimer as { unref?: () => void }).unref?.();
81
+ this.initRetryDelay = Math.min(RETRY_MAX_DELAY, this.initRetryDelay * RETRY_GROWTH);
82
+ });
83
+ }
84
+ return this.statePromise;
85
+ }
86
+
87
+ private async init(): Promise<ChainState> {
88
+ let configs = this.configured.sources.map(normalizeSource);
89
+ let readOnly = this.isReadOnly(this.configured);
90
+ let probes = await probeSources(configs, readOnly);
91
+ try {
92
+ let { active, needsWrite, existing } = chooseStartupConfig({ configured: this.configured, probes, debugName: this.config.debugName() });
93
+ let sources = await this.buildSources(active);
94
+ let configWriteFailed = false;
95
+ if (needsWrite) {
96
+ console.log(`Storage routing config for ${this.config.debugName()} is out of date (stored version ${existing && getConfigVersion(existing) || "none"}, ours ${getConfigVersion(this.configured)})`);
97
+ let { failures, total } = await writeRoutingToAllStores({ configured: this.configured, sources, debugName: this.config.debugName() });
98
+ configWriteFailed = failures.length > 0;
99
+ if (failures.length && failures.length === total) {
100
+ // 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.
101
+ console.error(`The storage routing config for ${this.config.debugName()} (version ${getConfigVersion(this.configured)}) could not be written to ANY of the ${total} stores. ${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.`} Retrying in ${CONFIG_WRITE_RETRY_INTERVAL / 1000}s. Failures: ${failures.join(" | ")}`);
102
+ if (existing) {
103
+ active = existing;
104
+ for (let source of sources) {
105
+ source.dispose();
106
+ }
107
+ sources = await this.buildSources(active);
108
+ }
109
+ } else if (failures.length) {
110
+ console.error(`The storage routing config for ${this.config.debugName()} (version ${getConfigVersion(this.configured)}) was written to ${total - failures.length} of ${total} stores. Retrying the rest in ${CONFIG_WRITE_RETRY_INTERVAL / 1000}s (they also pull it from the stores that have it). Failures: ${failures.join(" | ")}`);
111
+ }
112
+ }
113
+ for (let source of sources) {
114
+ let probe = probes.find(x => x.responded && x.sourceConfig.url === source.config.url);
115
+ if (probe) {
116
+ source.seedLatency(probe.latency);
117
+ }
118
+ }
119
+ this.activeConfig = active;
120
+ this.startConfigPoll();
121
+ let state: ChainState = { config: active, sources };
122
+ this.latestState = state;
123
+ this.routingRewriter.start(configWriteFailed);
124
+ return state;
125
+ } finally {
126
+ disposeProbes(probes);
127
+ }
128
+ }
129
+
130
+ /** 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. */
131
+ private isReadOnly(config: RemoteConfig): boolean {
132
+ if (this.config.directConnect || isNode()) return false;
133
+ return config.sources.map(normalizeSource).some(x => x.public);
134
+ }
135
+
136
+ private async createChainSource(sourceConfig: SourceConfig, readOnly: boolean): Promise<SourceWrapper> {
137
+ let source = await SourceWrapper.create(sourceConfig, { readOnly });
138
+ source.startPinging();
139
+ return source;
140
+ }
141
+
142
+ private async buildSources(config: RemoteConfig): Promise<SourceWrapper[]> {
143
+ let readOnly = this.isReadOnly(config);
144
+ let sources: SourceWrapper[] = [];
145
+ for (let sourceConfig of config.sources.map(normalizeSource)) {
146
+ sources.push(await this.createChainSource(sourceConfig, readOnly));
147
+ }
148
+ return sources;
149
+ }
150
+
151
+ private startConfigPoll(): void {
152
+ if (this.pollTimer || this.disposed) return;
153
+ this.pollTimer = setInterval(() => {
154
+ void this.refreshActiveConfig().catch((e: Error) => {
155
+ console.error(`Checking for a new storage routing config failed for ${this.config.debugName()}: ${e.stack ?? e}`);
156
+ });
157
+ }, CONFIG_POLL_INTERVAL);
158
+ (this.pollTimer as { unref?: () => void }).unref?.();
159
+ }
160
+
161
+ // Deduplicates concurrent refreshes (the poll timer and wrong-target write retries share this)
162
+ private configRefreshInFlight: Promise<void> | undefined;
163
+ public refreshActiveConfig(): Promise<void> {
164
+ if (!this.configRefreshInFlight) {
165
+ this.configRefreshInFlight = this.checkForNewConfig().finally(() => {
166
+ this.configRefreshInFlight = undefined;
167
+ });
168
+ }
169
+ return this.configRefreshInFlight;
170
+ }
171
+
172
+ private async fetchLatestConfig(state: ChainState): Promise<RemoteConfig | undefined> {
173
+ let errors: string[] = [];
174
+ for (let source of state.sources) {
175
+ try {
176
+ let latest = await source.readRoutingConfig();
177
+ if (latest) return normalizeRemoteConfig(latest);
178
+ } catch (e) {
179
+ errors.push(`${source.config.url}: ${(e as Error).stack ?? e}`);
180
+ }
181
+ }
182
+ if (errors.length === state.sources.length) {
183
+ throw new Error(`No storage source could give us the routing config for ${this.config.debugName()}: ${errors.join(" | ")}`);
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ private async checkForNewConfig(): Promise<void> {
189
+ if (this.disposed || !this.statePromise) return;
190
+ let state: ChainState;
191
+ try {
192
+ state = await this.statePromise;
193
+ } catch {
194
+ return;
195
+ }
196
+ let latest = await this.fetchLatestConfig(state);
197
+ if (!latest) return;
198
+ await this.adoptNewConfig(state, latest);
199
+ }
200
+
201
+ private async adoptNewConfig(state: ChainState, latest: RemoteConfig): Promise<void> {
202
+ if (JSON.stringify(latest) === JSON.stringify(state.config)) return;
203
+ let received = new Date().toISOString();
204
+ let onlyIntermediatesChanged = JSON.stringify(resolveIntermediateSources(latest)) === JSON.stringify(resolveIntermediateSources(state.config));
205
+ if (onlyIntermediatesChanged) {
206
+ console.log(`Storage routing switchover windows changed (version ${getConfigVersion(state.config)} -> ${getConfigVersion(latest)}), received ${received}, rebuilding sources. New config: ${JSON.stringify(latest)}`);
207
+ } else {
208
+ console.log(`Storage routing config changed (version ${getConfigVersion(state.config)} -> ${getConfigVersion(latest)}), received ${received}, rebuilding sources. New config: ${JSON.stringify(latest)}`);
209
+ }
210
+ let strippedKey = (config: SourceConfig) => JSON.stringify({ ...config, validWindow: undefined });
211
+ let oldByConfig = new Map<string, SourceWrapper[]>();
212
+ for (let source of state.sources) {
213
+ let key = strippedKey(source.config);
214
+ let list = oldByConfig.get(key);
215
+ if (!list) {
216
+ list = [];
217
+ oldByConfig.set(key, list);
218
+ }
219
+ list.push(source);
220
+ }
221
+ let readOnly = this.isReadOnly(latest);
222
+ let sources: SourceWrapper[] = [];
223
+ for (let sourceConfig of latest.sources.map(normalizeSource)) {
224
+ let old = oldByConfig.get(strippedKey(sourceConfig))?.shift();
225
+ if (old) {
226
+ old.updateValidWindow(sourceConfig.validWindow);
227
+ sources.push(old);
228
+ } else {
229
+ sources.push(await this.createChainSource(sourceConfig, readOnly));
230
+ }
231
+ }
232
+ for (let leftovers of oldByConfig.values()) {
233
+ for (let leftover of leftovers) {
234
+ leftover.dispose();
235
+ }
236
+ }
237
+ this.activeConfig = latest;
238
+ let newState: ChainState = { config: latest, sources };
239
+ this.latestState = newState;
240
+ this.statePromise = Promise.resolve(newState);
241
+ }
242
+
243
+ private lastAvailabilityRecheck = 0;
244
+ private availabilityRecheckInFlight: Promise<void> | undefined;
245
+ /** Every source failed: re-contact all of them (routing re-read + connection re-attempt) and adopt whatever config comes back. Throttled, and deduplicated across concurrent callers. */
246
+ public recheckAvailability(): Promise<void> {
247
+ if (this.availabilityRecheckInFlight) return this.availabilityRecheckInFlight;
248
+ if (Date.now() - this.lastAvailabilityRecheck < AVAILABILITY_RECHECK_THROTTLE) return Promise.resolve();
249
+ this.lastAvailabilityRecheck = Date.now();
250
+ this.availabilityRecheckInFlight = this.recheckAvailabilityNow().finally(() => {
251
+ this.availabilityRecheckInFlight = undefined;
252
+ });
253
+ return this.availabilityRecheckInFlight;
254
+ }
255
+ private async recheckAvailabilityNow(): Promise<void> {
256
+ if (this.disposed || !this.statePromise) return;
257
+ let state: ChainState;
258
+ try {
259
+ state = await this.statePromise;
260
+ } catch {
261
+ return;
262
+ }
263
+ console.log(`Every storage source failed for ${this.config.debugName()}; re-contacting all ${state.sources.length} sources (routing re-read + connection re-attempt)`);
264
+ let results = await Promise.all(state.sources.map(async source => {
265
+ try {
266
+ return await source.readRoutingConfig();
267
+ } catch {
268
+ return undefined;
269
+ }
270
+ }));
271
+ let latest = results.find(x => x);
272
+ if (!latest) return;
273
+ await this.adoptNewConfig(state, latest);
274
+ }
275
+
276
+ public dispose(): void {
277
+ this.disposed = true;
278
+ this.unsubscribeRoutingPush?.();
279
+ if (this.pollTimer) {
280
+ clearInterval(this.pollTimer);
281
+ }
282
+ if (this.initRetryTimer) {
283
+ clearTimeout(this.initRetryTimer);
284
+ }
285
+ this.routingRewriter.dispose();
286
+ let statePromise = this.statePromise;
287
+ if (statePromise) {
288
+ void statePromise.then(state => {
289
+ for (let source of state.sources) {
290
+ source.dispose();
291
+ }
292
+ }, () => { });
293
+ }
294
+ }
295
+ }
296
+
297
+ export type SourceProbe = {
298
+ probe: SourceWrapper;
299
+ sourceConfig: SourceConfig;
300
+ responded: boolean;
301
+ latency: number;
302
+ existing: RemoteConfig | undefined;
303
+ error: string;
304
+ };
305
+
306
+ /** One throwaway SourceWrapper per configured source, each asked for its stored routing config - which also measures first-contact latency, seeded into the real sources afterwards. The probes MUST be disposed (disposeProbes) once the caller is done with them. */
307
+ export async function probeSources(configs: SourceConfig[], readOnly: boolean): Promise<SourceProbe[]> {
308
+ return await Promise.all(configs.map(async sourceConfig => {
309
+ let probe = await SourceWrapper.create(sourceConfig, { background: false, readOnly });
310
+ let start = Date.now();
311
+ try {
312
+ let existing = await probe.readRoutingConfig();
313
+ return { probe, sourceConfig, responded: true, latency: Date.now() - start, existing, error: "" };
314
+ } catch (e) {
315
+ return { probe, sourceConfig, responded: false, latency: 0, existing: undefined, error: `${sourceConfig.url}: ${(e as Error).stack ?? e}` };
316
+ }
317
+ }));
318
+ }
319
+
320
+ export function disposeProbes(probes: SourceProbe[]): void {
321
+ for (let probe of probes) {
322
+ probe.probe.dispose();
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Which routing config the chain should RUN: the newest of ours and every stored one. needsWrite
328
+ * when ours is strictly the newest, meaning the stores have to be told about it. A stored config
329
+ * with our exact version but DIFFERENT content wins without a write, loudly: config updates must
330
+ * bump the version, or they are ignored - silently taking the changed one would make "what is the
331
+ * bucket running" depend on which process started last.
332
+ *
333
+ * Throws when no source answered at all: with nothing stored and nobody to write to, there is no
334
+ * config to run.
335
+ */
336
+ export function chooseStartupConfig(config: { configured: RemoteConfig; probes: SourceProbe[]; debugName: string }): { active: RemoteConfig; needsWrite: boolean; existing: RemoteConfig | undefined } {
337
+ let { configured, probes, debugName } = config;
338
+ let found = probes.find(x => x.responded);
339
+ if (!found) {
340
+ throw new Error(`Cannot initialize storage for ${debugName}: no source answered. ${probes.map(x => x.error).join(" | ")}`);
341
+ }
342
+ let existing = found.existing;
343
+ let active = configured;
344
+ let needsWrite = true;
345
+ if (existing && getConfigVersion(existing) >= getConfigVersion(configured)) {
346
+ if (getConfigVersion(existing) === getConfigVersion(configured) && JSON.stringify(existing) !== JSON.stringify(configured)) {
347
+ 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(configured)}`);
348
+ }
349
+ active = existing;
350
+ needsWrite = false;
351
+ }
352
+ if (needsWrite) {
353
+ let best: RemoteConfig | undefined;
354
+ let conflictUrl: string | undefined;
355
+ for (let probe of probes) {
356
+ let stored = probe.existing;
357
+ if (!stored) continue;
358
+ if (!best || getConfigVersion(stored) > getConfigVersion(best)) {
359
+ best = stored;
360
+ }
361
+ if (getConfigVersion(stored) === getConfigVersion(configured) && JSON.stringify(stored) !== JSON.stringify(configured)) {
362
+ conflictUrl = probe.sourceConfig.url;
363
+ }
364
+ }
365
+ if (best && getConfigVersion(best) >= getConfigVersion(configured)) {
366
+ if (conflictUrl && getConfigVersion(best) === getConfigVersion(configured)) {
367
+ 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(configured)}`);
368
+ }
369
+ active = best;
370
+ needsWrite = false;
371
+ }
372
+ }
373
+ return { active, needsWrite, existing };
374
+ }
375
+
376
+ /**
377
+ * Writes the given routing config to every configured store, one write per url+name: the write
378
+ * lands in the store the entry NAMES, so two entries sharing a URL but naming different stores are
379
+ * two separate deliveries - deduping by URL alone leaves the second store unconfigured forever. All
380
+ * in parallel, every failure tolerated (no-write-access included - the attempt classifies it,
381
+ * nothing pre-checks it): a down node must not stop the others from getting the config - they would
382
+ * then reject writes as unconfigured precisely BECAUSE it never arrived - and it must never stop
383
+ * the chain from starting. A store that missed it pulls it off its peers, and the rewrite loop
384
+ * tries again (see RoutingRewriteLoop).
385
+ */
386
+ export async function writeRoutingToAllStores(config: { configured: RemoteConfig; sources: SourceWrapper[]; debugName: string }): Promise<{ failures: string[]; total: number }> {
387
+ let { configured, sources, debugName } = config;
388
+ let routingData = serializeRemoteConfig(configured);
389
+ let routingWriteTime = Date.now();
390
+ let targets: SourceWrapper[] = [];
391
+ let seen = new Set<string>();
392
+ for (let source of sources) {
393
+ let key = `${source.config.url}|${source.config.name}`;
394
+ if (seen.has(key)) continue;
395
+ seen.add(key);
396
+ targets.push(source);
397
+ }
398
+ console.log(`Writing routing config version ${getConfigVersion(configured)} for ${debugName} to all ${targets.length} stores (write time ${new Date(routingWriteTime).toISOString()}): ${targets.map(x => `${x.config.url} (store ${JSON.stringify(x.config.name)})`).join(", ")}`);
399
+ let failures: string[] = [];
400
+ await Promise.all(targets.map(async source => {
401
+ try {
402
+ await source.write(archives => archives.set(ROUTING_FILE, routingData, { lastModified: routingWriteTime }));
403
+ console.log(`Wrote storage routing config version ${getConfigVersion(configured)} to ${source.config.url} (store ${JSON.stringify(source.config.name)})`);
404
+ } catch (e) {
405
+ failures.push(`${source.config.url} (store ${JSON.stringify(source.config.name)}): ${(e as Error).stack ?? e}`);
406
+ }
407
+ }));
408
+ return { failures, total: targets.length };
409
+ }
410
+
411
+ /**
412
+ * The periodic re-write of the chain's in-code config: failures retried on the short interval - a
413
+ * store without the config rejects every write aimed at it, so this not landing is a big deal,
414
+ * logged on every attempt - and even success repeated hourly, in case a store lost it. The failure
415
+ * this exists for: a server whose trust was only granted AFTER startup, so the startup write was
416
+ * rejected and nothing else would ever retry it.
417
+ */
418
+ export class RoutingRewriteLoop {
419
+ constructor(private config: {
420
+ configured: RemoteConfig;
421
+ debugName: () => string;
422
+ /** The chain's newest adopted state: what decides whether ours is still the config to write, and the sources it is written through. Undefined until the first init finishes. */
423
+ latest: () => { config: RemoteConfig; sources: SourceWrapper[] } | undefined;
424
+ }) { }
425
+
426
+ private timer: ReturnType<typeof setTimeout> | undefined;
427
+ private disposed = false;
428
+
429
+ /** (Re)arms the loop - called at the end of every init, with whether that init's write failed (which picks the short retry interval). */
430
+ public start(failedAtStartup: boolean): void {
431
+ this.schedule(failedAtStartup && CONFIG_WRITE_RETRY_INTERVAL || CONFIG_WRITE_REFRESH_INTERVAL);
432
+ }
433
+
434
+ public dispose(): void {
435
+ this.disposed = true;
436
+ if (this.timer) {
437
+ clearTimeout(this.timer);
438
+ }
439
+ }
440
+
441
+ private schedule(delay: number): void {
442
+ if (this.disposed) return;
443
+ if (this.timer) {
444
+ clearTimeout(this.timer);
445
+ }
446
+ this.timer = setTimeout(() => {
447
+ this.timer = undefined;
448
+ void this.rewrite().catch((e: Error) => {
449
+ console.error(`Rewriting the storage routing config for ${this.config.debugName()} failed: ${e.stack ?? e}`);
450
+ this.schedule(CONFIG_WRITE_RETRY_INTERVAL);
451
+ });
452
+ }, delay);
453
+ (this.timer as { unref?: () => void }).unref?.();
454
+ }
455
+
456
+ private async rewrite(): Promise<void> {
457
+ if (this.disposed) return;
458
+ let configured = this.config.configured;
459
+ let state = this.config.latest();
460
+ if (!state) {
461
+ this.schedule(CONFIG_WRITE_RETRY_INTERVAL);
462
+ return;
463
+ }
464
+ // The bucket adopted a NEWER config than our in-code one, so ours is stale and every store would (rightly) refuse it
465
+ if (getConfigVersion(state.config) > getConfigVersion(configured)) {
466
+ this.schedule(CONFIG_WRITE_REFRESH_INTERVAL);
467
+ return;
468
+ }
469
+ // Same version but different content: startup already decided the stored one wins until the version is bumped (see chooseStartupConfig), and re-writing ours would flip the bucket back and forth between the two
470
+ if (getConfigVersion(state.config) === getConfigVersion(configured) && JSON.stringify(state.config) !== JSON.stringify(configured)) {
471
+ this.schedule(CONFIG_WRITE_REFRESH_INTERVAL);
472
+ return;
473
+ }
474
+ let { failures, total } = await writeRoutingToAllStores({ configured, sources: state.sources, debugName: this.config.debugName() });
475
+ if (failures.length) {
476
+ console.error(`The periodic routing config write for ${this.config.debugName()} (version ${getConfigVersion(configured)}) failed on ${failures.length} of ${total} stores - retrying in ${CONFIG_WRITE_RETRY_INTERVAL / 1000}s: ${failures.join(" | ")}`);
477
+ this.schedule(CONFIG_WRITE_RETRY_INTERVAL);
478
+ return;
479
+ }
480
+ this.schedule(CONFIG_WRITE_REFRESH_INTERVAL);
481
+ }
482
+ }
@@ -11,34 +11,9 @@ export type ArchivesChainOptions = {
11
11
  directConnect?: boolean;
12
12
  };
13
13
  export declare class ArchivesChain implements IArchives {
14
- private options?;
15
- private configured;
16
- private activeConfig;
17
- private statePromise;
18
- private latestState;
19
- private initRetryDelay;
20
- private initRetryTimer;
21
- private pollTimer;
22
- private disposed;
23
- private unsubscribeRoutingPush;
24
- constructor(config: RemoteConfig | RemoteConfigBase, options?: ArchivesChainOptions | undefined);
14
+ private state;
15
+ constructor(config: RemoteConfig | RemoteConfigBase, options?: ArchivesChainOptions);
25
16
  getDebugName(): string;
26
- private getState;
27
- private init;
28
- /** 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. */
29
- private isReadOnly;
30
- private createChainSource;
31
- private buildSources;
32
- private startConfigPoll;
33
- private configRefreshInFlight;
34
- private refreshActiveConfig;
35
- private fetchLatestConfig;
36
- private checkForNewConfig;
37
- private adoptNewConfig;
38
- private lastAvailabilityRecheck;
39
- private availabilityRecheckInFlight;
40
- private recheckAvailability;
41
- private recheckAvailabilityNow;
42
17
  private run;
43
18
  private runPrimary;
44
19
  /** Races call against a size-based deadline. Uploads know their size upfront; gets are given SMART_TIMEOUT_PROBE to produce anything, and only then is the file's info fetched (from the same source, itself time-limited) to size the deadline - measured from the call's start, so a source that was slow before the probe doesn't get the full allowance again. Timed-out calls keep running in the background (they cannot be cancelled) but their eventual result is ignored. */