sliftutils 1.7.9 → 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/bin/storageserver.js +4 -0
- package/{teststorage → examplestorage}/browser.tsx +18 -22
- package/{teststorage/server.ts → examplestorage/exampleserver.ts} +4 -6
- package/index.d.ts +770 -84
- 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 -8
- package/misc/https/dns.ts +104 -106
- package/misc/https/hostServer.d.ts +0 -3
- package/misc/https/hostServer.ts +12 -8
- package/package.json +3 -2
- package/render-utils/dist/observer.tsx.cache +3 -2
- package/spec.txt +0 -55
- package/storage/ArchivesDisk.d.ts +67 -0
- package/storage/ArchivesDisk.ts +369 -0
- 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 +127 -1
- package/storage/IArchives.ts +201 -1
- package/storage/backblaze.d.ts +29 -2
- package/storage/backblaze.ts +214 -78
- 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 +34 -18
- package/storage/remoteStorage/ArchivesRemote.ts +85 -65
- package/storage/remoteStorage/ArchivesUrl.d.ts +48 -0
- package/storage/remoteStorage/ArchivesUrl.ts +86 -0
- package/storage/remoteStorage/accessPage.tsx +111 -28
- package/storage/remoteStorage/blobStore.d.ts +128 -25
- package/storage/remoteStorage/blobStore.ts +757 -290
- package/storage/remoteStorage/cliArgs.d.ts +1 -0
- package/storage/remoteStorage/cliArgs.ts +9 -0
- package/storage/remoteStorage/createArchives.d.ts +85 -0
- package/storage/remoteStorage/createArchives.ts +612 -0
- 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/grantAccess.js +4 -0
- package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
- package/storage/remoteStorage/grantAccessCli.ts +27 -0
- package/storage/remoteStorage/remoteConfig.d.ts +29 -0
- package/storage/remoteStorage/remoteConfig.ts +194 -0
- 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 +30 -27
- package/storage/remoteStorage/storageController.ts +297 -159
- package/storage/remoteStorage/storageServer.d.ts +6 -0
- package/storage/remoteStorage/storageServer.ts +88 -94
- package/storage/remoteStorage/storageServerCli.d.ts +1 -0
- package/storage/remoteStorage/storageServerCli.ts +38 -0
- package/storage/remoteStorage/storageServerState.d.ts +43 -0
- package/storage/remoteStorage/storageServerState.ts +540 -0
- package/testsite/server.ts +0 -2
- package/yarn.lock +11 -6
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
module.allowclient = true;
|
|
2
|
+
|
|
3
|
+
import { isNode, sort } from "socket-function/src/misc";
|
|
4
|
+
import {
|
|
5
|
+
IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig,
|
|
6
|
+
ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, WRITE_PAST_WINDOW_GRACE, STORAGE_WRONG_VALID_WINDOW,
|
|
7
|
+
STORAGE_WRONG_ROUTE, FULL_ROUTE, VARIABLE_SHARD,
|
|
8
|
+
} from "../IArchives";
|
|
9
|
+
import {
|
|
10
|
+
ROUTING_FILE, getConfigVersion, parseHostedUrl, parseBackblazeUrl,
|
|
11
|
+
normalizeRemoteConfig, normalizeSource, parseRoutingData, serializeRemoteConfig,
|
|
12
|
+
getRoute, routeContains, parseVariableRoute,
|
|
13
|
+
} from "./remoteConfig";
|
|
14
|
+
import { ArchivesRemote } from "./ArchivesRemote";
|
|
15
|
+
import { ArchivesBackblaze } from "../backblaze";
|
|
16
|
+
import { getStorageServerConfigOptional, getLocalArchives } from "./storageServerState";
|
|
17
|
+
import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
|
|
18
|
+
|
|
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).
|
|
27
|
+
|
|
28
|
+
const CONFIG_POLL_INTERVAL = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
// The direct API IArchives for one source, with no URL-form fallback and no chaining. Used by the
|
|
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.
|
|
34
|
+
export function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives {
|
|
35
|
+
if (source.type === "backblaze") {
|
|
36
|
+
return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable });
|
|
37
|
+
}
|
|
38
|
+
let parsed = parseHostedUrl(source.url);
|
|
39
|
+
let server = isNode() && getStorageServerConfigOptional() || undefined;
|
|
40
|
+
if (server && parsed.address === server.domain && parsed.port === server.port) {
|
|
41
|
+
// This source is a bucket hosted by our own process - use it directly instead of calling
|
|
42
|
+
// ourselves over HTTPS
|
|
43
|
+
return getLocalArchives(parsed.account, parsed.bucketName);
|
|
44
|
+
}
|
|
45
|
+
return new ArchivesRemote({ url: source.url, accountName: source.accountName, waitForAccess: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type ChainState = {
|
|
49
|
+
config: RemoteConfig;
|
|
50
|
+
sources: SourceWrapper[];
|
|
51
|
+
};
|
|
52
|
+
|
|
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);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class ArchivesChain implements IArchives {
|
|
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;
|
|
74
|
+
|
|
75
|
+
constructor(config: RemoteConfig | RemoteConfigBase) {
|
|
76
|
+
this.configured = normalizeRemoteConfig(config);
|
|
77
|
+
this.activeConfig = this.configured;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public getDebugName() {
|
|
81
|
+
let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as HostedConfig | BackblazeConfig).url);
|
|
82
|
+
return `chain/${urls.join(",")}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
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) {
|
|
93
|
+
let promise = this.init();
|
|
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);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return this.statePromise;
|
|
113
|
+
}
|
|
114
|
+
|
|
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 });
|
|
121
|
+
try {
|
|
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();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!found) {
|
|
131
|
+
throw new Error(`Cannot initialize storage for ${this.getDebugName()}: no source answered. ${probeErrors.join(" | ")}`);
|
|
132
|
+
}
|
|
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)}`);
|
|
139
|
+
}
|
|
140
|
+
active = existing;
|
|
141
|
+
needsWrite = false;
|
|
142
|
+
}
|
|
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
|
+
}
|
|
170
|
+
}
|
|
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);
|
|
181
|
+
}
|
|
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();
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
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);
|
|
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;
|
|
252
|
+
try {
|
|
253
|
+
state = await this.statePromise;
|
|
254
|
+
} catch {
|
|
255
|
+
// Init is failing; its own retry loop handles that
|
|
256
|
+
return;
|
|
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 });
|
|
282
|
+
}
|
|
283
|
+
|
|
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
|
+
}
|
|
292
|
+
let errors: string[] = [];
|
|
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`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
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;
|
|
307
|
+
}
|
|
308
|
+
return await run(api);
|
|
309
|
+
}
|
|
310
|
+
return await source.read(run);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (source.isConnected()) throw e;
|
|
313
|
+
source.noteFailure();
|
|
314
|
+
errors.push(String((e as Error).stack ?? e));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`All sources failed for ${this.getDebugName()}${config.route !== undefined && ` (route ${config.route})` || ""}: ${errors.join(" | ") || "no sources available"}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
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)`);
|
|
332
|
+
}
|
|
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)`);
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
return await target.write(run);
|
|
339
|
+
} catch (e) {
|
|
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;
|
|
348
|
+
}
|
|
349
|
+
if (!target.isConnected()) {
|
|
350
|
+
target.noteFailure();
|
|
351
|
+
}
|
|
352
|
+
throw e;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
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);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The access-page link (plus our machineId/ip, for the approver to match the request) for the
|
|
363
|
+
// first hosted source that hasn't granted us access. undefined when we have access everywhere
|
|
364
|
+
// we can have it. Also registers the access request server-side.
|
|
365
|
+
public async waitingForAccess(): Promise<{ link: string; machineId: string; ip: string } | undefined> {
|
|
366
|
+
let state = await this.getState();
|
|
367
|
+
for (let source of state.sources) {
|
|
368
|
+
if (source.api instanceof ArchivesRemote) {
|
|
369
|
+
let waiting = await source.api.waitingForAccess();
|
|
370
|
+
if (waiting) return waiting;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
public async get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
|
|
377
|
+
let result = await this.get2(fileName, config);
|
|
378
|
+
return result && result.data || undefined;
|
|
379
|
+
}
|
|
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));
|
|
382
|
+
}
|
|
383
|
+
public async getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined> {
|
|
384
|
+
return await this.request({ route: getRoute(fileName) }, archives => archives.getInfo(fileName));
|
|
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
|
+
|
|
422
|
+
public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
|
|
423
|
+
return (await this.findInfo(prefix, config)).map(x => x.path);
|
|
424
|
+
}
|
|
425
|
+
public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
|
|
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;
|
|
442
|
+
}
|
|
443
|
+
public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
|
|
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 => {
|
|
447
|
+
if (!archives.getChangesAfter) {
|
|
448
|
+
throw new Error(`${archives.getDebugName()} does not support getChangesAfter`);
|
|
449
|
+
}
|
|
450
|
+
return await archives.getChangesAfter(time);
|
|
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;
|
|
464
|
+
}
|
|
465
|
+
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
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 => {
|
|
469
|
+
if (!archives.getSyncStatus) {
|
|
470
|
+
throw new Error(`${archives.getDebugName()} does not support getSyncStatus`);
|
|
471
|
+
}
|
|
472
|
+
return await archives.getSyncStatus();
|
|
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
|
+
};
|
|
479
|
+
}
|
|
480
|
+
public async getConfig(): Promise<ArchivesConfig> {
|
|
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
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
504
|
+
this.assertNotBareVariableShard(fileName);
|
|
505
|
+
await this.request({ write: true, route: getRoute(fileName) }, archives => archives.set(fileName, data, config));
|
|
506
|
+
}
|
|
507
|
+
public async del(fileName: string): Promise<void> {
|
|
508
|
+
await this.request({ write: true, route: getRoute(fileName) }, archives => archives.del(fileName));
|
|
509
|
+
}
|
|
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());
|
|
537
|
+
let errors: string[] = [];
|
|
538
|
+
for (let target of targets) {
|
|
539
|
+
if (!target.isConnected()) {
|
|
540
|
+
target.noteFailure();
|
|
541
|
+
errors.push(`${target.config.url} is not connected`);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
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));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
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));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
public async getURL(path: string): Promise<string> {
|
|
577
|
+
let state = await this.getState();
|
|
578
|
+
let route = getRoute(path);
|
|
579
|
+
for (let source of state.sources) {
|
|
580
|
+
if (source.config.public === false) continue;
|
|
581
|
+
if (!routeContains(source.config.route, route)) continue;
|
|
582
|
+
if (source.url) return await source.url.getURL(path);
|
|
583
|
+
if (source.api) return await source.api.getURL(path);
|
|
584
|
+
}
|
|
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
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// The IArchives for a RemoteConfig (or a single source - a routing URL string works). Fully lazy:
|
|
608
|
+
// nothing is contacted until the first call. Call dispose() when done with it, so its background
|
|
609
|
+
// retry/poll loops stop.
|
|
610
|
+
export function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain {
|
|
611
|
+
return new ArchivesChain(config);
|
|
612
|
+
}
|