sliftutils 1.7.10 → 1.7.11

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 (83) hide show
  1. package/examplestorage/exampleserver.ts +0 -2
  2. package/index.d.ts +290 -54
  3. package/misc/dist/environment.ts.cache +3 -3
  4. package/misc/dist/getSecret.ts.cache +139 -0
  5. package/misc/dist/strings.ts.cache +13 -0
  6. package/misc/dist/zip.ts.cache +9 -0
  7. package/misc/getSecret.ts +38 -16
  8. package/misc/https/cloudflareHelpers.d.ts +19 -0
  9. package/misc/https/cloudflareHelpers.ts +69 -0
  10. package/misc/https/dist/certs.ts.cache +115 -179
  11. package/misc/https/dist/cloudflareHelpers.ts.cache +68 -0
  12. package/misc/https/dist/dns.ts.cache +112 -100
  13. package/misc/https/dist/hostServer.ts.cache +130 -0
  14. package/misc/https/dist/httpsCerts.ts.cache +24 -5
  15. package/misc/https/dist/persistentLocalStorage.ts.cache +3 -2
  16. package/misc/https/dns.d.ts +41 -12
  17. package/misc/https/dns.ts +104 -101
  18. package/misc/https/hostServer.d.ts +0 -6
  19. package/misc/https/hostServer.ts +9 -5
  20. package/package.json +2 -2
  21. package/render-utils/dist/observer.tsx.cache +3 -2
  22. package/spec.txt +0 -90
  23. package/storage/ArchivesDisk.d.ts +2 -0
  24. package/storage/ArchivesDisk.ts +7 -3
  25. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +1242 -0
  26. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +513 -0
  27. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +352 -0
  28. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +306 -0
  29. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +435 -0
  30. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +58 -0
  31. package/storage/BulkDatabase2/dist/blockCache.ts.cache +179 -0
  32. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +149 -0
  33. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +101 -0
  34. package/storage/BulkDatabase2/dist/streamLog.ts.cache +227 -0
  35. package/storage/BulkDatabase2/dist/syncClient.ts.cache +104 -0
  36. package/storage/IArchives.d.ts +47 -15
  37. package/storage/IArchives.ts +86 -31
  38. package/storage/backblaze.d.ts +15 -0
  39. package/storage/backblaze.ts +199 -79
  40. package/storage/dist/ArchivesDisk.ts.cache +373 -0
  41. package/storage/dist/FileFolderAPI.tsx.cache +508 -65
  42. package/storage/dist/IArchives.ts.cache +44 -0
  43. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +8 -2
  44. package/storage/dist/JSONStorage.ts.cache +6 -2
  45. package/storage/dist/PendingManager.tsx.cache +1 -1
  46. package/storage/dist/TransactionStorage.ts.cache +231 -92
  47. package/storage/dist/backblaze.ts.cache +797 -0
  48. package/storage/dist/fileSystemPointer.ts.cache +36 -3
  49. package/storage/dist/remoteFileStorage.ts.cache +495 -0
  50. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -1
  51. package/storage/remoteStorage/ArchivesRemote.ts +28 -8
  52. package/storage/remoteStorage/ArchivesUrl.d.ts +2 -0
  53. package/storage/remoteStorage/ArchivesUrl.ts +16 -4
  54. package/storage/remoteStorage/blobStore.d.ts +52 -3
  55. package/storage/remoteStorage/blobStore.ts +414 -101
  56. package/storage/remoteStorage/createArchives.d.ts +31 -10
  57. package/storage/remoteStorage/createArchives.ts +470 -253
  58. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +197 -0
  59. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +85 -0
  60. package/storage/remoteStorage/dist/accessPage.tsx.cache +222 -0
  61. package/storage/remoteStorage/dist/blobStore.ts.cache +837 -0
  62. package/storage/remoteStorage/dist/cliArgs.ts.cache +16 -0
  63. package/storage/remoteStorage/dist/createArchives.ts.cache +610 -0
  64. package/storage/remoteStorage/dist/remoteConfig.ts.cache +206 -0
  65. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +223 -0
  66. package/storage/remoteStorage/dist/storageController.ts.cache +500 -0
  67. package/storage/remoteStorage/dist/storageServer.ts.cache +106 -0
  68. package/storage/remoteStorage/dist/storageServerCli.ts.cache +39 -0
  69. package/storage/remoteStorage/dist/storageServerState.ts.cache +518 -0
  70. package/storage/remoteStorage/remoteConfig.d.ts +8 -0
  71. package/storage/remoteStorage/remoteConfig.ts +105 -5
  72. package/storage/remoteStorage/sourceWrapper.d.ts +41 -0
  73. package/storage/remoteStorage/sourceWrapper.ts +223 -0
  74. package/storage/remoteStorage/spec.md +31 -0
  75. package/storage/remoteStorage/storageController.d.ts +11 -0
  76. package/storage/remoteStorage/storageController.ts +109 -40
  77. package/storage/remoteStorage/storageServer.d.ts +0 -5
  78. package/storage/remoteStorage/storageServer.ts +17 -10
  79. package/storage/remoteStorage/storageServerCli.ts +0 -3
  80. package/storage/remoteStorage/storageServerState.d.ts +7 -1
  81. package/storage/remoteStorage/storageServerState.ts +230 -48
  82. package/testsite/server.ts +0 -2
  83. package/yarn.lock +11 -6
@@ -1,37 +1,39 @@
1
1
  module.allowclient = true;
2
2
 
3
- import fs from "fs";
4
- import os from "os";
5
- import { isNode } from "socket-function/src/misc";
3
+ import { isNode, sort } from "socket-function/src/misc";
6
4
  import {
7
5
  IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig,
8
- ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus,
6
+ ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, WRITE_PAST_WINDOW_GRACE, STORAGE_WRONG_VALID_WINDOW,
7
+ STORAGE_WRONG_ROUTE, FULL_ROUTE, VARIABLE_SHARD,
9
8
  } from "../IArchives";
10
9
  import {
11
- ROUTING_FILE, getConfigVersion, getBucketBaseUrl, parseHostedUrl,
10
+ ROUTING_FILE, getConfigVersion, parseHostedUrl, parseBackblazeUrl,
12
11
  normalizeRemoteConfig, normalizeSource, parseRoutingData, serializeRemoteConfig,
12
+ getRoute, routeContains, parseVariableRoute,
13
13
  } from "./remoteConfig";
14
14
  import { ArchivesRemote } from "./ArchivesRemote";
15
- import { ArchivesUrl } from "./ArchivesUrl";
16
15
  import { ArchivesBackblaze } from "../backblaze";
17
16
  import { getStorageServerConfigOptional, getLocalArchives } from "./storageServerState";
18
- import { STORAGE_ACCESS_DENIED } from "./storageController";
17
+ import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
19
18
 
20
- // Turns a RemoteConfig into a usable IArchives (createArchives). Setup is fully lazy: on the first
21
- // call we read the routing file (ROUTING_FILE) from every source, adopt the newest version found
22
- // (exactly ONE level of indirection - we never fetch routing configs from newly adopted sources),
23
- // and write the adopted config to every source that is missing it (which CREATES not-yet-existing
24
- // buckets) or holds an older version.
19
+ // Turns a RemoteConfig into a usable IArchives (createArchives). Initialization is lazy: on the
20
+ // first call we walk the sources IN ORDER and the first one that answers is authoritative (sources
21
+ // are synchronized copies of the same bucket, so we never consult the rest). Its stored routing
22
+ // config wins over the in-memory one - unless ours has a strictly newer version, in which case we
23
+ // write ours (creating the bucket when no routing exists at all). A failed init (all sources down,
24
+ // or the routing write was rejected) stays failed for callers but retries itself in the background
25
+ // with a ramping delay. Once running, we re-read the routing config every CONFIG_POLL_INTERVAL and
26
+ // rebuild the source list when it changed (reusing wrappers for unchanged sources).
25
27
 
26
- const DOWN_RETRY_DELAY = 30 * 1000;
27
- const ENSURE_RETRY_DELAY = 30 * 1000;
28
+ const CONFIG_POLL_INTERVAL = 5 * 60 * 1000;
28
29
 
29
30
  // The direct API IArchives for one source, with no URL-form fallback and no chaining. Used by the
30
- // storage server for its synchronization sources: hosted sources block waiting for account access
31
- // (logging instructions) when access hasn't been granted yet.
31
+ // storage server for its synchronization sources. Denied calls throw immediately (registering the
32
+ // access request in the background): the sync loops retry-and-log until access is granted, while
33
+ // explicit writes surface the denial (with grant instructions) to the caller instead of hanging.
32
34
  export function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives {
33
35
  if (source.type === "backblaze") {
34
- return new ArchivesBackblaze({ bucketName: source.bucketName, public: source.public, immutable: source.immutable });
36
+ return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable });
35
37
  }
36
38
  let parsed = parseHostedUrl(source.url);
37
39
  let server = isNode() && getStorageServerConfigOptional() || undefined;
@@ -40,270 +42,329 @@ export function createApiArchives(source: HostedConfig | BackblazeConfig): IArch
40
42
  // ourselves over HTTPS
41
43
  return getLocalArchives(parsed.account, parsed.bucketName);
42
44
  }
43
- return new ArchivesRemote({ url: source.url, accountName: source.accountName });
45
+ return new ArchivesRemote({ url: source.url, accountName: source.accountName, waitForAccess: false });
44
46
  }
45
47
 
46
- function hasBackblazeCreds(): boolean {
47
- return isNode() && fs.existsSync(os.homedir() + "/backblaze.json");
48
- }
49
-
50
- type ChainSource = {
51
- config: HostedConfig | BackblazeConfig;
52
- // Direct API access. Missing when we can only use the public-URL form (backblaze without
53
- // credentials, or backblaze in the browser).
54
- api?: IArchives;
55
- // Public-URL fallback, used when the API denies us access. Never created when the config
56
- // explicitly says public: false (then we don't even hit the URL).
57
- url?: ArchivesUrl;
58
- // Why writes to this source can never work (collected into the error when a write finds no
59
- // source that accepts it)
60
- writeBlocked?: string;
61
- // After a source errors we stop trying it until this time, so one down source doesn't stall
62
- // every call
63
- downUntil: number;
64
- // Whether we confirmed this source holds an up-to-date routing file. Writing that file creates
65
- // the bucket, so this also tracks which buckets we've initialized.
66
- routingEnsured: boolean;
67
- lastEnsureAttempt: number;
48
+ type ChainState = {
49
+ config: RemoteConfig;
50
+ sources: SourceWrapper[];
68
51
  };
69
52
 
70
- function buildChainSource(config: HostedConfig | BackblazeConfig): ChainSource {
71
- let source: ChainSource = { config, downUntil: 0, routingEnsured: false, lastEnsureAttempt: 0 };
72
- if (config.type === "backblaze") {
73
- if (hasBackblazeCreds()) {
74
- source.api = new ArchivesBackblaze({ bucketName: config.bucketName, public: config.public, immutable: config.immutable });
75
- } else if (isNode()) {
76
- source.writeBlocked = `No backblaze credentials for ${config.url}. Create ~/backblaze.json with { "applicationKeyId": ..., "applicationKey": ... } to enable API access.`;
77
- } else {
78
- source.writeBlocked = `Browsers cannot write to backblaze (bucket ${config.url})`;
79
- }
80
- if (!source.api && config.public !== false) {
81
- source.url = new ArchivesUrl(getBucketBaseUrl(config.url));
82
- }
83
- return source;
84
- }
85
- let parsed = parseHostedUrl(config.url);
86
- let server = isNode() && getStorageServerConfigOptional() || undefined;
87
- if (server && parsed.address === server.domain && parsed.port === server.port) {
88
- // A bucket hosted by our own process - use it directly instead of calling ourselves
89
- source.api = getLocalArchives(parsed.account, parsed.bucketName);
90
- return source;
91
- }
92
- source.api = new ArchivesRemote({ url: config.url, accountName: config.accountName, waitForAccess: false });
93
- if (config.public !== false) {
94
- source.url = new ArchivesUrl(getBucketBaseUrl(config.url));
95
- }
96
- return source;
53
+ function configWindowCurrent(config: HostedConfig | BackblazeConfig): boolean {
54
+ let now = Date.now();
55
+ let [start, end] = config.validWindow;
56
+ return start - WRITE_PAST_WINDOW_GRACE <= now && now <= end + WRITE_PAST_WINDOW_GRACE;
57
+ }
58
+
59
+ // Client writes target only the CURRENTLY valid source: a future-window source receives its data
60
+ // through server-side downstream propagation/backfill, never directly from clients
61
+ function configAcceptsWrites(config: HostedConfig | BackblazeConfig): boolean {
62
+ return configWindowCurrent(config);
97
63
  }
98
64
 
99
65
  export class ArchivesChain implements IArchives {
100
- private normalized: RemoteConfig;
101
- // The config we operate on: ours, or a newer-versioned one adopted from a source's routing file
102
- private adopted: RemoteConfig;
103
- private sourcesPromise: Promise<ChainSource[]> | undefined;
66
+ private configured: RemoteConfig;
67
+ // The config we actually run on (the authoritative stored one after init)
68
+ private activeConfig: RemoteConfig;
69
+ private statePromise: Promise<ChainState> | undefined;
70
+ private initRetryDelay = RETRY_START_DELAY;
71
+ private initRetryTimer: ReturnType<typeof setTimeout> | undefined;
72
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
73
+ private disposed = false;
104
74
 
105
75
  constructor(config: RemoteConfig | RemoteConfigBase) {
106
- this.normalized = normalizeRemoteConfig(config);
107
- this.adopted = this.normalized;
76
+ this.configured = normalizeRemoteConfig(config);
77
+ this.activeConfig = this.configured;
108
78
  }
109
79
 
110
80
  public getDebugName() {
111
- let urls = this.adopted.sources.map(x => typeof x === "string" && x || (x as HostedConfig | BackblazeConfig).url);
81
+ let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as HostedConfig | BackblazeConfig).url);
112
82
  return `chain/${urls.join(",")}`;
113
83
  }
114
84
 
115
- private getSourceConfigs(config: RemoteConfig): (HostedConfig | BackblazeConfig)[] {
116
- return config.sources.map(normalizeSource);
117
- }
118
-
119
- private getSources(): Promise<ChainSource[]> {
120
- if (!this.sourcesPromise) {
85
+ // Lazy init that rethrows its error to every caller, while a background timer resets and
86
+ // retries it with a ramping delay - so a chain that couldn't initialize fixes itself once a
87
+ // source comes back, without any caller having to drive it.
88
+ private getState(): Promise<ChainState> {
89
+ if (this.disposed) {
90
+ return Promise.reject(new Error(`ArchivesChain ${this.getDebugName()} has been disposed`));
91
+ }
92
+ if (!this.statePromise) {
121
93
  let promise = this.init();
122
- this.sourcesPromise = promise;
123
- // A failed init isn't cached, so a source coming up later fixes us
124
- promise.catch(() => {
125
- if (this.sourcesPromise === promise) {
126
- this.sourcesPromise = undefined;
127
- }
94
+ this.statePromise = promise;
95
+ promise.then(() => {
96
+ this.initRetryDelay = RETRY_START_DELAY;
97
+ }, (e: Error) => {
98
+ if (this.disposed || this.initRetryTimer) return;
99
+ console.error(`Storage init failed for ${this.getDebugName()}, retrying in ${Math.round(this.initRetryDelay / 1000)}s. ${e.stack ?? e}`);
100
+ this.initRetryTimer = setTimeout(() => {
101
+ this.initRetryTimer = undefined;
102
+ if (this.disposed) return;
103
+ if (this.statePromise === promise) {
104
+ this.statePromise = undefined;
105
+ }
106
+ this.getState().catch(() => { });
107
+ }, this.initRetryDelay);
108
+ (this.initRetryTimer as { unref?: () => void }).unref?.();
109
+ this.initRetryDelay = Math.min(RETRY_MAX_DELAY, this.initRetryDelay * RETRY_GROWTH);
128
110
  });
129
111
  }
130
- return this.sourcesPromise;
112
+ return this.statePromise;
131
113
  }
132
114
 
133
- private async init(): Promise<ChainSource[]> {
134
- let sources = this.getSourceConfigs(this.normalized).map(buildChainSource);
135
- let fetched = await Promise.all(sources.map(async source => {
115
+ private async init(): Promise<ChainState> {
116
+ let configs = this.configured.sources.map(normalizeSource);
117
+ let probeErrors: string[] = [];
118
+ let found: { probe: SourceWrapper; existing: RemoteConfig | undefined } | undefined;
119
+ for (let sourceConfig of configs) {
120
+ let probe = await SourceWrapper.create(sourceConfig, { background: false });
136
121
  try {
137
- let data = await this.readFromSource(source, archives => archives.get(ROUTING_FILE));
138
- return data && parseRoutingData(data) || undefined;
139
- } catch {
140
- // Down or access denied - ensureRouting retries reconciliation for this source later
141
- return undefined;
142
- }
143
- }));
144
- let best = this.normalized;
145
- for (let config of fetched) {
146
- if (config && getConfigVersion(config) > getConfigVersion(best)) {
147
- best = config;
122
+ let data = await probe.read(archives => archives.get(ROUTING_FILE));
123
+ found = { probe, existing: data && parseRoutingData(data) || undefined };
124
+ break;
125
+ } catch (e) {
126
+ probeErrors.push(`${sourceConfig.url}: ${(e as Error).stack ?? e}`);
127
+ probe.dispose();
148
128
  }
149
129
  }
150
- this.adopted = best;
151
- if (best !== this.normalized) {
152
- // Exactly one level of indirection: the adopted config's sources are used directly,
153
- // and we never fetch routing configs from THEM to adopt something newer again
154
- sources = this.getSourceConfigs(best).map(buildChainSource);
155
- await Promise.all(sources.map(source => this.ensureRouting(source)));
156
- return sources;
130
+ if (!found) {
131
+ throw new Error(`Cannot initialize storage for ${this.getDebugName()}: no source answered. ${probeErrors.join(" | ")}`);
157
132
  }
158
- for (let i = 0; i < sources.length; i++) {
159
- let config = fetched[i];
160
- if (config && getConfigVersion(config) >= getConfigVersion(best)) {
161
- sources[i].routingEnsured = true;
133
+ let active = this.configured;
134
+ let { probe, existing } = found;
135
+ let needsWrite = true;
136
+ if (existing && getConfigVersion(existing) >= getConfigVersion(this.configured)) {
137
+ if (getConfigVersion(existing) === getConfigVersion(this.configured) && JSON.stringify(existing) !== JSON.stringify(this.configured)) {
138
+ console.error(`Archives configuration updated without updating the version, for ${probe.config.url}. Updates will be ignored until you increase the version. Using: ${JSON.stringify(existing)}, ignoring: ${JSON.stringify(this.configured)}`);
162
139
  }
140
+ active = existing;
141
+ needsWrite = false;
163
142
  }
164
- await Promise.all(sources.map((source, i) => this.ensureRouting(source, { known: { existing: fetched[i] } })));
165
- return sources;
166
- }
167
-
168
- // Makes sure a source holds our adopted routing config, creating the bucket when it doesn't
169
- // exist yet (a bucket exists iff its routing file does) and upgrading older versions. Failures
170
- // (no access, source down) are swallowed - we retry periodically, and actual writes surface
171
- // the real error.
172
- private async ensureRouting(source: ChainSource, config?: { known?: { existing: RemoteConfig | undefined }; force?: boolean }): Promise<void> {
173
- if (source.routingEnsured) return;
174
- if (!config?.force && source.lastEnsureAttempt !== 0 && Date.now() - source.lastEnsureAttempt < ENSURE_RETRY_DELAY) return;
175
- source.lastEnsureAttempt = Date.now();
176
- let known = config?.known;
177
- try {
178
- let existing = known && known.existing;
179
- if (!known) {
180
- let data = await this.readFromSource(source, archives => archives.get(ROUTING_FILE));
181
- existing = data && parseRoutingData(data) || undefined;
143
+ let sources = await this.buildSources(active);
144
+ if (needsWrite) {
145
+ // We decided to write (ours is newer than the authoritative first-up source's, or no
146
+ // routing exists yet). The write goes to all sources, so now EVERY reachable source's
147
+ // stored routing is read, and the write is refused unless our version is strictly
148
+ // greater than all of them - a source already at (or past) our version means this
149
+ // version number is taken, and writing anyway would leave sources at the same version
150
+ // with different content, each rejecting the other's pushes forever.
151
+ let best: RemoteConfig | undefined;
152
+ let conflictUrl: string | undefined;
153
+ for (let source of sources) {
154
+ let data: Buffer | undefined;
155
+ try {
156
+ data = await source.read(archives => archives.get(ROUTING_FILE));
157
+ } catch {
158
+ // Down sources are still protected by the server-side version guard when the
159
+ // write eventually reaches them
160
+ continue;
161
+ }
162
+ if (!data) continue;
163
+ let stored = parseRoutingData(data);
164
+ if (!best || getConfigVersion(stored) > getConfigVersion(best)) {
165
+ best = stored;
166
+ }
167
+ if (getConfigVersion(stored) === getConfigVersion(this.configured) && JSON.stringify(stored) !== JSON.stringify(this.configured)) {
168
+ conflictUrl = source.config.url;
169
+ }
182
170
  }
183
- if (existing && getConfigVersion(existing) >= getConfigVersion(this.adopted)) {
184
- source.routingEnsured = true;
185
- return;
171
+ if (best && getConfigVersion(best) >= getConfigVersion(this.configured)) {
172
+ if (conflictUrl && getConfigVersion(best) === getConfigVersion(this.configured)) {
173
+ 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)}`);
174
+ }
175
+ active = best;
176
+ needsWrite = false;
177
+ for (let source of sources) {
178
+ source.dispose();
179
+ }
180
+ sources = await this.buildSources(active);
186
181
  }
187
- if (!source.api) return;
188
- await source.api.set(ROUTING_FILE, serializeRemoteConfig(this.adopted));
189
- source.routingEnsured = true;
190
- } catch { }
191
- }
192
-
193
- // Reads from one source, API first, falling back to the public-URL form when the API denies us
194
- // access. Access-denied does NOT mark the source down (access can be granted at any moment);
195
- // genuine failures do.
196
- private async readFromSource<T>(source: ChainSource, run: (archives: IArchives) => Promise<T>): Promise<T> {
197
- if (source.api) {
198
- try {
199
- return await run(source.api);
200
- } catch (e) {
201
- let denied = String((e as Error).stack || e).includes(STORAGE_ACCESS_DENIED);
202
- if (!denied) {
203
- source.downUntil = Date.now() + DOWN_RETRY_DELAY;
182
+ }
183
+ if (needsWrite) {
184
+ // The update only happens when EVERY source would accept it - a partial update would
185
+ // leave the sources out of sync, and a client without access retrying the write forever
186
+ // would never initialize. Without full access we run on the stored config (when there
187
+ // is one) and log the problem instead.
188
+ let missing: string[] = [];
189
+ for (let source of sources) {
190
+ try {
191
+ if (!await source.hasWriteAccess()) {
192
+ missing.push(source.config.url);
193
+ }
194
+ } catch (e) {
195
+ missing.push(`${source.config.url} (check failed: ${(e as Error).stack ?? e})`);
196
+ }
197
+ }
198
+ if (missing.length) {
199
+ 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.`}`);
200
+ if (existing) {
201
+ active = existing;
202
+ for (let source of sources) {
203
+ source.dispose();
204
+ }
205
+ sources = await this.buildSources(active);
206
+ }
207
+ } else {
208
+ try {
209
+ // A rejected write fails init, which retries from scratch - re-reading the
210
+ // routing, so losing a create race to another client just adopts their config
211
+ // on the next attempt.
212
+ await probe.write(archives => archives.set(ROUTING_FILE, serializeRemoteConfig(this.configured)));
213
+ } catch (e) {
214
+ for (let source of sources) {
215
+ source.dispose();
216
+ }
217
+ probe.dispose();
204
218
  throw e;
205
219
  }
206
- if (!source.url) throw e;
207
220
  }
208
221
  }
209
- if (!source.url) {
210
- throw new Error(`Source ${source.config.url} has no API access and no public URL form (public: false)`);
222
+ probe.dispose();
223
+ this.activeConfig = active;
224
+ this.startConfigPoll();
225
+ return { config: active, sources };
226
+ }
227
+
228
+ private async buildSources(config: RemoteConfig): Promise<SourceWrapper[]> {
229
+ let sources: SourceWrapper[] = [];
230
+ for (let sourceConfig of config.sources.map(normalizeSource)) {
231
+ let source = await SourceWrapper.create(sourceConfig);
232
+ // Latency (for variable-shard target preference) is tracked from initialization on
233
+ source.startPinging();
234
+ sources.push(source);
211
235
  }
236
+ return sources;
237
+ }
238
+
239
+ private startConfigPoll(): void {
240
+ if (this.pollTimer || this.disposed) return;
241
+ this.pollTimer = setInterval(() => {
242
+ void this.checkForNewConfig().catch((e: Error) => {
243
+ console.error(`Checking for a new storage routing config failed for ${this.getDebugName()}: ${e.stack ?? e}`);
244
+ });
245
+ }, CONFIG_POLL_INTERVAL);
246
+ (this.pollTimer as { unref?: () => void }).unref?.();
247
+ }
248
+
249
+ private async checkForNewConfig(): Promise<void> {
250
+ if (this.disposed || !this.statePromise) return;
251
+ let state: ChainState;
212
252
  try {
213
- return await run(source.url);
214
- } catch (e) {
215
- source.downUntil = Date.now() + DOWN_RETRY_DELAY;
216
- throw e;
253
+ state = await this.statePromise;
254
+ } catch {
255
+ // Init is failing; its own retry loop handles that
256
+ return;
217
257
  }
258
+ let data = await this.run(state, {}, archives => archives.get(ROUTING_FILE));
259
+ if (!data) return;
260
+ let latest = parseRoutingData(data);
261
+ if (JSON.stringify(latest) === JSON.stringify(state.config)) return;
262
+ console.log(`Storage routing config changed for ${this.getDebugName()}, rebuilding sources`);
263
+ let oldByConfig = new Map(state.sources.map(source => [JSON.stringify(source.config), source]));
264
+ let sources: SourceWrapper[] = [];
265
+ for (let sourceConfig of latest.sources.map(normalizeSource)) {
266
+ let key = JSON.stringify(sourceConfig);
267
+ let old = oldByConfig.get(key);
268
+ if (old) {
269
+ oldByConfig.delete(key);
270
+ sources.push(old);
271
+ } else {
272
+ sources.push(await SourceWrapper.create(sourceConfig));
273
+ }
274
+ }
275
+ // In-flight requests still hold the old wrappers and finish fine; dispose just stops any
276
+ // background reconnect loops
277
+ for (let leftover of oldByConfig.values()) {
278
+ leftover.dispose();
279
+ }
280
+ this.activeConfig = latest;
281
+ this.statePromise = Promise.resolve({ config: latest, sources });
218
282
  }
219
283
 
220
- // Tries each source in turn, skipping recently-down ones. A source that answers - even with
221
- // undefined / empty - is authoritative: sources are synchronized copies of the same bucket, so
222
- // a miss on one is a miss everywhere (we don't scan the rest).
223
- private async read<T>(config: { apiOnly?: boolean }, run: (archives: IArchives) => Promise<T>): Promise<T> {
224
- let sources = await this.getSources();
284
+ // Runs a request against the first available source (that covers the key's route, when one is
285
+ // given), moving to the next one ONLY when the source's WebSocket is down (checked directly -
286
+ // never by inspecting the error, which is arbitrary data). An error from a connected source is
287
+ // an application error and throws as-is. A down source gets its background reconnect kicked.
288
+ private async run<T>(state: ChainState, config: { apiOnly?: boolean; write?: boolean; route?: number }, run: (archives: IArchives) => Promise<T>): Promise<T> {
289
+ if (config.write) {
290
+ return await this.runWrite(state, config.route, run);
291
+ }
225
292
  let errors: string[] = [];
226
- for (let source of sources) {
227
- if (source.downUntil > Date.now()) {
228
- errors.push(`${source.config.url} is down, retrying after ${new Date(source.downUntil).toISOString()}`);
293
+ for (let source of state.sources) {
294
+ if (config.route !== undefined && !routeContains(source.config.route, config.route)) continue;
295
+ if (!configWindowCurrent(source.config)) continue;
296
+ if (!source.isConnected()) {
297
+ source.noteFailure();
298
+ errors.push(`${source.config.url} is not connected`);
229
299
  continue;
230
300
  }
231
- // Opportunistic (even on reads): create the bucket / upgrade its routing where needed
232
- void this.ensureRouting(source);
233
- if (config.apiOnly) {
234
- let api = source.api;
235
- if (!api) {
236
- errors.push(`${source.config.url} has URL-only access, which cannot serve this operation`);
237
- continue;
238
- }
239
- try {
240
- return await run(api);
241
- } catch (e) {
242
- if (!String((e as Error).stack || e).includes(STORAGE_ACCESS_DENIED)) {
243
- source.downUntil = Date.now() + DOWN_RETRY_DELAY;
301
+ try {
302
+ if (config.apiOnly) {
303
+ let api = source.api;
304
+ if (!api) {
305
+ errors.push(`${source.config.url} has URL-only access, which cannot serve this operation`);
306
+ continue;
244
307
  }
245
- errors.push(String(e));
246
- continue;
308
+ return await run(api);
247
309
  }
248
- }
249
- try {
250
- return await this.readFromSource(source, run);
310
+ return await source.read(run);
251
311
  } catch (e) {
252
- errors.push(String(e));
312
+ if (source.isConnected()) throw e;
313
+ source.noteFailure();
314
+ errors.push(String((e as Error).stack ?? e));
253
315
  }
254
316
  }
255
- throw new Error(`All sources failed for ${this.getDebugName()}: ${errors.join(" | ")}`);
317
+ throw new Error(`All sources failed for ${this.getDebugName()}${config.route !== undefined && ` (route ${config.route})` || ""}: ${errors.join(" | ") || "no sources available"}`);
256
318
  }
257
319
 
258
- private getAccessHelp(source: ChainSource): string {
259
- if (source.config.type === "remote") {
260
- let parsed = parseHostedUrl(source.config.url);
261
- let account = source.config.accountName || parsed.account;
262
- return `No write access to account ${JSON.stringify(account)} on ${parsed.address}:${parsed.port}. Visit https://${parsed.address}:${parsed.port}/${account} to grant this machine access.`;
263
- }
264
- return `Write to ${source.config.url} was denied (check the ~/backblaze.json credentials)`;
265
- }
266
-
267
- // Writes go to the first source that accepts them - the storage servers synchronize the
268
- // sources between themselves. If no source accepts the write, the error explains how to get
269
- // write access (the access page link, or the backblaze secret to create).
270
- private async write(run: (archives: IArchives) => Promise<void>): Promise<void> {
271
- let sources = await this.getSources();
272
- let errors: string[] = [];
273
- for (let source of sources) {
274
- if (source.config.syncOptions?.noWriteBack) continue;
275
- if (source.writeBlocked) {
276
- errors.push(source.writeBlocked);
277
- continue;
320
+ // Writes are consistent: they go to the first currently-valid source covering the key's route
321
+ // and NEVER fail over to another node - a client wrongly deciding nodes are down must not
322
+ // scatter its writes across the chain. (Reads fail over freely above: sources are synchronized
323
+ // copies, so reading from any of them is safe.) The one retry is for a window boundary passing
324
+ // (or a route disagreement) mid-write, which re-resolves the target rather than falling back.
325
+ private async runWrite<T>(state: ChainState, route: number | undefined, run: (archives: IArchives) => Promise<T>): Promise<T> {
326
+ let retriedWrongWindow = false;
327
+ let retriedWrongRoute = false;
328
+ while (true) {
329
+ let target = state.sources.find(x => configAcceptsWrites(x.config) && (route === undefined || routeContains(x.config.route, route)));
330
+ if (!target) {
331
+ throw new Error(`No source accepts writes for ${this.getDebugName()}${route !== undefined && ` (route ${route})` || ""} (every source is outside its valid window or outside the key's route)`);
278
332
  }
279
- let api = source.api;
280
- if (!api) continue;
281
- if (source.downUntil > Date.now()) {
282
- errors.push(`${source.config.url} is down, retrying after ${new Date(source.downUntil).toISOString()}`);
283
- continue;
333
+ if (!target.isConnected()) {
334
+ target.noteFailure();
335
+ throw new Error(`Cannot write: the write target ${target.config.url} is not connected (writes never fail over to other sources)`);
284
336
  }
285
- // The write needs the bucket to exist, so this one is awaited (and never throttled)
286
- await this.ensureRouting(source, { force: true });
287
337
  try {
288
- return await run(api);
338
+ return await target.write(run);
289
339
  } catch (e) {
290
- if (String((e as Error).stack || e).includes(STORAGE_ACCESS_DENIED)) {
291
- errors.push(this.getAccessHelp(source));
292
- } else {
293
- source.downUntil = Date.now() + DOWN_RETRY_DELAY;
294
- errors.push(String(e));
340
+ let message = String((e as Error).stack ?? e);
341
+ if (message.includes(STORAGE_WRONG_VALID_WINDOW) && !retriedWrongWindow) {
342
+ retriedWrongWindow = true;
343
+ continue;
344
+ }
345
+ if (message.includes(STORAGE_WRONG_ROUTE) && !retriedWrongRoute) {
346
+ retriedWrongRoute = true;
347
+ continue;
295
348
  }
349
+ if (!target.isConnected()) {
350
+ target.noteFailure();
351
+ }
352
+ throw e;
296
353
  }
297
354
  }
298
- throw new Error(`Write failed on every source of ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
355
+ }
356
+
357
+ private async request<T>(config: { apiOnly?: boolean; write?: boolean; route?: number }, run: (archives: IArchives) => Promise<T>): Promise<T> {
358
+ let state = await this.getState();
359
+ return await this.run(state, config, run);
299
360
  }
300
361
 
301
362
  // The access-page link (plus our machineId/ip, for the approver to match the request) for the
302
363
  // first hosted source that hasn't granted us access. undefined when we have access everywhere
303
364
  // we can have it. Also registers the access request server-side.
304
365
  public async waitingForAccess(): Promise<{ link: string; machineId: string; ip: string } | undefined> {
305
- let sources = await this.getSources();
306
- for (let source of sources) {
366
+ let state = await this.getState();
367
+ for (let source of state.sources) {
307
368
  if (source.api instanceof ArchivesRemote) {
308
369
  let waiting = await source.api.waitingForAccess();
309
370
  if (waiting) return waiting;
@@ -316,80 +377,236 @@ export class ArchivesChain implements IArchives {
316
377
  let result = await this.get2(fileName, config);
317
378
  return result && result.data || undefined;
318
379
  }
319
- public async get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined> {
320
- return await this.read({}, archives => archives.get2(fileName, config));
380
+ public async get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
381
+ return await this.request({ route: getRoute(fileName) }, archives => archives.get2(fileName, config));
321
382
  }
322
383
  public async getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined> {
323
- return await this.read({}, archives => archives.getInfo(fileName));
384
+ return await this.request({ route: getRoute(fileName) }, archives => archives.getInfo(fileName));
324
385
  }
386
+
387
+ // A minimal set of connected, currently-valid API sources whose routes cover [0, 1) - listing
388
+ // operations must merge every shard. Greedy sweep: repeatedly take the source that extends
389
+ // coverage the furthest. In practice this is one source (unsharded), or one per shard.
390
+ private selectCoveringSources(state: ChainState): SourceWrapper[] {
391
+ let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api && x.isConnected());
392
+ let chosen: SourceWrapper[] = [];
393
+ let covered = 0;
394
+ while (covered < 1) {
395
+ let best: SourceWrapper | undefined;
396
+ let bestEnd = covered;
397
+ for (let source of candidates) {
398
+ let [start, end] = source.config.route || FULL_ROUTE;
399
+ if (start > covered) continue;
400
+ if (end > bestEnd) {
401
+ bestEnd = end;
402
+ best = source;
403
+ }
404
+ }
405
+ if (!best) {
406
+ throw new Error(`Cannot cover the full route space for ${this.getDebugName()}: the available sources only cover up to ${covered} (some shards are down or URL-only)`);
407
+ }
408
+ chosen.push(best);
409
+ covered = bestEnd;
410
+ }
411
+ return chosen;
412
+ }
413
+
414
+ private async runOnApi<T>(source: SourceWrapper, run: (archives: IArchives) => Promise<T>): Promise<T> {
415
+ let api = source.api;
416
+ if (!api) {
417
+ throw new Error(`${source.config.url} has URL-only access, which cannot serve this operation`);
418
+ }
419
+ return await run(api);
420
+ }
421
+
325
422
  public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
326
- return await this.read({ apiOnly: true }, archives => archives.find(prefix, config));
423
+ return (await this.findInfo(prefix, config)).map(x => x.path);
327
424
  }
328
425
  public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
329
- return await this.read({ apiOnly: true }, archives => archives.findInfo(prefix, config));
426
+ let state = await this.getState();
427
+ let covering = this.selectCoveringSources(state);
428
+ let results = await Promise.all(covering.map(source => this.runOnApi(source, archives => archives.findInfo(prefix, config))));
429
+ // Overlapping shards can both report a path; the newest wins
430
+ let byPath = new Map<string, ArchiveFileInfo>();
431
+ for (let list of results) {
432
+ for (let file of list) {
433
+ let existing = byPath.get(file.path);
434
+ if (!existing || file.createTime > existing.createTime) {
435
+ byPath.set(file.path, file);
436
+ }
437
+ }
438
+ }
439
+ let merged = [...byPath.values()];
440
+ sort(merged, x => x.path);
441
+ return merged;
330
442
  }
331
443
  public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
332
- return await this.read({ apiOnly: true }, async archives => {
444
+ let state = await this.getState();
445
+ let covering = this.selectCoveringSources(state);
446
+ let results = await Promise.all(covering.map(source => this.runOnApi(source, async archives => {
333
447
  if (!archives.getChangesAfter) {
334
448
  throw new Error(`${archives.getDebugName()} does not support getChangesAfter`);
335
449
  }
336
450
  return await archives.getChangesAfter(time);
337
- });
451
+ })));
452
+ let byPath = new Map<string, ArchiveFileInfo>();
453
+ for (let list of results) {
454
+ for (let file of list) {
455
+ let existing = byPath.get(file.path);
456
+ if (!existing || file.createTime > existing.createTime) {
457
+ byPath.set(file.path, file);
458
+ }
459
+ }
460
+ }
461
+ let merged = [...byPath.values()];
462
+ sort(merged, x => x.path);
463
+ return merged;
338
464
  }
339
465
  public async getSyncStatus(): Promise<ArchivesSyncStatus> {
340
- return await this.read({ apiOnly: true }, async archives => {
466
+ let state = await this.getState();
467
+ let covering = this.selectCoveringSources(state);
468
+ let statuses = await Promise.all(covering.map(source => this.runOnApi(source, async archives => {
341
469
  if (!archives.getSyncStatus) {
342
470
  throw new Error(`${archives.getDebugName()} does not support getSyncStatus`);
343
471
  }
344
472
  return await archives.getSyncStatus();
345
- });
473
+ })));
474
+ return {
475
+ allScansComplete: statuses.every(x => x.allScansComplete),
476
+ indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
477
+ sources: statuses.flatMap(x => x.sources),
478
+ };
346
479
  }
347
480
  public async getConfig(): Promise<ArchivesConfig> {
348
- let sources = await this.getSources();
349
- if (!sources.some(x => x.api)) return {};
350
- return await this.read({ apiOnly: true }, archives => archives.getConfig());
481
+ let state = await this.getState();
482
+ if (!state.sources.some(x => x.api)) return { remoteConfig: state.config };
483
+ let config = await this.run(state, { apiOnly: true }, archives => archives.getConfig());
484
+ return { ...config, remoteConfig: state.config };
485
+ }
486
+ /** True only when EVERY write-receiving source would accept our writes (partial write access
487
+ * desynchronizes sources, so it counts as no access). */
488
+ public async hasWriteAccess(): Promise<boolean> {
489
+ let state = await this.getState();
490
+ for (let source of state.sources) {
491
+ if (!configAcceptsWrites(source.config)) continue;
492
+ if (!await source.hasWriteAccess()) return false;
493
+ }
494
+ return true;
495
+ }
496
+
497
+ private assertNotBareVariableShard(fileName: string): void {
498
+ if (fileName.includes(VARIABLE_SHARD) && parseVariableRoute(fileName) === undefined) {
499
+ throw new Error(`Keys containing VARIABLE_SHARD must be written with setVariableShard, which materializes the shard value and returns the full key. Key: ${JSON.stringify(fileName)}`);
500
+ }
351
501
  }
352
502
 
353
503
  public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
354
- await this.write(archives => archives.set(fileName, data, config));
504
+ this.assertNotBareVariableShard(fileName);
505
+ await this.request({ write: true, route: getRoute(fileName) }, archives => archives.set(fileName, data, config));
355
506
  }
356
507
  public async del(fileName: string): Promise<void> {
357
- await this.write(archives => archives.del(fileName));
508
+ await this.request({ write: true, route: getRoute(fileName) }, archives => archives.del(fileName));
358
509
  }
359
- public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
360
- let sources = await this.getSources();
510
+
511
+ /** Writes a key containing the VARIABLE_SHARD sentinel: picks the lowest-latency up write
512
+ * shard, materializes the key with a random value inside that shard's route, writes it, and
513
+ * returns the FULL key actually written (the caller needs it to ever read the value back).
514
+ * Unlike normal writes this CAN move to another shard when the preferred one is down (error +
515
+ * socket down, same rule as reads) - each shard receives a different key, so write
516
+ * consistency is preserved. */
517
+ public async setVariableShard(key: string, data: Buffer, config?: { lastModified?: number }): Promise<string> {
518
+ if (!key.includes(VARIABLE_SHARD)) {
519
+ throw new Error(`Expected the key to contain the VARIABLE_SHARD sentinel, was ${JSON.stringify(key)}`);
520
+ }
521
+ if (parseVariableRoute(key) !== undefined) {
522
+ throw new Error(`The key already has a materialized shard value; write it with set instead. Key: ${JSON.stringify(key)}`);
523
+ }
524
+ let state = await this.getState();
525
+ // Per-shard write consistency still holds: within a route, only the FIRST source may take
526
+ // writes - latency only picks WHICH shard the key materializes into
527
+ let targetsByRoute = new Map<string, SourceWrapper>();
528
+ for (let source of state.sources) {
529
+ if (!configAcceptsWrites(source.config)) continue;
530
+ let routeKey = JSON.stringify(source.config.route || FULL_ROUTE);
531
+ if (!targetsByRoute.has(routeKey)) {
532
+ targetsByRoute.set(routeKey, source);
533
+ }
534
+ }
535
+ let targets = [...targetsByRoute.values()];
536
+ sort(targets, x => x.getLatency());
361
537
  let errors: string[] = [];
362
- for (let source of sources) {
363
- if (source.config.syncOptions?.noWriteBack) continue;
364
- if (source.writeBlocked) {
365
- errors.push(source.writeBlocked);
538
+ for (let target of targets) {
539
+ if (!target.isConnected()) {
540
+ target.noteFailure();
541
+ errors.push(`${target.config.url} is not connected`);
366
542
  continue;
367
543
  }
368
- if (!source.api) continue;
369
- if (source.downUntil > Date.now()) {
370
- errors.push(`${source.config.url} is down, retrying after ${new Date(source.downUntil).toISOString()}`);
371
- continue;
544
+ let [start, end] = target.config.route || FULL_ROUTE;
545
+ let fullKey = key.replace(VARIABLE_SHARD, VARIABLE_SHARD + "_" + (start + Math.random() * (end - start)));
546
+ try {
547
+ await target.write(archives => archives.set(fullKey, data, config));
548
+ return fullKey;
549
+ } catch (e) {
550
+ // An error alone doesn't justify moving on (it would be an application error); the
551
+ // socket must also be down
552
+ if (target.isConnected()) throw e;
553
+ target.noteFailure();
554
+ errors.push(String((e as Error).stack ?? e));
372
555
  }
373
- await this.ensureRouting(source, { force: true });
374
- // The data stream cannot be rewound, so there is no failover to later sources here
375
- return await source.api.setLargeFile(config);
376
556
  }
377
- throw new Error(`No writable source for setLargeFile on ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
557
+ throw new Error(`Every variable-shard write target failed for ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
558
+ }
559
+
560
+ public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
561
+ this.assertNotBareVariableShard(config.path);
562
+ let state = await this.getState();
563
+ // Same consistency rule as runWrite: the first currently-valid source for the route, or nothing
564
+ let route = getRoute(config.path);
565
+ let target = state.sources.find(x => configAcceptsWrites(x.config) && routeContains(x.config.route, route));
566
+ if (!target) {
567
+ throw new Error(`No source accepts writes for setLargeFile on ${this.getDebugName()} (route ${route})`);
568
+ }
569
+ if (!target.isConnected()) {
570
+ target.noteFailure();
571
+ throw new Error(`Cannot write: the write target ${target.config.url} is not connected (writes never fail over to other sources)`);
572
+ }
573
+ await target.write(archives => archives.setLargeFile(config));
378
574
  }
379
575
 
380
576
  public async getURL(path: string): Promise<string> {
381
- let sources = await this.getSources();
382
- for (let source of sources) {
577
+ let state = await this.getState();
578
+ let route = getRoute(path);
579
+ for (let source of state.sources) {
383
580
  if (source.config.public === false) continue;
581
+ if (!routeContains(source.config.route, route)) continue;
384
582
  if (source.url) return await source.url.getURL(path);
385
583
  if (source.api) return await source.api.getURL(path);
386
584
  }
387
- throw new Error(`No public source to build a URL from for ${this.getDebugName()} (every source has public: false)`);
585
+ throw new Error(`No public source covering route ${route} to build a URL from for ${this.getDebugName()}`);
586
+ }
587
+
588
+ public dispose(): void {
589
+ this.disposed = true;
590
+ if (this.pollTimer) {
591
+ clearInterval(this.pollTimer);
592
+ }
593
+ if (this.initRetryTimer) {
594
+ clearTimeout(this.initRetryTimer);
595
+ }
596
+ let statePromise = this.statePromise;
597
+ if (statePromise) {
598
+ void statePromise.then(state => {
599
+ for (let source of state.sources) {
600
+ source.dispose();
601
+ }
602
+ }, () => { });
603
+ }
388
604
  }
389
605
  }
390
606
 
391
607
  // The IArchives for a RemoteConfig (or a single source - a routing URL string works). Fully lazy:
392
- // nothing is contacted until the first call.
608
+ // nothing is contacted until the first call. Call dispose() when done with it, so its background
609
+ // retry/poll loops stop.
393
610
  export function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain {
394
611
  return new ArchivesChain(config);
395
612
  }