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 +14 -3
- package/package.json +1 -1
- package/storage/remoteStorage/createArchives.d.ts +9 -2
- package/storage/remoteStorage/createArchives.ts +62 -33
- package/storage/remoteStorage/dist/createArchives.ts.cache +59 -39
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +12 -5
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +24 -9
- package/storage/remoteStorage/remoteConfig.ts +9 -4
- package/storage/remoteStorage/sourceWrapper.d.ts +5 -1
- package/storage/remoteStorage/sourceWrapper.ts +23 -7
- package/storage/remoteStorage/storageServer.ts +1 -0
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
|
-
|
|
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
|
@@ -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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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.
|
|
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
|
-
|
|
479
|
-
let
|
|
480
|
-
|
|
481
|
-
|
|
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.
|
|
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> {
|