sliftutils 1.7.15 → 1.7.17
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.
- package/index.d.ts +8 -2
- package/misc/https/hostServer.d.ts +1 -1
- package/misc/https/hostServer.ts +33 -50
- package/package.json +2 -2
- package/storage/dist/ArchivesDisk.ts.cache +3 -2
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/backblaze.ts.cache +10 -6
- package/storage/remoteStorage/createArchives.d.ts +6 -1
- package/storage/remoteStorage/createArchives.ts +263 -177
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +3 -2
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +2 -2
- package/storage/remoteStorage/dist/blobStore.ts.cache +6 -5
- package/storage/remoteStorage/dist/createArchives.ts.cache +295 -206
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +6 -7
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +10 -2
- package/storage/remoteStorage/dist/storageServerState.ts.cache +3 -2
- package/storage/remoteStorage/sourceWrapper.d.ts +1 -0
- package/storage/remoteStorage/sourceWrapper.ts +8 -0
- package/yarn.lock +4 -4
|
@@ -36,6 +36,9 @@ const WRONG_TARGET_BOUNDARY_RETRY_DELAY = 15 * 1000;
|
|
|
36
36
|
// Otherwise our config is stale; re-fetch it from the server, at most this often (when throttled,
|
|
37
37
|
// retry with what we have)
|
|
38
38
|
const CONFIG_REFRESH_THROTTLE = 30 * 1000;
|
|
39
|
+
// When every source looks down, all of them are re-contacted (routing re-read + connection
|
|
40
|
+
// re-attempt) before giving up - at most this often
|
|
41
|
+
const AVAILABILITY_RECHECK_THROTTLE = 5 * 1000;
|
|
39
42
|
|
|
40
43
|
// The direct API IArchives for one source, with no URL-form fallback and no chaining. Used by the
|
|
41
44
|
// storage server for its synchronization sources. Denied calls throw immediately (registering the
|
|
@@ -124,115 +127,109 @@ export class ArchivesChain implements IArchives {
|
|
|
124
127
|
|
|
125
128
|
private async init(): Promise<ChainState> {
|
|
126
129
|
let configs = this.configured.sources.map(normalizeSource);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
// EVERY source is contacted for its picture of the routing config (per the spec: the config
|
|
131
|
+
// is duplicated into all of them) - which also establishes our connection to every node up
|
|
132
|
+
// front. Contact is parallel; ADOPTION is deterministic by config order among the sources
|
|
133
|
+
// that answered (the first up source is authoritative).
|
|
134
|
+
let fetches = await Promise.all(configs.map(async sourceConfig => {
|
|
130
135
|
let probe = await SourceWrapper.create(sourceConfig, { background: false });
|
|
131
136
|
try {
|
|
132
137
|
let data = await probe.read(archives => archives.get(ROUTING_FILE));
|
|
133
|
-
|
|
134
|
-
break;
|
|
138
|
+
return { probe, sourceConfig, responded: true, existing: data && parseRoutingData(data) || undefined, error: "" };
|
|
135
139
|
} catch (e) {
|
|
136
|
-
|
|
137
|
-
probe.dispose();
|
|
140
|
+
return { probe, sourceConfig, responded: false, existing: undefined, error: `${sourceConfig.url}: ${(e as Error).stack ?? e}` };
|
|
138
141
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let { probe, existing } = found;
|
|
145
|
-
let needsWrite = true;
|
|
146
|
-
if (existing && getConfigVersion(existing) >= getConfigVersion(this.configured)) {
|
|
147
|
-
if (getConfigVersion(existing) === getConfigVersion(this.configured) && JSON.stringify(existing) !== JSON.stringify(this.configured)) {
|
|
148
|
-
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)}`);
|
|
149
|
-
}
|
|
150
|
-
active = existing;
|
|
151
|
-
needsWrite = false;
|
|
152
|
-
}
|
|
153
|
-
let sources = await this.buildSources(active);
|
|
154
|
-
if (needsWrite) {
|
|
155
|
-
// We decided to write (ours is newer than the authoritative first-up source's, or no
|
|
156
|
-
// routing exists yet). The write goes to all sources, so now EVERY reachable source's
|
|
157
|
-
// stored routing is read, and the write is refused unless our version is strictly
|
|
158
|
-
// greater than all of them - a source already at (or past) our version means this
|
|
159
|
-
// version number is taken, and writing anyway would leave sources at the same version
|
|
160
|
-
// with different content, each rejecting the other's pushes forever.
|
|
161
|
-
let best: RemoteConfig | undefined;
|
|
162
|
-
let conflictUrl: string | undefined;
|
|
163
|
-
for (let source of sources) {
|
|
164
|
-
let data: Buffer | undefined;
|
|
165
|
-
try {
|
|
166
|
-
data = await source.read(archives => archives.get(ROUTING_FILE));
|
|
167
|
-
} catch {
|
|
168
|
-
// Down sources are still protected by the server-side version guard when the
|
|
169
|
-
// write eventually reaches them
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
if (!data) continue;
|
|
173
|
-
let stored = parseRoutingData(data);
|
|
174
|
-
if (!best || getConfigVersion(stored) > getConfigVersion(best)) {
|
|
175
|
-
best = stored;
|
|
176
|
-
}
|
|
177
|
-
if (getConfigVersion(stored) === getConfigVersion(this.configured) && JSON.stringify(stored) !== JSON.stringify(this.configured)) {
|
|
178
|
-
conflictUrl = source.config.url;
|
|
179
|
-
}
|
|
142
|
+
}));
|
|
143
|
+
try {
|
|
144
|
+
let found = fetches.find(x => x.responded);
|
|
145
|
+
if (!found) {
|
|
146
|
+
throw new Error(`Cannot initialize storage for ${this.getDebugName()}: no source answered. ${fetches.map(x => x.error).join(" | ")}`);
|
|
180
147
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
148
|
+
let existing = found.existing;
|
|
149
|
+
let active = this.configured;
|
|
150
|
+
let needsWrite = true;
|
|
151
|
+
if (existing && getConfigVersion(existing) >= getConfigVersion(this.configured)) {
|
|
152
|
+
if (getConfigVersion(existing) === getConfigVersion(this.configured) && JSON.stringify(existing) !== JSON.stringify(this.configured)) {
|
|
153
|
+
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)}`);
|
|
184
154
|
}
|
|
185
|
-
active =
|
|
155
|
+
active = existing;
|
|
186
156
|
needsWrite = false;
|
|
187
|
-
for (let source of sources) {
|
|
188
|
-
source.dispose();
|
|
189
|
-
}
|
|
190
|
-
sources = await this.buildSources(active);
|
|
191
157
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
158
|
+
if (needsWrite) {
|
|
159
|
+
// We decided to write (ours is newer than the authoritative first-up source's, or
|
|
160
|
+
// no routing exists yet). The write goes to all sources, so it is refused unless
|
|
161
|
+
// our version is strictly greater than EVERY source's stored version (all already
|
|
162
|
+
// fetched above) - a source at (or past) our version means this version number is
|
|
163
|
+
// taken, and writing anyway would leave sources at the same version with different
|
|
164
|
+
// content, each rejecting the other's pushes forever. Down sources are still
|
|
165
|
+
// protected by the server-side version guard when the write eventually reaches them.
|
|
166
|
+
let best: RemoteConfig | undefined;
|
|
167
|
+
let conflictUrl: string | undefined;
|
|
168
|
+
for (let fetch of fetches) {
|
|
169
|
+
let stored = fetch.existing;
|
|
170
|
+
if (!stored) continue;
|
|
171
|
+
if (!best || getConfigVersion(stored) > getConfigVersion(best)) {
|
|
172
|
+
best = stored;
|
|
203
173
|
}
|
|
204
|
-
|
|
205
|
-
|
|
174
|
+
if (getConfigVersion(stored) === getConfigVersion(this.configured) && JSON.stringify(stored) !== JSON.stringify(this.configured)) {
|
|
175
|
+
conflictUrl = fetch.sourceConfig.url;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (best && getConfigVersion(best) >= getConfigVersion(this.configured)) {
|
|
179
|
+
if (conflictUrl && getConfigVersion(best) === getConfigVersion(this.configured)) {
|
|
180
|
+
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)}`);
|
|
181
|
+
}
|
|
182
|
+
active = best;
|
|
183
|
+
needsWrite = false;
|
|
206
184
|
}
|
|
207
185
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
186
|
+
let sources = await this.buildSources(active);
|
|
187
|
+
if (needsWrite) {
|
|
188
|
+
// The update only happens when EVERY source would accept it - a partial update
|
|
189
|
+
// would leave the sources out of sync, and a client without access retrying the
|
|
190
|
+
// write forever would never initialize. Without full access we run on the stored
|
|
191
|
+
// config (when there is one) and log the problem instead.
|
|
192
|
+
let missing: string[] = [];
|
|
193
|
+
for (let source of sources) {
|
|
194
|
+
try {
|
|
195
|
+
if (!await source.hasWriteAccess()) {
|
|
196
|
+
missing.push(source.config.url);
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
199
|
+
missing.push(`${source.config.url} (check failed: ${(e as Error).stack ?? e})`);
|
|
214
200
|
}
|
|
215
|
-
sources = await this.buildSources(active);
|
|
216
201
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
202
|
+
if (missing.length) {
|
|
203
|
+
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.`}`);
|
|
204
|
+
if (existing) {
|
|
205
|
+
active = existing;
|
|
206
|
+
for (let source of sources) {
|
|
207
|
+
source.dispose();
|
|
208
|
+
}
|
|
209
|
+
sources = await this.buildSources(active);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
try {
|
|
213
|
+
// A rejected write fails init, which retries from scratch - re-reading the
|
|
214
|
+
// routing, so losing a create race to another client just adopts their
|
|
215
|
+
// config on the next attempt.
|
|
216
|
+
await found.probe.write(archives => archives.set(ROUTING_FILE, serializeRemoteConfig(this.configured)));
|
|
217
|
+
} catch (e) {
|
|
218
|
+
for (let source of sources) {
|
|
219
|
+
source.dispose();
|
|
220
|
+
}
|
|
221
|
+
throw e;
|
|
226
222
|
}
|
|
227
|
-
probe.dispose();
|
|
228
|
-
throw e;
|
|
229
223
|
}
|
|
230
224
|
}
|
|
225
|
+
this.activeConfig = active;
|
|
226
|
+
this.startConfigPoll();
|
|
227
|
+
return { config: active, sources };
|
|
228
|
+
} finally {
|
|
229
|
+
for (let fetch of fetches) {
|
|
230
|
+
fetch.probe.dispose();
|
|
231
|
+
}
|
|
231
232
|
}
|
|
232
|
-
probe.dispose();
|
|
233
|
-
this.activeConfig = active;
|
|
234
|
-
this.startConfigPoll();
|
|
235
|
-
return { config: active, sources };
|
|
236
233
|
}
|
|
237
234
|
|
|
238
235
|
private async buildSources(config: RemoteConfig): Promise<SourceWrapper[]> {
|
|
@@ -293,6 +290,10 @@ export class ArchivesChain implements IArchives {
|
|
|
293
290
|
}
|
|
294
291
|
let latest = await this.fetchLatestConfig(state);
|
|
295
292
|
if (!latest) return;
|
|
293
|
+
await this.adoptNewConfig(state, latest);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async adoptNewConfig(state: ChainState, latest: RemoteConfig): Promise<void> {
|
|
296
297
|
if (JSON.stringify(latest) === JSON.stringify(state.config)) return;
|
|
297
298
|
console.log(`Storage routing config changed for ${this.getDebugName()}, rebuilding sources`);
|
|
298
299
|
let oldByConfig = new Map(state.sources.map(source => [JSON.stringify(source.config), source]));
|
|
@@ -304,11 +305,13 @@ export class ArchivesChain implements IArchives {
|
|
|
304
305
|
oldByConfig.delete(key);
|
|
305
306
|
sources.push(old);
|
|
306
307
|
} else {
|
|
307
|
-
|
|
308
|
+
let source = await SourceWrapper.create(sourceConfig);
|
|
309
|
+
source.startPinging();
|
|
310
|
+
sources.push(source);
|
|
308
311
|
}
|
|
309
312
|
}
|
|
310
313
|
// In-flight requests still hold the old wrappers and finish fine; dispose just stops any
|
|
311
|
-
// background reconnect loops
|
|
314
|
+
// background reconnect/ping loops
|
|
312
315
|
for (let leftover of oldByConfig.values()) {
|
|
313
316
|
leftover.dispose();
|
|
314
317
|
}
|
|
@@ -316,6 +319,42 @@ export class ArchivesChain implements IArchives {
|
|
|
316
319
|
this.statePromise = Promise.resolve({ config: latest, sources });
|
|
317
320
|
}
|
|
318
321
|
|
|
322
|
+
// When every source looks down, the routing config is re-read from EVERY source - which both
|
|
323
|
+
// re-attempts their connections (our liveness re-check) and discovers a routing update we may
|
|
324
|
+
// have missed (adopting it re-initializes the source list). Concurrent callers share one pass.
|
|
325
|
+
private lastAvailabilityRecheck = 0;
|
|
326
|
+
private availabilityRecheckInFlight: Promise<void> | undefined;
|
|
327
|
+
private recheckAvailability(): Promise<void> {
|
|
328
|
+
if (this.availabilityRecheckInFlight) return this.availabilityRecheckInFlight;
|
|
329
|
+
if (Date.now() - this.lastAvailabilityRecheck < AVAILABILITY_RECHECK_THROTTLE) return Promise.resolve();
|
|
330
|
+
this.lastAvailabilityRecheck = Date.now();
|
|
331
|
+
this.availabilityRecheckInFlight = this.recheckAvailabilityNow().finally(() => {
|
|
332
|
+
this.availabilityRecheckInFlight = undefined;
|
|
333
|
+
});
|
|
334
|
+
return this.availabilityRecheckInFlight;
|
|
335
|
+
}
|
|
336
|
+
private async recheckAvailabilityNow(): Promise<void> {
|
|
337
|
+
if (this.disposed || !this.statePromise) return;
|
|
338
|
+
let state: ChainState;
|
|
339
|
+
try {
|
|
340
|
+
state = await this.statePromise;
|
|
341
|
+
} catch {
|
|
342
|
+
// Init is failing; its own retry loop handles that
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
let results = await Promise.all(state.sources.map(async source => {
|
|
346
|
+
try {
|
|
347
|
+
let data = await source.read(archives => archives.get(ROUTING_FILE));
|
|
348
|
+
return data && parseRoutingData(data) || undefined;
|
|
349
|
+
} catch {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
}));
|
|
353
|
+
let latest = results.find(x => x);
|
|
354
|
+
if (!latest) return;
|
|
355
|
+
await this.adoptNewConfig(state, latest);
|
|
356
|
+
}
|
|
357
|
+
|
|
319
358
|
// Runs a request against the first available source (that covers the key's route, when one is
|
|
320
359
|
// given), moving to the next one ONLY when the source's WebSocket is down (checked directly -
|
|
321
360
|
// never by inspecting the error, which is arbitrary data). An error from a connected source is
|
|
@@ -324,32 +363,38 @@ export class ArchivesChain implements IArchives {
|
|
|
324
363
|
if (config.write) {
|
|
325
364
|
return await this.runWrite(config.route, run);
|
|
326
365
|
}
|
|
327
|
-
let
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
source.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
continue;
|
|
366
|
+
let recheckedAvailability = false;
|
|
367
|
+
while (true) {
|
|
368
|
+
let errors: string[] = [];
|
|
369
|
+
for (let source of state.sources) {
|
|
370
|
+
if (config.route !== undefined && !routeContains(source.config.route, config.route)) continue;
|
|
371
|
+
if (!configWindowCurrent(source.config)) continue;
|
|
372
|
+
try {
|
|
373
|
+
if (config.apiOnly) {
|
|
374
|
+
let api = source.api;
|
|
375
|
+
if (!api) {
|
|
376
|
+
errors.push(`${source.config.url} has URL-only access, which cannot serve this operation`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
return await run(api);
|
|
342
380
|
}
|
|
343
|
-
return await run
|
|
381
|
+
return await source.read(run);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
if (source.isConnected()) throw e;
|
|
384
|
+
source.noteFailure();
|
|
385
|
+
errors.push(String((e as Error).stack ?? e));
|
|
344
386
|
}
|
|
345
|
-
return await source.read(run);
|
|
346
|
-
} catch (e) {
|
|
347
|
-
if (source.isConnected()) throw e;
|
|
348
|
-
source.noteFailure();
|
|
349
|
-
errors.push(String((e as Error).stack ?? e));
|
|
350
387
|
}
|
|
388
|
+
// Every source failed: re-contact everything (routing re-read + connection re-attempt,
|
|
389
|
+
// possibly adopting a routing update) and try once more before giving up
|
|
390
|
+
if (!recheckedAvailability) {
|
|
391
|
+
recheckedAvailability = true;
|
|
392
|
+
await this.recheckAvailability();
|
|
393
|
+
state = await this.getState();
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`All sources failed for ${this.getDebugName()}${config.route !== undefined && ` (route ${config.route})` || ""}: ${errors.join(" | ") || "no sources available"}`);
|
|
351
397
|
}
|
|
352
|
-
throw new Error(`All sources failed for ${this.getDebugName()}${config.route !== undefined && ` (route ${config.route})` || ""}: ${errors.join(" | ") || "no sources available"}`);
|
|
353
398
|
}
|
|
354
399
|
|
|
355
400
|
// Writes are consistent: they go to the first currently-valid source covering the key's route
|
|
@@ -360,16 +405,20 @@ export class ArchivesChain implements IArchives {
|
|
|
360
405
|
private async runWrite<T>(route: number | undefined, run: (archives: IArchives) => Promise<T>): Promise<T> {
|
|
361
406
|
let retriedWrongWindow = false;
|
|
362
407
|
let retriedWrongRoute = false;
|
|
408
|
+
let recheckedAvailability = false;
|
|
363
409
|
while (true) {
|
|
364
410
|
let state = await this.getState();
|
|
365
411
|
let target = state.sources.find(x => configAcceptsWrites(x.config) && (route === undefined || routeContains(x.config.route, route)));
|
|
366
412
|
if (!target) {
|
|
413
|
+
// Before giving up, re-contact every source once (routing re-read + connection
|
|
414
|
+
// re-attempt, possibly adopting a routing update that fixes the target)
|
|
415
|
+
if (!recheckedAvailability) {
|
|
416
|
+
recheckedAvailability = true;
|
|
417
|
+
await this.recheckAvailability();
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
367
420
|
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)`);
|
|
368
421
|
}
|
|
369
|
-
if (!target.isConnected()) {
|
|
370
|
-
target.noteFailure();
|
|
371
|
-
throw new Error(`Cannot write: the write target ${target.config.url} is not connected (writes never fail over to other sources)`);
|
|
372
|
-
}
|
|
373
422
|
try {
|
|
374
423
|
return await target.write(run);
|
|
375
424
|
} catch (e) {
|
|
@@ -384,8 +433,15 @@ export class ArchivesChain implements IArchives {
|
|
|
384
433
|
await this.prepareWrongTargetRetry(state, "route");
|
|
385
434
|
continue;
|
|
386
435
|
}
|
|
436
|
+
// The connection died during (or before) the request. One full re-contact pass,
|
|
437
|
+
// then one retry - still the same consistent target, never a different node.
|
|
387
438
|
if (!target.isConnected()) {
|
|
388
439
|
target.noteFailure();
|
|
440
|
+
if (!recheckedAvailability) {
|
|
441
|
+
recheckedAvailability = true;
|
|
442
|
+
await this.recheckAvailability();
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
389
445
|
}
|
|
390
446
|
throw e;
|
|
391
447
|
}
|
|
@@ -442,8 +498,8 @@ export class ArchivesChain implements IArchives {
|
|
|
442
498
|
// A minimal set of connected, currently-valid API sources whose routes cover [0, 1) - listing
|
|
443
499
|
// operations must merge every shard. Greedy sweep: repeatedly take the source that extends
|
|
444
500
|
// coverage the furthest. In practice this is one source (unsharded), or one per shard.
|
|
445
|
-
private selectCoveringSources(state: ChainState): SourceWrapper[] {
|
|
446
|
-
let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api &&
|
|
501
|
+
private selectCoveringSources(state: ChainState, excluded: Set<SourceWrapper>): SourceWrapper[] {
|
|
502
|
+
let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api && !excluded.has(x));
|
|
447
503
|
let chosen: SourceWrapper[] = [];
|
|
448
504
|
let covered = 0;
|
|
449
505
|
while (covered < 1) {
|
|
@@ -466,21 +522,39 @@ export class ArchivesChain implements IArchives {
|
|
|
466
522
|
return chosen;
|
|
467
523
|
}
|
|
468
524
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
525
|
+
// Fans one call out over a covering set of shards. Sources are never pre-filtered by
|
|
526
|
+
// connectivity - the request is attempted, and a failure with the socket down afterwards
|
|
527
|
+
// excludes that source and re-selects the covering set (an error from a live source throws).
|
|
528
|
+
private async runOnCovering<T>(run: (archives: IArchives) => Promise<T>): Promise<T[]> {
|
|
529
|
+
let state = await this.getState();
|
|
530
|
+
let excluded = new Set<SourceWrapper>();
|
|
531
|
+
while (true) {
|
|
532
|
+
let covering = this.selectCoveringSources(state, excluded);
|
|
533
|
+
let results = await Promise.all(covering.map(async source => {
|
|
534
|
+
let api = source.api;
|
|
535
|
+
if (!api) {
|
|
536
|
+
throw new Error(`${source.config.url} has URL-only access, which cannot serve this operation`);
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
return { value: await run(api) };
|
|
540
|
+
} catch (e) {
|
|
541
|
+
if (source.isConnected()) throw e;
|
|
542
|
+
source.noteFailure();
|
|
543
|
+
excluded.add(source);
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
}));
|
|
547
|
+
if (results.every(x => x)) {
|
|
548
|
+
return results.map(x => (x as { value: T }).value);
|
|
549
|
+
}
|
|
473
550
|
}
|
|
474
|
-
return await run(api);
|
|
475
551
|
}
|
|
476
552
|
|
|
477
553
|
public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
|
|
478
554
|
return (await this.findInfo(prefix, config)).map(x => x.path);
|
|
479
555
|
}
|
|
480
556
|
public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
|
|
481
|
-
let
|
|
482
|
-
let covering = this.selectCoveringSources(state);
|
|
483
|
-
let results = await Promise.all(covering.map(source => this.runOnApi(source, archives => archives.findInfo(prefix, config))));
|
|
557
|
+
let results = await this.runOnCovering(archives => archives.findInfo(prefix, config));
|
|
484
558
|
// Overlapping shards can both report a path; the newest wins
|
|
485
559
|
let byPath = new Map<string, ArchiveFileInfo>();
|
|
486
560
|
for (let list of results) {
|
|
@@ -496,14 +570,12 @@ export class ArchivesChain implements IArchives {
|
|
|
496
570
|
return merged;
|
|
497
571
|
}
|
|
498
572
|
public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
|
|
499
|
-
let
|
|
500
|
-
let covering = this.selectCoveringSources(state);
|
|
501
|
-
let results = await Promise.all(covering.map(source => this.runOnApi(source, async archives => {
|
|
573
|
+
let results = await this.runOnCovering(async archives => {
|
|
502
574
|
if (!archives.getChangesAfter) {
|
|
503
575
|
throw new Error(`${archives.getDebugName()} does not support getChangesAfter`);
|
|
504
576
|
}
|
|
505
577
|
return await archives.getChangesAfter(time);
|
|
506
|
-
})
|
|
578
|
+
});
|
|
507
579
|
let byPath = new Map<string, ArchiveFileInfo>();
|
|
508
580
|
for (let list of results) {
|
|
509
581
|
for (let file of list) {
|
|
@@ -518,14 +590,12 @@ export class ArchivesChain implements IArchives {
|
|
|
518
590
|
return merged;
|
|
519
591
|
}
|
|
520
592
|
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
521
|
-
let
|
|
522
|
-
let covering = this.selectCoveringSources(state);
|
|
523
|
-
let statuses = await Promise.all(covering.map(source => this.runOnApi(source, async archives => {
|
|
593
|
+
let statuses = await this.runOnCovering(async archives => {
|
|
524
594
|
if (!archives.getSyncStatus) {
|
|
525
595
|
throw new Error(`${archives.getDebugName()} does not support getSyncStatus`);
|
|
526
596
|
}
|
|
527
597
|
return await archives.getSyncStatus();
|
|
528
|
-
})
|
|
598
|
+
});
|
|
529
599
|
return {
|
|
530
600
|
allScansComplete: statuses.every(x => x.allScansComplete),
|
|
531
601
|
indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
|
|
@@ -569,58 +639,74 @@ export class ArchivesChain implements IArchives {
|
|
|
569
639
|
// is down (error + socket down, same rule as reads) - each shard receives a different key, so
|
|
570
640
|
// write consistency is preserved.
|
|
571
641
|
private async setVariableShard(key: string, data: Buffer, config?: { lastModified?: number }): Promise<string> {
|
|
572
|
-
let
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
let
|
|
579
|
-
|
|
580
|
-
|
|
642
|
+
let recheckedAvailability = false;
|
|
643
|
+
while (true) {
|
|
644
|
+
let state = await this.getState();
|
|
645
|
+
// Per-shard write consistency still holds: within a route, only the FIRST source may
|
|
646
|
+
// take writes - latency only picks WHICH shard the key materializes into
|
|
647
|
+
let targetsByRoute = new Map<string, SourceWrapper>();
|
|
648
|
+
for (let source of state.sources) {
|
|
649
|
+
if (!configAcceptsWrites(source.config)) continue;
|
|
650
|
+
let routeKey = JSON.stringify(source.config.route || FULL_ROUTE);
|
|
651
|
+
if (!targetsByRoute.has(routeKey)) {
|
|
652
|
+
targetsByRoute.set(routeKey, source);
|
|
653
|
+
}
|
|
581
654
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
655
|
+
let targets = [...targetsByRoute.values()];
|
|
656
|
+
sort(targets, x => x.getLatency());
|
|
657
|
+
let errors: string[] = [];
|
|
658
|
+
for (let target of targets) {
|
|
659
|
+
let [start, end] = target.config.route || FULL_ROUTE;
|
|
660
|
+
let fullKey = key.replace(VARIABLE_SHARD, VARIABLE_SHARD + "_" + (start + Math.random() * (end - start)));
|
|
661
|
+
try {
|
|
662
|
+
await target.write(archives => archives.set(fullKey, data, config));
|
|
663
|
+
return fullKey;
|
|
664
|
+
} catch (e) {
|
|
665
|
+
// NOTE: If we run into cases when transport level errors happen, but we are still connected, then we might want to add additional checking here, additional errors in which we will retry the other targets. However, ideally, checking if the WebSocket connection is still connected will handle all those cases. Distinguishing between application errors (which we can't retry) and transport errors.
|
|
666
|
+
if (target.isConnected()) throw e;
|
|
667
|
+
target.noteFailure();
|
|
668
|
+
errors.push(String((e as Error).stack ?? e));
|
|
669
|
+
}
|
|
591
670
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
await
|
|
596
|
-
|
|
597
|
-
} catch (e) {
|
|
598
|
-
// An error alone doesn't justify moving on (it would be an application error); the
|
|
599
|
-
// socket must also be down
|
|
600
|
-
if (target.isConnected()) throw e;
|
|
601
|
-
target.noteFailure();
|
|
602
|
-
errors.push(String((e as Error).stack ?? e));
|
|
671
|
+
// Every shard failed: re-contact everything and try once more before giving up
|
|
672
|
+
if (!recheckedAvailability) {
|
|
673
|
+
recheckedAvailability = true;
|
|
674
|
+
await this.recheckAvailability();
|
|
675
|
+
continue;
|
|
603
676
|
}
|
|
677
|
+
throw new Error(`Every variable-shard write target failed for ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
|
|
604
678
|
}
|
|
605
|
-
throw new Error(`Every variable-shard write target failed for ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
|
|
606
679
|
}
|
|
607
680
|
|
|
608
681
|
public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
|
|
609
682
|
if (config.path.includes(VARIABLE_SHARD) && parseVariableRoute(config.path) === undefined) {
|
|
610
683
|
throw new Error(`setLargeFile does not support VARIABLE_SHARD keys (there is no way to return the materialized key); write the file with set, or materialize the key yourself. Key: ${JSON.stringify(config.path)}`);
|
|
611
684
|
}
|
|
612
|
-
let state = await this.getState();
|
|
613
|
-
// Same consistency rule as runWrite: the first currently-valid source for the route, or nothing
|
|
614
685
|
let route = getRoute(config.path);
|
|
615
|
-
let
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
target
|
|
621
|
-
|
|
686
|
+
let recheckedAvailability = false;
|
|
687
|
+
while (true) {
|
|
688
|
+
let state = await this.getState();
|
|
689
|
+
// Same consistency rule as runWrite: the first currently-valid source for the route, or nothing
|
|
690
|
+
let target = state.sources.find(x => configAcceptsWrites(x.config) && routeContains(x.config.route, route));
|
|
691
|
+
if (!target) {
|
|
692
|
+
if (!recheckedAvailability) {
|
|
693
|
+
recheckedAvailability = true;
|
|
694
|
+
await this.recheckAvailability();
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
throw new Error(`No source accepts writes for setLargeFile on ${this.getDebugName()} (route ${route})`);
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
await target.write(archives => archives.setLargeFile(config));
|
|
701
|
+
return;
|
|
702
|
+
} catch (e) {
|
|
703
|
+
// The data stream cannot be rewound, so a mid-upload disconnect cannot be retried
|
|
704
|
+
if (!target.isConnected()) {
|
|
705
|
+
target.noteFailure();
|
|
706
|
+
}
|
|
707
|
+
throw e;
|
|
708
|
+
}
|
|
622
709
|
}
|
|
623
|
-
await target.write(archives => archives.setLargeFile(config));
|
|
624
710
|
}
|
|
625
711
|
|
|
626
712
|
public async getURL(path: string): Promise<string> {
|