sliftutils 1.7.46 → 1.7.48

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 CHANGED
@@ -3188,7 +3188,12 @@ declare module "sliftutils/storage/remoteStorage/createArchives" {
3188
3188
  /** The address, port, account, and bucket name a bucket routing URL addresses. Throws when the URL isn't a hosted bucket routing URL (https://host:port/file/<account>/<bucketName>/storage/storagerouting.json). */
3189
3189
  export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteConfig";
3190
3190
  export declare function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives;
3191
+ export type ArchivesChainOptions = {
3192
+ /** Outside of node we default to read-only downloads over the public URLs (no API connection) when the config has public sources. Set this to connect to the API anyway - needed for writing, listing, and any other operation the plain URL form cannot serve. */
3193
+ directConnect?: boolean;
3194
+ };
3191
3195
  export declare class ArchivesChain implements IArchives {
3196
+ private options?;
3192
3197
  private configured;
3193
3198
  private activeConfig;
3194
3199
  private statePromise;
@@ -3197,10 +3202,12 @@ declare module "sliftutils/storage/remoteStorage/createArchives" {
3197
3202
  private pollTimer;
3198
3203
  private disposed;
3199
3204
  private unsubscribeRoutingPush;
3200
- constructor(config: RemoteConfig | RemoteConfigBase);
3205
+ constructor(config: RemoteConfig | RemoteConfigBase, options?: ArchivesChainOptions | undefined);
3201
3206
  getDebugName(): string;
3202
3207
  private getState;
3203
3208
  private init;
3209
+ /** Clientside, a config with public sources is served entirely over plain URL downloads - no API connection, no access grant, and no writing. directConnect opts out of that. */
3210
+ private isReadOnly;
3204
3211
  private createChainSource;
3205
3212
  private buildSources;
3206
3213
  private startConfigPoll;
@@ -3272,7 +3279,7 @@ declare module "sliftutils/storage/remoteStorage/createArchives" {
3272
3279
  getURLs(path: string): Promise<string[]>;
3273
3280
  dispose(): void;
3274
3281
  }
3275
- export declare function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain;
3282
+ export declare function createArchives(config: RemoteConfig | RemoteConfigBase, options?: ArchivesChainOptions): ArchivesChain;
3276
3283
  export declare function listServerBuckets(config: {
3277
3284
  url: string;
3278
3285
  account: string;
@@ -3421,12 +3428,16 @@ declare module "sliftutils/storage/remoteStorage/sourceWrapper" {
3421
3428
  updateValidWindow(validWindow: [number, number]): void;
3422
3429
  static create(config: HostedConfig | BackblazeConfig, options?: {
3423
3430
  background?: boolean;
3431
+ readOnly?: boolean;
3424
3432
  }): Promise<SourceWrapper>;
3425
3433
  getDebugName(): string;
3426
3434
  isConnected(): boolean;
3427
3435
  /** A source whose window has passed is never read from or written to (see the window checks in ArchivesChain), and an intermediate only exists for the minutes of a deploy switchover - on either, being unreachable is expected, not a problem to report. */
3428
3436
  private isConnectionProblemWorthReporting;
3429
- /** Call after a request failed while isConnected() was false: starts (if not already running) the background reconnect loop. Never blocks - the failed request still throws. */
3437
+ private cooldownUntil;
3438
+ /** A source that failed while disconnected is skipped by callers for SOURCE_FAILURE_COOLDOWN - but only while some other source can serve the request. When nothing else is usable, callers ignore this and try it anyway, so a total outage still retries every time. */
3439
+ isOnCooldown(): boolean;
3440
+ /** Call after a request failed while isConnected() was false: puts the source on cooldown and starts (if not already running) the background reconnect loop. Never blocks - the failed request still throws. */
3430
3441
  noteFailure(): void;
3431
3442
  private reconnectLoop;
3432
3443
  private checkAccess;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.7.46",
3
+ "version": "1.7.48",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -5,7 +5,12 @@ import { ServerBucketInfo, ActiveBucketInfo } from "./storageServerState";
5
5
  /** The address, port, account, and bucket name a bucket routing URL addresses. Throws when the URL isn't a hosted bucket routing URL (https://host:port/file/<account>/<bucketName>/storage/storagerouting.json). */
6
6
  export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteConfig";
7
7
  export declare function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives;
8
+ export type ArchivesChainOptions = {
9
+ /** Outside of node we default to read-only downloads over the public URLs (no API connection) when the config has public sources. Set this to connect to the API anyway - needed for writing, listing, and any other operation the plain URL form cannot serve. */
10
+ directConnect?: boolean;
11
+ };
8
12
  export declare class ArchivesChain implements IArchives {
13
+ private options?;
9
14
  private configured;
10
15
  private activeConfig;
11
16
  private statePromise;
@@ -14,10 +19,12 @@ export declare class ArchivesChain implements IArchives {
14
19
  private pollTimer;
15
20
  private disposed;
16
21
  private unsubscribeRoutingPush;
17
- constructor(config: RemoteConfig | RemoteConfigBase);
22
+ constructor(config: RemoteConfig | RemoteConfigBase, options?: ArchivesChainOptions | undefined);
18
23
  getDebugName(): string;
19
24
  private getState;
20
25
  private init;
26
+ /** Clientside, a config with public sources is served entirely over plain URL downloads - no API connection, no access grant, and no writing. directConnect opts out of that. */
27
+ private isReadOnly;
21
28
  private createChainSource;
22
29
  private buildSources;
23
30
  private startConfigPoll;
@@ -89,7 +96,7 @@ export declare class ArchivesChain implements IArchives {
89
96
  getURLs(path: string): Promise<string[]>;
90
97
  dispose(): void;
91
98
  }
92
- export declare function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain;
99
+ export declare function createArchives(config: RemoteConfig | RemoteConfigBase, options?: ArchivesChainOptions): ArchivesChain;
93
100
  export declare function listServerBuckets(config: {
94
101
  url: string;
95
102
  account: string;
@@ -45,6 +45,11 @@ type ChainState = {
45
45
  sources: SourceWrapper[];
46
46
  };
47
47
 
48
+ export type ArchivesChainOptions = {
49
+ /** Outside of node we default to read-only downloads over the public URLs (no API connection) when the config has public sources. Set this to connect to the API anyway - needed for writing, listing, and any other operation the plain URL form cannot serve. */
50
+ directConnect?: boolean;
51
+ };
52
+
48
53
  function configWindowCurrent(config: HostedConfig | BackblazeConfig): boolean {
49
54
  let now = Date.now();
50
55
  let [start, end] = config.validWindow;
@@ -55,6 +60,34 @@ function configAcceptsWrites(config: HostedConfig | BackblazeConfig): boolean {
55
60
  return configWindowCurrent(config);
56
61
  }
57
62
 
63
+ /** The fewest sources whose routes span the whole key space, or undefined when they leave a gap. */
64
+ function coverRoutes(candidates: SourceWrapper[]): SourceWrapper[] | undefined {
65
+ let chosen: SourceWrapper[] = [];
66
+ let covered = 0;
67
+ while (covered < 1) {
68
+ let best: SourceWrapper | undefined;
69
+ let bestEnd = covered;
70
+ for (let source of candidates) {
71
+ let [start, end] = source.config.route || FULL_ROUTE;
72
+ if (start > covered) continue;
73
+ if (end > bestEnd) {
74
+ bestEnd = end;
75
+ best = source;
76
+ }
77
+ }
78
+ if (!best) return undefined;
79
+ chosen.push(best);
80
+ covered = bestEnd;
81
+ }
82
+ return chosen;
83
+ }
84
+
85
+ /** Drops sources that recently failed while disconnected - unless that would leave nothing, in which case a down source is still better than no source, and we retry it immediately. */
86
+ function preferUsable(sources: SourceWrapper[]): SourceWrapper[] {
87
+ let usable = sources.filter(x => !x.isOnCooldown());
88
+ return usable.length && usable || sources;
89
+ }
90
+
58
91
  export class ArchivesChain implements IArchives {
59
92
  private configured: RemoteConfig;
60
93
  private activeConfig: RemoteConfig;
@@ -65,7 +98,7 @@ export class ArchivesChain implements IArchives {
65
98
  private disposed = false;
66
99
  private unsubscribeRoutingPush: (() => void) | undefined;
67
100
 
68
- constructor(config: RemoteConfig | RemoteConfigBase) {
101
+ constructor(config: RemoteConfig | RemoteConfigBase, private options?: ArchivesChainOptions) {
69
102
  this.configured = normalizeRemoteConfig(config);
70
103
  this.activeConfig = this.configured;
71
104
  this.unsubscribeRoutingPush = onServerRoutingChanged(() => {
@@ -107,8 +140,9 @@ export class ArchivesChain implements IArchives {
107
140
 
108
141
  private async init(): Promise<ChainState> {
109
142
  let configs = this.configured.sources.map(normalizeSource);
143
+ let readOnly = this.isReadOnly(this.configured);
110
144
  let fetches = await Promise.all(configs.map(async sourceConfig => {
111
- let probe = await SourceWrapper.create(sourceConfig, { background: false });
145
+ let probe = await SourceWrapper.create(sourceConfig, { background: false, readOnly });
112
146
  let start = Date.now();
113
147
  try {
114
148
  let existing = await probe.readRoutingConfig();
@@ -215,16 +249,23 @@ export class ArchivesChain implements IArchives {
215
249
  }
216
250
  }
217
251
 
218
- private async createChainSource(sourceConfig: HostedConfig | BackblazeConfig): Promise<SourceWrapper> {
219
- let source = await SourceWrapper.create(sourceConfig);
252
+ /** Clientside, a config with public sources is served entirely over plain URL downloads - no API connection, no access grant, and no writing. directConnect opts out of that. */
253
+ private isReadOnly(config: RemoteConfig): boolean {
254
+ if (this.options?.directConnect || isNode()) return false;
255
+ return config.sources.map(normalizeSource).some(x => x.public);
256
+ }
257
+
258
+ private async createChainSource(sourceConfig: HostedConfig | BackblazeConfig, readOnly: boolean): Promise<SourceWrapper> {
259
+ let source = await SourceWrapper.create(sourceConfig, { readOnly });
220
260
  source.startPinging();
221
261
  return source;
222
262
  }
223
263
 
224
264
  private async buildSources(config: RemoteConfig): Promise<SourceWrapper[]> {
265
+ let readOnly = this.isReadOnly(config);
225
266
  let sources: SourceWrapper[] = [];
226
267
  for (let sourceConfig of config.sources.map(normalizeSource)) {
227
- sources.push(await this.createChainSource(sourceConfig));
268
+ sources.push(await this.createChainSource(sourceConfig, readOnly));
228
269
  }
229
270
  return sources;
230
271
  }
@@ -300,6 +341,7 @@ export class ArchivesChain implements IArchives {
300
341
  }
301
342
  list.push(source);
302
343
  }
344
+ let readOnly = this.isReadOnly(latest);
303
345
  let sources: SourceWrapper[] = [];
304
346
  for (let sourceConfig of latest.sources.map(normalizeSource)) {
305
347
  let old = oldByConfig.get(strippedKey(sourceConfig))?.shift();
@@ -307,7 +349,7 @@ export class ArchivesChain implements IArchives {
307
349
  old.updateValidWindow(sourceConfig.validWindow);
308
350
  sources.push(old);
309
351
  } else {
310
- sources.push(await this.createChainSource(sourceConfig));
352
+ sources.push(await this.createChainSource(sourceConfig, readOnly));
311
353
  }
312
354
  }
313
355
  for (let leftovers of oldByConfig.values()) {
@@ -358,9 +400,11 @@ export class ArchivesChain implements IArchives {
358
400
  let recheckedAvailability = false;
359
401
  while (true) {
360
402
  let errors: string[] = [];
361
- for (let source of state.sources) {
362
- if (config.route !== undefined && !routeContains(source.config.route, config.route)) continue;
363
- if (!configWindowCurrent(source.config)) continue;
403
+ let candidates = state.sources.filter(source =>
404
+ (config.route === undefined || routeContains(source.config.route, config.route))
405
+ && configWindowCurrent(source.config)
406
+ );
407
+ for (let source of preferUsable(candidates)) {
364
408
  try {
365
409
  if (config.apiOnly) {
366
410
  let api = source.api;
@@ -393,7 +437,7 @@ export class ArchivesChain implements IArchives {
393
437
  let recheckedAvailability = false;
394
438
  while (true) {
395
439
  let state = await this.getState();
396
- let target = state.sources.find(x => configAcceptsWrites(x.config) && (route === undefined || routeContains(x.config.route, route)));
440
+ let target = preferUsable(state.sources.filter(x => configAcceptsWrites(x.config) && (route === undefined || routeContains(x.config.route, route))))[0];
397
441
  if (!target) {
398
442
  if (!recheckedAvailability) {
399
443
  recheckedAvailability = true;
@@ -475,24 +519,10 @@ export class ArchivesChain implements IArchives {
475
519
 
476
520
  private selectCoveringSources(state: ChainState, excluded: Set<SourceWrapper>): SourceWrapper[] {
477
521
  let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api && !excluded.has(x));
478
- let chosen: SourceWrapper[] = [];
479
- let covered = 0;
480
- while (covered < 1) {
481
- let best: SourceWrapper | undefined;
482
- let bestEnd = covered;
483
- for (let source of candidates) {
484
- let [start, end] = source.config.route || FULL_ROUTE;
485
- if (start > covered) continue;
486
- if (end > bestEnd) {
487
- bestEnd = end;
488
- best = source;
489
- }
490
- }
491
- if (!best) {
492
- 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)`);
493
- }
494
- chosen.push(best);
495
- covered = bestEnd;
522
+ // Unlike the single-source paths, dropping a cooled-down source can leave a route gap - so the cooled-down set only comes back as a whole, when the healthy sources cannot cover everything on their own
523
+ let chosen = coverRoutes(candidates.filter(x => !x.isOnCooldown())) || coverRoutes(candidates);
524
+ if (!chosen) {
525
+ throw new Error(`Cannot cover the full route space for ${this.getDebugName()}: the available sources leave a gap (some shards are down or URL-only)`);
496
526
  }
497
527
  return chosen;
498
528
  }
@@ -632,8 +662,7 @@ export class ArchivesChain implements IArchives {
632
662
  while (true) {
633
663
  let state = await this.getState();
634
664
  let targetsByRoute = new Map<string, SourceWrapper>();
635
- for (let source of state.sources) {
636
- if (!configAcceptsWrites(source.config)) continue;
665
+ for (let source of preferUsable(state.sources.filter(x => configAcceptsWrites(x.config)))) {
637
666
  let routeKey = JSON.stringify(source.config.route || FULL_ROUTE);
638
667
  if (!targetsByRoute.has(routeKey)) {
639
668
  targetsByRoute.set(routeKey, source);
@@ -672,7 +701,7 @@ export class ArchivesChain implements IArchives {
672
701
  let recheckedAvailability = false;
673
702
  while (true) {
674
703
  let state = await this.getState();
675
- let target = state.sources.find(x => configAcceptsWrites(x.config) && routeContains(x.config.route, route));
704
+ let target = preferUsable(state.sources.filter(x => configAcceptsWrites(x.config) && routeContains(x.config.route, route)))[0];
676
705
  if (!target) {
677
706
  if (!recheckedAvailability) {
678
707
  recheckedAvailability = true;
@@ -737,8 +766,8 @@ export class ArchivesChain implements IArchives {
737
766
  }
738
767
  }
739
768
 
740
- export function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain {
741
- return new ArchivesChain(config);
769
+ export function createArchives(config: RemoteConfig | RemoteConfigBase, options?: ArchivesChainOptions): ArchivesChain {
770
+ return new ArchivesChain(config, options);
742
771
  }
743
772
 
744
773
  async function callServer<T>(url: string, run: (controller: typeof RemoteStorageController.nodes[string]) => Promise<T>): Promise<T> {