sliftutils 1.7.15 → 1.7.16

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.
@@ -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
- let probeErrors: string[] = [];
128
- let found: { probe: SourceWrapper; existing: RemoteConfig | undefined } | undefined;
129
- for (let sourceConfig of configs) {
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
- found = { probe, existing: data && parseRoutingData(data) || undefined };
134
- break;
138
+ return { probe, sourceConfig, responded: true, existing: data && parseRoutingData(data) || undefined, error: "" };
135
139
  } catch (e) {
136
- probeErrors.push(`${sourceConfig.url}: ${(e as Error).stack ?? e}`);
137
- probe.dispose();
140
+ return { probe, sourceConfig, responded: false, existing: undefined, error: `${sourceConfig.url}: ${(e as Error).stack ?? e}` };
138
141
  }
139
- }
140
- if (!found) {
141
- throw new Error(`Cannot initialize storage for ${this.getDebugName()}: no source answered. ${probeErrors.join(" | ")}`);
142
- }
143
- let active = this.configured;
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
- if (best && getConfigVersion(best) >= getConfigVersion(this.configured)) {
182
- if (conflictUrl && getConfigVersion(best) === getConfigVersion(this.configured)) {
183
- 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)}`);
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 = best;
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
- if (needsWrite) {
194
- // The update only happens when EVERY source would accept it - a partial update would
195
- // leave the sources out of sync, and a client without access retrying the write forever
196
- // would never initialize. Without full access we run on the stored config (when there
197
- // is one) and log the problem instead.
198
- let missing: string[] = [];
199
- for (let source of sources) {
200
- try {
201
- if (!await source.hasWriteAccess()) {
202
- missing.push(source.config.url);
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
- } catch (e) {
205
- missing.push(`${source.config.url} (check failed: ${(e as Error).stack ?? e})`);
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
- if (missing.length) {
209
- 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.`}`);
210
- if (existing) {
211
- active = existing;
212
- for (let source of sources) {
213
- source.dispose();
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
- } else {
218
- try {
219
- // A rejected write fails init, which retries from scratch - re-reading the
220
- // routing, so losing a create race to another client just adopts their config
221
- // on the next attempt.
222
- await probe.write(archives => archives.set(ROUTING_FILE, serializeRemoteConfig(this.configured)));
223
- } catch (e) {
224
- for (let source of sources) {
225
- source.dispose();
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
- sources.push(await SourceWrapper.create(sourceConfig));
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 errors: string[] = [];
328
- for (let source of state.sources) {
329
- if (config.route !== undefined && !routeContains(source.config.route, config.route)) continue;
330
- if (!configWindowCurrent(source.config)) continue;
331
- if (!source.isConnected()) {
332
- source.noteFailure();
333
- errors.push(`${source.config.url} is not connected`);
334
- continue;
335
- }
336
- try {
337
- if (config.apiOnly) {
338
- let api = source.api;
339
- if (!api) {
340
- errors.push(`${source.config.url} has URL-only access, which cannot serve this operation`);
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(api);
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 && x.isConnected());
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
- private async runOnApi<T>(source: SourceWrapper, run: (archives: IArchives) => Promise<T>): Promise<T> {
470
- let api = source.api;
471
- if (!api) {
472
- throw new Error(`${source.config.url} has URL-only access, which cannot serve this operation`);
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 state = await this.getState();
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 state = await this.getState();
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 state = await this.getState();
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 state = await this.getState();
573
- // Per-shard write consistency still holds: within a route, only the FIRST source may take
574
- // writes - latency only picks WHICH shard the key materializes into
575
- let targetsByRoute = new Map<string, SourceWrapper>();
576
- for (let source of state.sources) {
577
- if (!configAcceptsWrites(source.config)) continue;
578
- let routeKey = JSON.stringify(source.config.route || FULL_ROUTE);
579
- if (!targetsByRoute.has(routeKey)) {
580
- targetsByRoute.set(routeKey, source);
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
- let targets = [...targetsByRoute.values()];
584
- sort(targets, x => x.getLatency());
585
- let errors: string[] = [];
586
- for (let target of targets) {
587
- if (!target.isConnected()) {
588
- target.noteFailure();
589
- errors.push(`${target.config.url} is not connected`);
590
- continue;
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
- let [start, end] = target.config.route || FULL_ROUTE;
593
- let fullKey = key.replace(VARIABLE_SHARD, VARIABLE_SHARD + "_" + (start + Math.random() * (end - start)));
594
- try {
595
- await target.write(archives => archives.set(fullKey, data, config));
596
- return fullKey;
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 target = state.sources.find(x => configAcceptsWrites(x.config) && routeContains(x.config.route, route));
616
- if (!target) {
617
- throw new Error(`No source accepts writes for setLargeFile on ${this.getDebugName()} (route ${route})`);
618
- }
619
- if (!target.isConnected()) {
620
- target.noteFailure();
621
- throw new Error(`Cannot write: the write target ${target.config.url} is not connected (writes never fail over to other sources)`);
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> {