sliftutils 1.7.71 → 1.7.73
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 +506 -257
- package/misc/https/dist/hostServer.ts.cache +5 -2
- package/package.json +1 -1
- package/spec.txt +10 -0
- package/storage/ArchivesDisk.d.ts +4 -13
- package/storage/ArchivesDisk.ts +6 -4
- package/storage/IArchives.d.ts +15 -10
- package/storage/IArchives.ts +19 -6
- package/storage/backblaze.d.ts +3 -9
- package/storage/backblaze.ts +22 -19
- package/storage/dist/ArchivesDisk.ts.cache +5 -2
- package/storage/dist/IArchives.ts.cache +17 -3
- package/storage/dist/backblaze.ts.cache +35 -26
- package/storage/remoteStorage/ArchivesRemote.d.ts +5 -9
- package/storage/remoteStorage/ArchivesRemote.ts +25 -20
- package/storage/remoteStorage/ArchivesUrl.d.ts +3 -9
- package/storage/remoteStorage/ArchivesUrl.ts +7 -5
- package/storage/remoteStorage/accessPage.tsx +4 -4
- package/storage/remoteStorage/accessStats.d.ts +2 -0
- package/storage/remoteStorage/accessStats.ts +32 -0
- package/storage/remoteStorage/blobStore.d.ts +193 -88
- package/storage/remoteStorage/blobStore.ts +535 -400
- package/storage/remoteStorage/bucketDisk.d.ts +18 -0
- package/storage/remoteStorage/bucketDisk.ts +66 -0
- package/storage/remoteStorage/createArchives.d.ts +4 -10
- package/storage/remoteStorage/createArchives.ts +103 -62
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +28 -19
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +7 -4
- package/storage/remoteStorage/dist/accessPage.tsx.cache +6 -6
- package/storage/remoteStorage/dist/accessStats.ts.cache +38 -3
- package/storage/remoteStorage/dist/blobStore.ts.cache +516 -374
- package/storage/remoteStorage/dist/bucketDisk.ts.cache +72 -0
- package/storage/remoteStorage/dist/createArchives.ts.cache +114 -57
- package/storage/remoteStorage/dist/intermediateSources.ts.cache +12 -12
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +18 -2
- package/storage/remoteStorage/dist/serverConfig.ts.cache +116 -0
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +6 -5
- package/storage/remoteStorage/dist/sourcesList.ts.cache +5 -2
- package/storage/remoteStorage/dist/storageController.ts.cache +223 -190
- package/storage/remoteStorage/dist/storageServer.ts.cache +9 -9
- package/storage/remoteStorage/dist/storageServerState.ts.cache +329 -566
- package/storage/remoteStorage/dist/storePlan.ts.cache +149 -0
- package/storage/remoteStorage/dist/validation.ts.cache +40 -0
- package/storage/remoteStorage/grantAccessCli.ts +1 -1
- package/storage/remoteStorage/intermediateSources.d.ts +3 -5
- package/storage/remoteStorage/intermediateSources.ts +14 -16
- package/storage/remoteStorage/remoteConfig.d.ts +2 -2
- package/storage/remoteStorage/remoteConfig.ts +19 -3
- package/storage/remoteStorage/serverConfig.d.ts +27 -0
- package/storage/remoteStorage/serverConfig.ts +109 -0
- package/storage/remoteStorage/sourceWrapper.d.ts +3 -3
- package/storage/remoteStorage/sourceWrapper.ts +7 -6
- package/storage/remoteStorage/sourcesList.ts +3 -0
- package/storage/remoteStorage/storageController.d.ts +118 -36
- package/storage/remoteStorage/storageController.ts +166 -168
- package/storage/remoteStorage/storageServer.ts +1 -1
- package/storage/remoteStorage/storageServerState.d.ts +52 -63
- package/storage/remoteStorage/storageServerState.ts +340 -584
- package/storage/remoteStorage/storePlan.d.ts +34 -0
- package/storage/remoteStorage/storePlan.ts +166 -0
- package/storage/remoteStorage/validation.d.ts +4 -0
- package/storage/remoteStorage/validation.ts +31 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import { RemoteConfig } from "../IArchives";
|
|
4
|
+
export declare function getBucketFolder(account: string, bucketName: string, route?: [number, number]): string;
|
|
5
|
+
export declare function readRoutingFile(folder: string): Promise<Buffer | undefined>;
|
|
6
|
+
export declare function readRoutingFromDisk(account: string, bucketName: string): Promise<RemoteConfig | undefined>;
|
|
7
|
+
/** The routing file lives ONLY in the plain (routeless) bucket folder - it is what DEFINES the per-route stores, so it cannot live inside any of them. Served directly for reads (the stores never hold it). */
|
|
8
|
+
export declare function getRoutingFileResult(account: string, bucketName: string): Promise<{
|
|
9
|
+
data: Buffer;
|
|
10
|
+
writeTime: number;
|
|
11
|
+
size: number;
|
|
12
|
+
} | undefined>;
|
|
13
|
+
export type BucketDiskInfo = {
|
|
14
|
+
totalBytes: number;
|
|
15
|
+
freeBytes: number;
|
|
16
|
+
usedBytes: number;
|
|
17
|
+
};
|
|
18
|
+
export declare function getDiskInfo(folder: string): Promise<BucketDiskInfo>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { RemoteConfig } from "../IArchives";
|
|
4
|
+
import { ROUTING_FILE, parseRoutingData } from "./remoteConfig";
|
|
5
|
+
import { getStorageServerConfig } from "./serverConfig";
|
|
6
|
+
|
|
7
|
+
// The on-disk layout of buckets, and the direct-disk reads of the files that exist outside every store: the per-route folder naming and the routing file.
|
|
8
|
+
|
|
9
|
+
/** Each ROUTE gets its own folder: the bucket name plus the route range. One store serves exactly one route, so different shards of the same bucket - even across processes on different ports of the same machine - never share a folder and can never mix their data. No route (or the full route) keeps the plain folder, which is also where the routing file always lives. */
|
|
10
|
+
function getRouteFolderSuffix(route: [number, number] | undefined): string {
|
|
11
|
+
if (!route || route[0] === 0 && route[1] === 1) return "";
|
|
12
|
+
return `-route-${route[0]}-${route[1]}`;
|
|
13
|
+
}
|
|
14
|
+
export function getBucketFolder(account: string, bucketName: string, route?: [number, number]): string {
|
|
15
|
+
return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName + getRouteFolderSuffix(route));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The routing file is ours, on our own disk, at a path we know - so it is read directly. Going through an ArchivesDisk would construct a whole store (handle cache sweep loop, uploads-folder cleanup) just to read one file. */
|
|
19
|
+
function getRoutingFilePath(folder: string): string {
|
|
20
|
+
return path.join(folder, "files", ROUTING_FILE);
|
|
21
|
+
}
|
|
22
|
+
export async function readRoutingFile(folder: string): Promise<Buffer | undefined> {
|
|
23
|
+
try {
|
|
24
|
+
return await fs.promises.readFile(getRoutingFilePath(folder));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if ((e as { code?: string }).code === "ENOENT") return undefined;
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readRoutingFromDisk(account: string, bucketName: string): Promise<RemoteConfig | undefined> {
|
|
32
|
+
let data = await readRoutingFile(getBucketFolder(account, bucketName));
|
|
33
|
+
if (!data) return undefined;
|
|
34
|
+
return parseRoutingData(data);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The routing file lives ONLY in the plain (routeless) bucket folder - it is what DEFINES the per-route stores, so it cannot live inside any of them. Served directly for reads (the stores never hold it). */
|
|
38
|
+
export async function getRoutingFileResult(account: string, bucketName: string): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
39
|
+
let filePath = getRoutingFilePath(getBucketFolder(account, bucketName));
|
|
40
|
+
try {
|
|
41
|
+
let data = await fs.promises.readFile(filePath);
|
|
42
|
+
let stats = await fs.promises.stat(filePath);
|
|
43
|
+
return { data, writeTime: stats.mtimeMs, size: data.length };
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if ((e as { code?: string }).code === "ENOENT") return undefined;
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type BucketDiskInfo = {
|
|
51
|
+
totalBytes: number;
|
|
52
|
+
freeBytes: number;
|
|
53
|
+
usedBytes: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export async function getDiskInfo(folder: string): Promise<BucketDiskInfo> {
|
|
57
|
+
let stats = await fs.promises.statfs(folder);
|
|
58
|
+
let blockSize = Number(stats.bsize);
|
|
59
|
+
let totalBytes = Number(stats.blocks) * blockSize;
|
|
60
|
+
return {
|
|
61
|
+
totalBytes,
|
|
62
|
+
// Matches the server's own low-space check: what an unprivileged process can actually use
|
|
63
|
+
freeBytes: Number(stats.bavail) * blockSize,
|
|
64
|
+
usedBytes: totalBytes - Number(stats.bfree) * blockSize,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
/// <reference types="node" />
|
|
3
|
-
import { IArchives, RemoteConfig, RemoteConfigBase,
|
|
3
|
+
import { IArchives, RemoteConfig, RemoteConfigBase, SourceConfig, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig } from "../IArchives";
|
|
4
4
|
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
|
-
export declare function createApiArchives(source:
|
|
7
|
+
export declare function createApiArchives(source: SourceConfig): IArchives;
|
|
8
8
|
export type ArchivesChainOptions = {
|
|
9
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
10
|
directConnect?: boolean;
|
|
@@ -82,14 +82,8 @@ export declare class ArchivesChain implements IArchives {
|
|
|
82
82
|
} | undefined>;
|
|
83
83
|
private selectCoveringSources;
|
|
84
84
|
private runOnCovering;
|
|
85
|
-
find(prefix: string, config?:
|
|
86
|
-
|
|
87
|
-
type: "files" | "folders";
|
|
88
|
-
}): Promise<string[]>;
|
|
89
|
-
findInfo(prefix: string, config?: {
|
|
90
|
-
shallow?: boolean;
|
|
91
|
-
type: "files" | "folders";
|
|
92
|
-
}): Promise<ArchiveFileInfo[]>;
|
|
85
|
+
find(prefix: string, config?: FindConfig): Promise<string[]>;
|
|
86
|
+
findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]>;
|
|
93
87
|
getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
|
|
94
88
|
getSyncStatus(): Promise<ArchivesSyncStatus>;
|
|
95
89
|
getConfig(): Promise<ArchivesConfig>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { isNode, sort, watchSlowPromise } from "socket-function/src/misc";
|
|
2
2
|
import { delay } from "socket-function/src/batching";
|
|
3
3
|
import {
|
|
4
|
-
IArchives, RemoteConfig, RemoteConfigBase,
|
|
5
|
-
ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, GetConfig, GetInfoConfig, SetConfig, STORAGE_WRONG_VALID_WINDOW,
|
|
4
|
+
IArchives, RemoteConfig, RemoteConfigBase, SourceConfig,
|
|
5
|
+
ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig, STORAGE_WRONG_VALID_WINDOW,
|
|
6
6
|
STORAGE_WRONG_ROUTE, FULL_ROUTE, VARIABLE_SHARD, LARGE_SET_THRESHOLD, bufferChunkStream,
|
|
7
7
|
} from "../IArchives";
|
|
8
8
|
import {
|
|
@@ -16,7 +16,8 @@ import { SocketFunction } from "socket-function/SocketFunction";
|
|
|
16
16
|
import { ArchivesRemote, parseStorageUrl, authenticateStorage } from "./ArchivesRemote";
|
|
17
17
|
import { onServerRoutingChanged } from "./storageClientController";
|
|
18
18
|
import { ArchivesBackblaze } from "../backblaze";
|
|
19
|
-
import {
|
|
19
|
+
import { ServerBucketInfo, ActiveBucketInfo, getLocalArchives } from "./storageServerState";
|
|
20
|
+
import { isOwnAddress } from "./serverConfig";
|
|
20
21
|
import { RemoteStorageController, STORAGE_NOT_AUTHENTICATED } from "./storageController";
|
|
21
22
|
import { SourceWrapper, RETRY_START_DELAY, RETRY_MAX_DELAY, RETRY_GROWTH } from "./sourceWrapper";
|
|
22
23
|
|
|
@@ -27,6 +28,8 @@ const CONFIG_REFRESH_THROTTLE = 30 * 1000;
|
|
|
27
28
|
const AVAILABILITY_RECHECK_THROTTLE = 5 * 1000;
|
|
28
29
|
const PRIMARY_RETRY_TIMEOUT = 30 * 1000;
|
|
29
30
|
const PRIMARY_RETRY_DELAY = 2 * 1000;
|
|
31
|
+
const COVERING_RETRY_TIMEOUT = 30 * 1000;
|
|
32
|
+
const COVERING_RETRY_DELAY = 5 * 1000;
|
|
30
33
|
// Smart timeouts: an attempt gets this long to produce anything before we probe getInfo for the file's size (and the probe itself gets the same window)
|
|
31
34
|
const SMART_TIMEOUT_PROBE = 10 * 1000;
|
|
32
35
|
// Very generous assumed transfer rates - the resulting deadline exists to catch stuck sources, not slow ones
|
|
@@ -39,20 +42,22 @@ const SMART_TIMEOUT_MARKER = "ARCHIVES_SMART_TIMEOUT_c41a9d";
|
|
|
39
42
|
type SmartTimeout = {
|
|
40
43
|
path?: string;
|
|
41
44
|
uploadBytes?: number;
|
|
45
|
+
// Names the operation in timeout errors - without it a deletion (a tombstone write, uploadBytes 0) reads as "Upload of 0 bytes", which looks like a bug rather than a delete
|
|
46
|
+
label?: string;
|
|
42
47
|
};
|
|
43
48
|
|
|
44
49
|
/** 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). */
|
|
45
50
|
export { parseHostedUrl, parseBackblazeUrl, getBucketBaseUrl } from "./remoteConfig";
|
|
46
51
|
|
|
47
|
-
export function createApiArchives(source:
|
|
52
|
+
export function createApiArchives(source: SourceConfig): IArchives {
|
|
48
53
|
if (source.type === "backblaze") {
|
|
49
54
|
return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable, allowedOrigins: source.allowedOrigins });
|
|
50
55
|
}
|
|
51
56
|
let parsed = parseHostedUrl(source.url);
|
|
52
57
|
if (isNode() && isOwnAddress(parsed.address, parsed.port)) {
|
|
53
|
-
return getLocalArchives(parsed.account, parsed.bucketName);
|
|
58
|
+
return getLocalArchives(parsed.account, parsed.bucketName, source);
|
|
54
59
|
}
|
|
55
|
-
return new ArchivesRemote({ url: source.url, waitForAccess: false });
|
|
60
|
+
return new ArchivesRemote({ url: source.url, waitForAccess: false, sourceConfig: source });
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
type ChainState = {
|
|
@@ -65,13 +70,13 @@ export type ArchivesChainOptions = {
|
|
|
65
70
|
directConnect?: boolean;
|
|
66
71
|
};
|
|
67
72
|
|
|
68
|
-
function configWindowCurrent(config:
|
|
73
|
+
function configWindowCurrent(config: SourceConfig): boolean {
|
|
69
74
|
let now = Date.now();
|
|
70
75
|
let [start, end] = config.validWindow;
|
|
71
76
|
return start <= now && now < end;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
function configAcceptsWrites(config:
|
|
79
|
+
function configAcceptsWrites(config: SourceConfig): boolean {
|
|
75
80
|
return configWindowCurrent(config);
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -80,24 +85,16 @@ function materializeShardKey(key: string, target: SourceWrapper): string {
|
|
|
80
85
|
return key.replace(VARIABLE_SHARD, VARIABLE_SHARD + "_" + (start + Math.random() * (end - start)));
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
/**
|
|
88
|
+
/** Covers the whole key space with the AUTHORITATIVE sources: for each uncovered point, the FIRST source in config order whose route contains it - the exact selection every read and write uses, so listings come from the same nodes writes went to (read-your-writes). Never "fewest" or "widest": preferring a wide read replica over the routed write shards would serve listings from a node that only has the data second-hand. Returns undefined when the candidates leave a gap. */
|
|
84
89
|
function coverRoutes(candidates: SourceWrapper[]): SourceWrapper[] | undefined {
|
|
85
90
|
let chosen: SourceWrapper[] = [];
|
|
86
91
|
let covered = 0;
|
|
87
92
|
while (covered < 1) {
|
|
88
|
-
let
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (end > bestEnd) {
|
|
94
|
-
bestEnd = end;
|
|
95
|
-
best = source;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
if (!best) return undefined;
|
|
99
|
-
chosen.push(best);
|
|
100
|
-
covered = bestEnd;
|
|
93
|
+
let next = candidates.find(x => routeContains(x.config.route, covered));
|
|
94
|
+
if (!next) return undefined;
|
|
95
|
+
chosen.push(next);
|
|
96
|
+
let [, end] = next.config.route || FULL_ROUTE;
|
|
97
|
+
covered = end;
|
|
101
98
|
}
|
|
102
99
|
return chosen;
|
|
103
100
|
}
|
|
@@ -129,7 +126,7 @@ export class ArchivesChain implements IArchives {
|
|
|
129
126
|
}
|
|
130
127
|
|
|
131
128
|
public getDebugName() {
|
|
132
|
-
let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as
|
|
129
|
+
let urls = this.activeConfig.sources.map(x => typeof x === "string" && x || (x as SourceConfig).url);
|
|
133
130
|
return `chain ${urls.join(", ")}`;
|
|
134
131
|
}
|
|
135
132
|
|
|
@@ -279,7 +276,7 @@ export class ArchivesChain implements IArchives {
|
|
|
279
276
|
return config.sources.map(normalizeSource).some(x => x.public);
|
|
280
277
|
}
|
|
281
278
|
|
|
282
|
-
private async createChainSource(sourceConfig:
|
|
279
|
+
private async createChainSource(sourceConfig: SourceConfig, readOnly: boolean): Promise<SourceWrapper> {
|
|
283
280
|
let source = await SourceWrapper.create(sourceConfig, { readOnly });
|
|
284
281
|
source.startPinging();
|
|
285
282
|
return source;
|
|
@@ -354,7 +351,7 @@ export class ArchivesChain implements IArchives {
|
|
|
354
351
|
} else {
|
|
355
352
|
console.log(`Storage routing config changed (version ${getConfigVersion(state.config)} -> ${getConfigVersion(latest)}), received ${received}, rebuilding sources. New config: ${JSON.stringify(latest)}`);
|
|
356
353
|
}
|
|
357
|
-
let strippedKey = (config:
|
|
354
|
+
let strippedKey = (config: SourceConfig) => JSON.stringify({ ...config, validWindow: undefined });
|
|
358
355
|
let oldByConfig = new Map<string, SourceWrapper[]>();
|
|
359
356
|
for (let source of state.sources) {
|
|
360
357
|
let key = strippedKey(source.config);
|
|
@@ -533,7 +530,7 @@ export class ArchivesChain implements IArchives {
|
|
|
533
530
|
let result = await Promise.race([callPromise.then(value => ({ value })), delay(allowed).then(() => undefined)]);
|
|
534
531
|
if (result) return result.value;
|
|
535
532
|
abandon();
|
|
536
|
-
throw new Error(`${SMART_TIMEOUT_MARKER} Upload of ${timeout.uploadBytes} bytes to ${source.getDebugName()} timed out after ${Date.now() - start}ms (allowed ${Math.round(allowed)}ms: ${SMART_TIMEOUT_PROBE}ms base plus transfer at an assumed ${SMART_TIMEOUT_UPLOAD_BYTES_PER_SECOND} bytes/s)`);
|
|
533
|
+
throw new Error(`${SMART_TIMEOUT_MARKER} ${timeout.label || `Upload of ${timeout.uploadBytes} bytes`} to ${source.getDebugName()} timed out after ${Date.now() - start}ms (allowed ${Math.round(allowed)}ms: ${SMART_TIMEOUT_PROBE}ms base plus transfer at an assumed ${SMART_TIMEOUT_UPLOAD_BYTES_PER_SECOND} bytes/s)`);
|
|
537
534
|
}
|
|
538
535
|
const path = timeout.path;
|
|
539
536
|
if (path === undefined) return await callPromise;
|
|
@@ -606,14 +603,18 @@ export class ArchivesChain implements IArchives {
|
|
|
606
603
|
public async getFast(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number; url: string } | { data?: undefined; writeTime?: undefined; size?: undefined; url: string }> {
|
|
607
604
|
return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, fast: true, timeout: { path: fileName } }, async (archives, url) => {
|
|
608
605
|
let result = await archives.get2(fileName, config);
|
|
609
|
-
|
|
606
|
+
// Empty data is a tombstone, not content - see get2
|
|
607
|
+
if (!result || !result.data || !result.data.length && !config?.includeTombstones && !(config?.range && result.size)) return { url };
|
|
608
|
+
return { data: result.data, writeTime: result.writeTime, size: result.size, url };
|
|
610
609
|
});
|
|
611
610
|
}
|
|
612
611
|
/** Always resolves with a url - the authority that answered. A value that doesn't exist is still an answer FROM a server, so it comes back as { url } with no data (never plain undefined); errors from every source throw instead. */
|
|
613
612
|
public async get2(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number; url: string } | { data?: undefined; writeTime?: undefined; size?: undefined; url: string }> {
|
|
614
613
|
return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, timeout: { path: fileName } }, async (archives, url) => {
|
|
615
614
|
let result = await archives.get2(fileName, config);
|
|
616
|
-
|
|
615
|
+
// Empty data is a tombstone, not content (unless the caller asked for tombstones) - a ranged read of a REAL file can legitimately be empty though (range past EOF), which the total size distinguishes
|
|
616
|
+
if (!result || !result.data || !result.data.length && !config?.includeTombstones && !(config?.range && result.size)) return { url };
|
|
617
|
+
return { data: result.data, writeTime: result.writeTime, size: result.size, url };
|
|
617
618
|
});
|
|
618
619
|
}
|
|
619
620
|
public async getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number; url: string } | undefined> {
|
|
@@ -623,46 +624,85 @@ export class ArchivesChain implements IArchives {
|
|
|
623
624
|
});
|
|
624
625
|
}
|
|
625
626
|
|
|
626
|
-
private selectCoveringSources(state: ChainState,
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
627
|
+
private selectCoveringSources(state: ChainState, config?: { fallbacks?: boolean; exclude?: Set<SourceWrapper> }): SourceWrapper[] {
|
|
628
|
+
// No cooldown preference by default: listings must come from the AUTHORITATIVE sources (the first matching, like every read and write), or they miss just-written data. With fallbacks the caller chose availability over that: known-down sources move to the back so the next source covering their routes substitutes, and sources that failed THIS call are excluded outright.
|
|
629
|
+
let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api && !config?.exclude?.has(x));
|
|
630
|
+
if (config?.fallbacks) {
|
|
631
|
+
let usable = candidates.filter(x => !x.isOnCooldown());
|
|
632
|
+
let chosen = coverRoutes(usable) || coverRoutes(candidates);
|
|
633
|
+
if (chosen) return chosen;
|
|
634
|
+
}
|
|
635
|
+
let chosen = coverRoutes(candidates);
|
|
630
636
|
if (!chosen) {
|
|
631
637
|
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)`);
|
|
632
638
|
}
|
|
633
639
|
return chosen;
|
|
634
640
|
}
|
|
635
641
|
|
|
636
|
-
private async runOnCovering<T>(run: (archives: IArchives) => Promise<T
|
|
637
|
-
let
|
|
638
|
-
|
|
642
|
+
private async runOnCovering<T>(operation: string, run: (archives: IArchives) => Promise<T>, config?: { fallbacks?: boolean }): Promise<T[]> {
|
|
643
|
+
let deadline = Date.now() + COVERING_RETRY_TIMEOUT;
|
|
644
|
+
// Sources that failed during this call - with fallbacks, the next attempt covers their routes with the next source holding them instead
|
|
645
|
+
let failed = new Set<SourceWrapper>();
|
|
639
646
|
while (true) {
|
|
640
|
-
let
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
throw new Error(`${source.config.url} has URL-only access, which cannot serve this operation`);
|
|
645
|
-
}
|
|
647
|
+
let state = await this.getState();
|
|
648
|
+
// Errors name the OPERATION and the SPECIFIC sources that failed - "the find failed because source X is unavailable", never an anonymous failure attributed to the whole chain
|
|
649
|
+
let outcome = await (async (): Promise<{ values: T[] } | { error: Error }> => {
|
|
650
|
+
let covering: SourceWrapper[];
|
|
646
651
|
try {
|
|
647
|
-
|
|
652
|
+
covering = this.selectCoveringSources(state, { fallbacks: config?.fallbacks, exclude: failed });
|
|
648
653
|
} catch (e) {
|
|
649
|
-
|
|
650
|
-
source.noteFailure();
|
|
651
|
-
excluded.add(source);
|
|
652
|
-
return undefined;
|
|
654
|
+
return { error: new Error(`${operation} cannot run: ${(e as Error).message}`) };
|
|
653
655
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
656
|
+
let values: T[] = [];
|
|
657
|
+
let failures: { source: SourceWrapper; error: Error }[] = [];
|
|
658
|
+
await Promise.all(covering.map(async source => {
|
|
659
|
+
let api = source.api;
|
|
660
|
+
if (!api) {
|
|
661
|
+
failed.add(source);
|
|
662
|
+
failures.push({ source, error: new Error(`URL-only access, which cannot serve ${operation}`) });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
values.push(await run(api));
|
|
667
|
+
} catch (e) {
|
|
668
|
+
if (!source.isConnected()) source.noteFailure();
|
|
669
|
+
failed.add(source);
|
|
670
|
+
failures.push({ source, error: e as Error });
|
|
671
|
+
}
|
|
672
|
+
}));
|
|
673
|
+
if (!failures.length) return { values };
|
|
674
|
+
if (failures.length === 1) {
|
|
675
|
+
return { error: new Error(`${operation} failed because source ${failures[0].source.getDebugName()} is unavailable: ${failures[0].error.stack ?? failures[0].error}`) };
|
|
676
|
+
}
|
|
677
|
+
return { error: new Error(`${operation} failed because ${failures.length} of the ${covering.length} covering sources are unavailable: ${failures.map(x => `${x.source.getDebugName()}: ${x.error.stack ?? x.error}`).join(" | ")}`) };
|
|
678
|
+
})();
|
|
679
|
+
if ("values" in outcome) return outcome.values;
|
|
680
|
+
let error = outcome.error;
|
|
681
|
+
if (Date.now() >= deadline) throw error;
|
|
682
|
+
if (config?.fallbacks && failed.size) {
|
|
683
|
+
let substitutable = false;
|
|
684
|
+
try {
|
|
685
|
+
this.selectCoveringSources(state, { fallbacks: true, exclude: failed });
|
|
686
|
+
substitutable = true;
|
|
687
|
+
} catch { }
|
|
688
|
+
if (substitutable) {
|
|
689
|
+
console.error(`${error.message}. Substituting the failed sources' routes with fallback sources.`);
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
// No substitute covers the failed routes - retry the failed sources themselves after the delay
|
|
693
|
+
failed.clear();
|
|
657
694
|
}
|
|
695
|
+
console.error(`${error.message}. Retrying in ${COVERING_RETRY_DELAY / 1000}s (giving up at ${new Date(deadline).toISOString()}).`);
|
|
696
|
+
await delay(COVERING_RETRY_DELAY);
|
|
697
|
+
await this.recheckAvailability();
|
|
658
698
|
}
|
|
659
699
|
}
|
|
660
700
|
|
|
661
|
-
public async find(prefix: string, config?:
|
|
701
|
+
public async find(prefix: string, config?: FindConfig): Promise<string[]> {
|
|
662
702
|
return (await this.findInfo(prefix, config)).map(x => x.path);
|
|
663
703
|
}
|
|
664
|
-
public async findInfo(prefix: string, config?:
|
|
665
|
-
let results = await this.runOnCovering(archives => archives.findInfo(prefix, config));
|
|
704
|
+
public async findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]> {
|
|
705
|
+
let results = await this.runOnCovering(`The find of ${JSON.stringify(prefix)}`, archives => archives.findInfo(prefix, config), { fallbacks: config?.fallbacks });
|
|
666
706
|
let byPath = new Map<string, ArchiveFileInfo>();
|
|
667
707
|
for (let list of results) {
|
|
668
708
|
for (let file of list) {
|
|
@@ -677,7 +717,7 @@ export class ArchivesChain implements IArchives {
|
|
|
677
717
|
return merged;
|
|
678
718
|
}
|
|
679
719
|
public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
680
|
-
let results = await this.runOnCovering(archives => archives.getChangesAfter2(config));
|
|
720
|
+
let results = await this.runOnCovering(`The changes listing since ${new Date(config.time).toISOString()}`, archives => archives.getChangesAfter2(config));
|
|
681
721
|
let byPath = new Map<string, ArchiveFileInfo>();
|
|
682
722
|
for (let list of results) {
|
|
683
723
|
for (let file of list) {
|
|
@@ -692,7 +732,7 @@ export class ArchivesChain implements IArchives {
|
|
|
692
732
|
return merged;
|
|
693
733
|
}
|
|
694
734
|
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
695
|
-
let statuses = await this.runOnCovering(async archives => {
|
|
735
|
+
let statuses = await this.runOnCovering(`The sync status check`, async archives => {
|
|
696
736
|
if (!archives.getSyncStatus) {
|
|
697
737
|
throw new Error(`${archives.getDebugName()} does not support getSyncStatus`);
|
|
698
738
|
}
|
|
@@ -734,7 +774,7 @@ export class ArchivesChain implements IArchives {
|
|
|
734
774
|
await this.setLargeFile({ path: fileName, lastModified: config?.lastModified, getNextData: bufferChunkStream(data) });
|
|
735
775
|
return fileName;
|
|
736
776
|
}
|
|
737
|
-
await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: data.length } }, archives => archives.set(fileName, data, config));
|
|
777
|
+
await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: data.length, label: `Upload of ${JSON.stringify(fileName)} (${data.length} bytes)` } }, archives => archives.set(fileName, data, config));
|
|
738
778
|
return fileName;
|
|
739
779
|
}
|
|
740
780
|
|
|
@@ -763,7 +803,7 @@ export class ArchivesChain implements IArchives {
|
|
|
763
803
|
return ROUTING_FILE;
|
|
764
804
|
}
|
|
765
805
|
public async del(fileName: string, config?: DelConfig): Promise<void> {
|
|
766
|
-
await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: 0 } }, archives => archives.del(fileName, config));
|
|
806
|
+
await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: 0, label: `Deletion of ${JSON.stringify(fileName)}` } }, archives => archives.del(fileName, config));
|
|
767
807
|
}
|
|
768
808
|
|
|
769
809
|
// One write target per route range, lowest latency first. Within a shard the target is ALWAYS the first source in config order (see runPrimary - writes must stay on the same node): connectivity only decides which SHARD we pick, never which node within it, so a shard whose node is disconnected is dropped entirely when connectedOnly is set.
|
|
@@ -810,7 +850,7 @@ export class ArchivesChain implements IArchives {
|
|
|
810
850
|
let fullKey = materializeShardKey(key, target);
|
|
811
851
|
try {
|
|
812
852
|
// The shard picking already retries across shards, so a stuck shard just costs its timeout and we move on. Large data streams via setLargeFile - the key is already materialized here, so the no-VARIABLE_SHARD restriction on setLargeFile doesn't apply.
|
|
813
|
-
await this.applySmartTimeout({ uploadBytes: data.length }, target, () => {
|
|
853
|
+
await this.applySmartTimeout({ uploadBytes: data.length, label: `Variable-shard upload of ${JSON.stringify(fullKey)} (${data.length} bytes)` }, target, () => {
|
|
814
854
|
if (data.length > LARGE_SET_THRESHOLD) {
|
|
815
855
|
return target.write(archives => archives.setLargeFile({ path: fullKey, lastModified: config?.lastModified, getNextData: bufferChunkStream(data) }));
|
|
816
856
|
}
|
|
@@ -958,12 +998,12 @@ async function callServer<T>(url: string, run: (controller: typeof RemoteStorage
|
|
|
958
998
|
}
|
|
959
999
|
|
|
960
1000
|
export async function listServerBuckets(config: { url: string; account: string }): Promise<ServerBucketInfo[]> {
|
|
961
|
-
return await callServer(config.url, controller => controller.listBuckets(config.account));
|
|
1001
|
+
return await callServer(config.url, controller => controller.listBuckets({ account: config.account }));
|
|
962
1002
|
}
|
|
963
1003
|
|
|
964
1004
|
/** The live, in-memory state of one bucket on a server (routing config included), or a string saying why it is unavailable. Cheap - it never touches the server's disk - but only works while that bucket is loaded there. */
|
|
965
1005
|
export async function getServerActiveBucket(config: { url: string; account: string; bucketName: string }): Promise<ActiveBucketInfo | string> {
|
|
966
|
-
return await callServer(config.url, controller => controller.getActiveBucket(config.account, config.bucketName));
|
|
1006
|
+
return await callServer(config.url, controller => controller.getActiveBucket({ account: config.account, bucketName: config.bucketName }));
|
|
967
1007
|
}
|
|
968
1008
|
|
|
969
1009
|
/** The buckets a server currently has loaded. Admin only, so in practice this is our own machine's other process - a deploy successor asking its predecessor what is actually in use. */
|
|
@@ -973,15 +1013,16 @@ export async function listServerActiveBucketKeys(config: { url: string }): Promi
|
|
|
973
1013
|
|
|
974
1014
|
/** Tells a server to load one of its buckets into memory (starting its synchronization) and returns its live state, or a string saying why it could not be loaded. Only touches that server - nothing is written and no other source is contacted. */
|
|
975
1015
|
export async function activateServerBucket(config: { url: string; account: string; bucketName: string }): Promise<ActiveBucketInfo | string> {
|
|
976
|
-
return await callServer(config.url, controller => controller.activateBucket(config.account, config.bucketName));
|
|
1016
|
+
return await callServer(config.url, controller => controller.activateBucket({ account: config.account, bucketName: config.bucketName }));
|
|
977
1017
|
}
|
|
978
1018
|
|
|
979
1019
|
/** Zeroes the write statistics listServerBuckets reports, for every bucket in the account. */
|
|
980
1020
|
export async function clearServerWriteStats(config: { url: string; account: string }): Promise<{ clearedBuckets: number }> {
|
|
981
|
-
return await callServer(config.url, controller => controller.clearWriteStats(config.account));
|
|
1021
|
+
return await callServer(config.url, controller => controller.clearWriteStats({ account: config.account }));
|
|
982
1022
|
}
|
|
983
1023
|
|
|
984
1024
|
export async function getBucketInfo(config: { url: string }): Promise<ArchivesConfig> {
|
|
985
|
-
|
|
1025
|
+
// getConfig is bucket-level (no store selection), so the fabricated sourceConfig never has to match anything
|
|
1026
|
+
let remote = new ArchivesRemote({ url: config.url, waitForAccess: false, sourceConfig: normalizeSource(config.url) });
|
|
986
1027
|
return await remote.getConfig();
|
|
987
1028
|
}
|