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