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
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "../IArchives";
|
|
4
4
|
export declare const ROUTING_FILE = "storage/storagerouting.json";
|
|
5
|
+
/** The variable-shard route override embedded in the key ("<sentinel>_<value>", see VARIABLE_SHARD), or undefined when the key has no sentinel or the sentinel has no value yet. */
|
|
6
|
+
export declare function parseVariableRoute(key: string): number | undefined;
|
|
7
|
+
/** Where a key routes in [0, 1). A materialized variable-shard suffix completely overrides the hash. */
|
|
8
|
+
export declare function getRoute(key: string): number;
|
|
9
|
+
export declare function routeContains(route: [number, number] | undefined, value: number): boolean;
|
|
10
|
+
export declare function routesOverlap(a: [number, number] | undefined, b: [number, number] | undefined): boolean;
|
|
11
|
+
/** The overlap of two route ranges, or undefined when they don't overlap. */
|
|
12
|
+
export declare function routeIntersection(a: [number, number] | undefined, b: [number, number] | undefined): [number, number] | undefined;
|
|
5
13
|
export declare function getConfigVersion(config: RemoteConfig): number;
|
|
6
14
|
/** Strips the routing-file suffix, leaving the bucket's public base URL (file paths append to it). */
|
|
7
15
|
export declare function getBucketBaseUrl(url: string): string;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module.allowclient = true;
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { sort, sha256HashBuffer } from "socket-function/src/misc";
|
|
4
|
+
import { getBufferInt } from "socket-function/src/bits";
|
|
5
|
+
import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig, FULL_VALID_WINDOW, FULL_ROUTE, VARIABLE_SHARD } from "../IArchives";
|
|
4
6
|
|
|
5
7
|
// Parsing / normalization of RemoteConfig (see IArchives.ts). Every bucket stores its own
|
|
6
8
|
// configuration (a RemoteConfig) inside itself, at ROUTING_FILE. Writing that file creates the
|
|
@@ -10,6 +12,45 @@ import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from ".
|
|
|
10
12
|
export const ROUTING_FILE = "storage/storagerouting.json";
|
|
11
13
|
const ROUTING_SUFFIX = "/" + ROUTING_FILE;
|
|
12
14
|
|
|
15
|
+
const ROUTE_PRECISION = 1000 * 1000 * 1000;
|
|
16
|
+
|
|
17
|
+
/** The variable-shard route override embedded in the key ("<sentinel>_<value>", see VARIABLE_SHARD), or undefined when the key has no sentinel or the sentinel has no value yet. */
|
|
18
|
+
export function parseVariableRoute(key: string): number | undefined {
|
|
19
|
+
let index = key.indexOf(VARIABLE_SHARD);
|
|
20
|
+
if (index === -1) return undefined;
|
|
21
|
+
let match = /^_(\d+(?:\.\d+)?)/.exec(key.slice(index + VARIABLE_SHARD.length));
|
|
22
|
+
if (!match) return undefined;
|
|
23
|
+
return +match[1];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where a key routes in [0, 1). A materialized variable-shard suffix completely overrides the hash. */
|
|
27
|
+
export function getRoute(key: string): number {
|
|
28
|
+
let override = parseVariableRoute(key);
|
|
29
|
+
if (override !== undefined) return override;
|
|
30
|
+
let hash = getBufferInt(sha256HashBuffer(key));
|
|
31
|
+
return hash % ROUTE_PRECISION / ROUTE_PRECISION;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Route ranges are [start, end) - inclusive start, exclusive end
|
|
35
|
+
export function routeContains(route: [number, number] | undefined, value: number): boolean {
|
|
36
|
+
if (!route) return true;
|
|
37
|
+
return route[0] <= value && value < route[1];
|
|
38
|
+
}
|
|
39
|
+
export function routesOverlap(a: [number, number] | undefined, b: [number, number] | undefined): boolean {
|
|
40
|
+
let [aStart, aEnd] = a || FULL_ROUTE;
|
|
41
|
+
let [bStart, bEnd] = b || FULL_ROUTE;
|
|
42
|
+
return aStart < bEnd && bStart < aEnd;
|
|
43
|
+
}
|
|
44
|
+
/** The overlap of two route ranges, or undefined when they don't overlap. */
|
|
45
|
+
export function routeIntersection(a: [number, number] | undefined, b: [number, number] | undefined): [number, number] | undefined {
|
|
46
|
+
let [aStart, aEnd] = a || FULL_ROUTE;
|
|
47
|
+
let [bStart, bEnd] = b || FULL_ROUTE;
|
|
48
|
+
let start = Math.max(aStart, bStart);
|
|
49
|
+
let end = Math.min(aEnd, bEnd);
|
|
50
|
+
if (start >= end) return undefined;
|
|
51
|
+
return [start, end];
|
|
52
|
+
}
|
|
53
|
+
|
|
13
54
|
// A missing version counts as -1, so any explicitly versioned config beats an unversioned one
|
|
14
55
|
export function getConfigVersion(config: RemoteConfig): number {
|
|
15
56
|
return config.version ?? -1;
|
|
@@ -54,6 +95,22 @@ export function parseBackblazeUrl(url: string): { bucketName: string } {
|
|
|
54
95
|
|
|
55
96
|
export function normalizeSource(source: RemoteConfigBase): HostedConfig | BackblazeConfig {
|
|
56
97
|
if (typeof source !== "string") {
|
|
98
|
+
let window = source.validWindow;
|
|
99
|
+
if (!Array.isArray(window) || window.length !== 2 || !window.every(x => typeof x === "number")) {
|
|
100
|
+
throw new Error(`Object source configs must specify validWindow ([startMs, endMs] of the write times the source is valid for) - configuration changes must be scheduled, not flipped instantly. Use FULL_VALID_WINDOW when the source is always valid. Was ${JSON.stringify(window)} on ${JSON.stringify(source).slice(0, 500)}`);
|
|
101
|
+
}
|
|
102
|
+
let route = source.route;
|
|
103
|
+
if (route !== undefined && (!Array.isArray(route) || route.length !== 2 || !route.every(x => typeof x === "number") || route[0] < 0 || route[1] > 1 || route[0] >= route[1])) {
|
|
104
|
+
throw new Error(`Source route must be a [start, end) fraction range within [0, 1] with start < end, was ${JSON.stringify(route)} on ${JSON.stringify(source).slice(0, 500)}`);
|
|
105
|
+
}
|
|
106
|
+
if (source.readerDiskLimit !== undefined) {
|
|
107
|
+
if (typeof source.readerDiskLimit !== "number" || source.readerDiskLimit <= 0) {
|
|
108
|
+
throw new Error(`readerDiskLimit must be a positive byte count, was ${JSON.stringify(source.readerDiskLimit)} on ${JSON.stringify(source).slice(0, 500)}`);
|
|
109
|
+
}
|
|
110
|
+
if (!source.noFullSync) {
|
|
111
|
+
throw new Error(`readerDiskLimit requires noFullSync: a full copy cannot be bounded, only a read cache can. On ${JSON.stringify(source).slice(0, 500)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
57
114
|
if (source.type === "remote") {
|
|
58
115
|
// Throws if the URL is malformed, so bad configs are rejected before they're stored
|
|
59
116
|
parseHostedUrl(source.url);
|
|
@@ -62,17 +119,60 @@ export function normalizeSource(source: RemoteConfigBase): HostedConfig | Backbl
|
|
|
62
119
|
}
|
|
63
120
|
let hostname = new URL(source).hostname;
|
|
64
121
|
if (hostname.endsWith(".backblazeb2.com")) {
|
|
65
|
-
|
|
122
|
+
// Validates the URL (throws on malformed) before it's stored; the bucket name is read back
|
|
123
|
+
// out of the URL at use sites, never stored on the config.
|
|
124
|
+
parseBackblazeUrl(source);
|
|
125
|
+
return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW };
|
|
66
126
|
}
|
|
67
127
|
parseHostedUrl(source);
|
|
68
|
-
return { type: "remote", url: source };
|
|
128
|
+
return { type: "remote", url: source, validWindow: FULL_VALID_WINDOW };
|
|
69
129
|
}
|
|
70
130
|
|
|
71
131
|
export function normalizeRemoteConfig(config: RemoteConfig | RemoteConfigBase): RemoteConfig {
|
|
132
|
+
let result: RemoteConfig;
|
|
72
133
|
if (typeof config !== "string" && "sources" in config) {
|
|
73
|
-
|
|
134
|
+
result = { version: config.version, sources: config.sources.map(normalizeSource) };
|
|
135
|
+
} else {
|
|
136
|
+
result = { sources: [normalizeSource(config)] };
|
|
137
|
+
}
|
|
138
|
+
// Mixed immutability makes no sense AMONG sources valid at the same time: a mutable source
|
|
139
|
+
// would accept overwrites that its immutable peers refuse to synchronize, forking their
|
|
140
|
+
// contents. Sources are grouped by transitively overlapping valid windows (absorb everything
|
|
141
|
+
// overlapping the group, extend the group's end, repeat) - a soft check that is correct as
|
|
142
|
+
// long as windows have clean breaks (a group ends exactly where the next begins).
|
|
143
|
+
let sources = result.sources.map(normalizeSource);
|
|
144
|
+
let sorted = [...sources];
|
|
145
|
+
sort(sorted, x => x.validWindow[0]);
|
|
146
|
+
let group: typeof sources = [];
|
|
147
|
+
let groupEnd = 0;
|
|
148
|
+
function checkGroup() {
|
|
149
|
+
let immutableCount = group.filter(x => x.immutable).length;
|
|
150
|
+
if (immutableCount && immutableCount !== group.length) {
|
|
151
|
+
throw new Error(`Sources with overlapping valid windows must agree on immutability: ${immutableCount} of ${group.length} are immutable. Sources: ${JSON.stringify(group.map(x => ({ url: x.url, validWindow: x.validWindow, immutable: !!x.immutable })))}`);
|
|
152
|
+
}
|
|
153
|
+
// The sources valid at any instant must cover the whole key space, or keys routing into a
|
|
154
|
+
// gap could never be read
|
|
155
|
+
let routes = group.map(x => x.route || FULL_ROUTE);
|
|
156
|
+
sort(routes, x => x[0]);
|
|
157
|
+
let covered = 0;
|
|
158
|
+
for (let route of routes) {
|
|
159
|
+
if (route[0] > covered) break;
|
|
160
|
+
covered = Math.max(covered, route[1]);
|
|
161
|
+
}
|
|
162
|
+
if (covered < 1) {
|
|
163
|
+
throw new Error(`Sources with overlapping valid windows must cover the full route space [0, 1); coverage stops at ${covered}. Sources: ${JSON.stringify(group.map(x => ({ url: x.url, validWindow: x.validWindow, route: x.route || FULL_ROUTE })))}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (let source of sorted) {
|
|
167
|
+
if (group.length && source.validWindow[0] >= groupEnd) {
|
|
168
|
+
checkGroup();
|
|
169
|
+
group = [];
|
|
170
|
+
}
|
|
171
|
+
group.push(source);
|
|
172
|
+
groupEnd = Math.max(groupEnd, source.validWindow[1]);
|
|
74
173
|
}
|
|
75
|
-
|
|
174
|
+
checkGroup();
|
|
175
|
+
return result;
|
|
76
176
|
}
|
|
77
177
|
|
|
78
178
|
export function parseRoutingData(data: Buffer): RemoteConfig {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { IArchives, HostedConfig, BackblazeConfig } from "../IArchives";
|
|
2
|
+
import { ArchivesUrl } from "./ArchivesUrl";
|
|
3
|
+
export declare const RETRY_START_DELAY: number;
|
|
4
|
+
export declare const RETRY_MAX_DELAY: number;
|
|
5
|
+
export declare const RETRY_GROWTH = 1.5;
|
|
6
|
+
export declare class SourceWrapper {
|
|
7
|
+
config: HostedConfig | BackblazeConfig;
|
|
8
|
+
private background;
|
|
9
|
+
api?: IArchives;
|
|
10
|
+
url?: ArchivesUrl;
|
|
11
|
+
writeBlocked?: string;
|
|
12
|
+
private remote?;
|
|
13
|
+
private disposed;
|
|
14
|
+
private reconnectRunning;
|
|
15
|
+
private accessCache?;
|
|
16
|
+
private constructor();
|
|
17
|
+
static create(config: HostedConfig | BackblazeConfig, options?: {
|
|
18
|
+
background?: boolean;
|
|
19
|
+
}): Promise<SourceWrapper>;
|
|
20
|
+
getDebugName(): string;
|
|
21
|
+
isConnected(): boolean;
|
|
22
|
+
/** Call after a request failed while isConnected() was false: starts (if not already running)
|
|
23
|
+
* the background reconnect loop. Never blocks - the failed request still throws. */
|
|
24
|
+
noteFailure(): void;
|
|
25
|
+
private reconnectLoop;
|
|
26
|
+
private checkAccess;
|
|
27
|
+
read<T>(run: (archives: IArchives) => Promise<T>): Promise<T>;
|
|
28
|
+
hasWriteAccess(): Promise<boolean>;
|
|
29
|
+
private pings;
|
|
30
|
+
private pingTimer;
|
|
31
|
+
/** Starts measuring this source's latency (for variable-shard target preference). Only hosted
|
|
32
|
+
* remotes are pinged; our own local server counts as 0, everything else as Infinity. */
|
|
33
|
+
startPinging(): void;
|
|
34
|
+
/** Median of the recent pings. Sources that can't be pinged sort last (Infinity), except our
|
|
35
|
+
* own in-process server, which is the best possible target (0). */
|
|
36
|
+
getLatency(): number;
|
|
37
|
+
/** Writes always go through the API, so a permission error throws to the caller on every write
|
|
38
|
+
* (and access granted in the meantime is picked up automatically). */
|
|
39
|
+
write<T>(run: (archives: IArchives) => Promise<T>): Promise<T>;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
module.allowclient = true;
|
|
2
|
+
|
|
3
|
+
import { isNode, sort } from "socket-function/src/misc";
|
|
4
|
+
import { delay } from "socket-function/src/batching";
|
|
5
|
+
import { getSecret } from "../../misc/getSecret";
|
|
6
|
+
import { IArchives, HostedConfig, BackblazeConfig } from "../IArchives";
|
|
7
|
+
import { ROUTING_FILE, getBucketBaseUrl, parseHostedUrl, parseBackblazeUrl } from "./remoteConfig";
|
|
8
|
+
import { ArchivesRemote } from "./ArchivesRemote";
|
|
9
|
+
import { ArchivesUrl } from "./ArchivesUrl";
|
|
10
|
+
import { ArchivesBackblaze } from "../backblaze";
|
|
11
|
+
import { getStorageServerConfigOptional, getLocalArchives } from "./storageServerState";
|
|
12
|
+
|
|
13
|
+
export const RETRY_START_DELAY = 2 * 1000;
|
|
14
|
+
export const RETRY_MAX_DELAY = 5 * 60 * 1000;
|
|
15
|
+
export const RETRY_GROWTH = 1.5;
|
|
16
|
+
const ACCESS_RECHECK_INTERVAL = 60 * 1000;
|
|
17
|
+
const PING_INTERVAL = 60 * 1000;
|
|
18
|
+
const PING_HISTORY = 10;
|
|
19
|
+
|
|
20
|
+
async function hasBackblazeCreds(): Promise<boolean> {
|
|
21
|
+
if (!isNode()) return false;
|
|
22
|
+
try {
|
|
23
|
+
await getSecret("backblaze.json.applicationKeyId");
|
|
24
|
+
await getSecret("backblaze.json.applicationKey");
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// One source of a RemoteConfig, as a usable IArchives. Reads fall back to the public-URL form when
|
|
32
|
+
// we don't have API access; writes always attempt the API (so access granted later is picked up on
|
|
33
|
+
// the next write). When a call fails while the WebSocket is down, noteFailure starts a background
|
|
34
|
+
// reconnect loop with a ramping delay - it never blocks callers, it just keeps trying so the
|
|
35
|
+
// connection eventually comes back. Sources without a WebSocket (backblaze, URL-form, our own
|
|
36
|
+
// local server) report always-connected: they're last-resort sources where throwing is fine.
|
|
37
|
+
export class SourceWrapper {
|
|
38
|
+
public api?: IArchives;
|
|
39
|
+
public url?: ArchivesUrl;
|
|
40
|
+
public writeBlocked?: string;
|
|
41
|
+
private remote?: ArchivesRemote;
|
|
42
|
+
private disposed = false;
|
|
43
|
+
private reconnectRunning = false;
|
|
44
|
+
private accessCache?: { hasAccess: boolean; time: number };
|
|
45
|
+
|
|
46
|
+
private constructor(
|
|
47
|
+
public config: HostedConfig | BackblazeConfig,
|
|
48
|
+
// false for short-lived probe instances, which must never leave a retry loop behind
|
|
49
|
+
private background: boolean,
|
|
50
|
+
) { }
|
|
51
|
+
|
|
52
|
+
public static async create(config: HostedConfig | BackblazeConfig, options?: { background?: boolean }): Promise<SourceWrapper> {
|
|
53
|
+
let wrapper = new SourceWrapper(config, options?.background !== false);
|
|
54
|
+
if (config.type === "backblaze") {
|
|
55
|
+
if (await hasBackblazeCreds()) {
|
|
56
|
+
wrapper.api = new ArchivesBackblaze({ bucketName: parseBackblazeUrl(config.url).bucketName, public: config.public, immutable: config.immutable });
|
|
57
|
+
} else if (isNode()) {
|
|
58
|
+
wrapper.writeBlocked = `No backblaze credentials for ${config.url}. Provide backblaze.json.applicationKeyId and backblaze.json.applicationKey via getSecret to enable API access.`;
|
|
59
|
+
} else {
|
|
60
|
+
wrapper.writeBlocked = `Browsers cannot write to backblaze (bucket ${config.url})`;
|
|
61
|
+
}
|
|
62
|
+
if (!wrapper.api && config.public !== false) {
|
|
63
|
+
wrapper.url = new ArchivesUrl(getBucketBaseUrl(config.url));
|
|
64
|
+
}
|
|
65
|
+
return wrapper;
|
|
66
|
+
}
|
|
67
|
+
let parsed = parseHostedUrl(config.url);
|
|
68
|
+
let server = isNode() && getStorageServerConfigOptional() || undefined;
|
|
69
|
+
if (server && parsed.address === server.domain && parsed.port === server.port) {
|
|
70
|
+
// A bucket hosted by our own process - use it directly instead of calling ourselves
|
|
71
|
+
wrapper.api = getLocalArchives(parsed.account, parsed.bucketName);
|
|
72
|
+
return wrapper;
|
|
73
|
+
}
|
|
74
|
+
wrapper.remote = new ArchivesRemote({ url: config.url, accountName: config.accountName, waitForAccess: false });
|
|
75
|
+
wrapper.api = wrapper.remote;
|
|
76
|
+
if (config.public !== false) {
|
|
77
|
+
wrapper.url = new ArchivesUrl(getBucketBaseUrl(config.url));
|
|
78
|
+
}
|
|
79
|
+
return wrapper;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public getDebugName(): string {
|
|
83
|
+
return `source/${this.config.url}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public isConnected(): boolean {
|
|
87
|
+
if (!this.remote) return true;
|
|
88
|
+
return this.remote.isConnected();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Call after a request failed while isConnected() was false: starts (if not already running)
|
|
92
|
+
* the background reconnect loop. Never blocks - the failed request still throws. */
|
|
93
|
+
public noteFailure(): void {
|
|
94
|
+
if (!this.background || this.disposed || this.reconnectRunning) return;
|
|
95
|
+
if (this.isConnected()) return;
|
|
96
|
+
this.reconnectRunning = true;
|
|
97
|
+
void this.reconnectLoop();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async reconnectLoop(): Promise<void> {
|
|
101
|
+
let retryDelay = RETRY_START_DELAY;
|
|
102
|
+
while (true) {
|
|
103
|
+
await delay(retryDelay);
|
|
104
|
+
if (this.disposed) {
|
|
105
|
+
this.reconnectRunning = false;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (this.isConnected()) break;
|
|
109
|
+
try {
|
|
110
|
+
// Any call re-establishes the WebSocket; the result doesn't matter
|
|
111
|
+
await this.remote?.getInfo(ROUTING_FILE);
|
|
112
|
+
break;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// Even a failing call (e.g. access denied) proves the connection is back
|
|
115
|
+
if (this.isConnected()) break;
|
|
116
|
+
console.warn(`Cannot connect to storage source ${this.config.url}, retrying in ${Math.round(retryDelay / 1000)}s. ${(e as Error).stack ?? e}`);
|
|
117
|
+
}
|
|
118
|
+
retryDelay = Math.min(RETRY_MAX_DELAY, retryDelay * RETRY_GROWTH);
|
|
119
|
+
}
|
|
120
|
+
this.reconnectRunning = false;
|
|
121
|
+
console.log(`Reconnected to storage source ${this.config.url}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// For hosted sources: cached access check, so reads know whether to use the API or the public
|
|
125
|
+
// URL form. Access, once seen, is assumed to stick; no-access is re-checked periodically (the
|
|
126
|
+
// check also registers our access request server-side, which logs the grant link).
|
|
127
|
+
private async checkAccess(): Promise<boolean> {
|
|
128
|
+
let remote = this.remote;
|
|
129
|
+
if (!remote) return !!this.api;
|
|
130
|
+
let cached = this.accessCache;
|
|
131
|
+
if (cached && (cached.hasAccess || Date.now() - cached.time < ACCESS_RECHECK_INTERVAL)) {
|
|
132
|
+
return cached.hasAccess;
|
|
133
|
+
}
|
|
134
|
+
let waiting = await remote.waitingForAccess();
|
|
135
|
+
this.accessCache = { hasAccess: !waiting, time: Date.now() };
|
|
136
|
+
if (waiting) {
|
|
137
|
+
console.warn(`No access to storage account for ${this.config.url} (our machine ${waiting.machineId}, ip ${waiting.ip}). Reading via the public URL form where possible. Grant access at ${waiting.link}`);
|
|
138
|
+
}
|
|
139
|
+
return !waiting;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public async read<T>(run: (archives: IArchives) => Promise<T>): Promise<T> {
|
|
143
|
+
if (this.api && await this.checkAccess()) {
|
|
144
|
+
return await run(this.api);
|
|
145
|
+
}
|
|
146
|
+
if (this.url) {
|
|
147
|
+
return await run(this.url);
|
|
148
|
+
}
|
|
149
|
+
if (this.api) {
|
|
150
|
+
// No public URL form - let the API call throw its own error (which includes the access
|
|
151
|
+
// instructions for hosted sources)
|
|
152
|
+
return await run(this.api);
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`Source ${this.config.url} has no API access and no public URL form (public: false)`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public async hasWriteAccess(): Promise<boolean> {
|
|
158
|
+
if (this.writeBlocked || !this.api) return false;
|
|
159
|
+
return await this.api.hasWriteAccess();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private pings: number[] = [];
|
|
163
|
+
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
164
|
+
|
|
165
|
+
/** Starts measuring this source's latency (for variable-shard target preference). Only hosted
|
|
166
|
+
* remotes are pinged; our own local server counts as 0, everything else as Infinity. */
|
|
167
|
+
public startPinging(): void {
|
|
168
|
+
let remote = this.remote;
|
|
169
|
+
if (!remote || this.pingTimer || this.disposed) return;
|
|
170
|
+
let measure = async () => {
|
|
171
|
+
let start = Date.now();
|
|
172
|
+
try {
|
|
173
|
+
await remote.ping();
|
|
174
|
+
} catch {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.pings.push(Date.now() - start);
|
|
178
|
+
if (this.pings.length > PING_HISTORY) {
|
|
179
|
+
this.pings.shift();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
void measure();
|
|
183
|
+
this.pingTimer = setInterval(() => {
|
|
184
|
+
void measure();
|
|
185
|
+
}, PING_INTERVAL);
|
|
186
|
+
(this.pingTimer as { unref?: () => void }).unref?.();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Median of the recent pings. Sources that can't be pinged sort last (Infinity), except our
|
|
190
|
+
* own in-process server, which is the best possible target (0). */
|
|
191
|
+
public getLatency(): number {
|
|
192
|
+
if (!this.remote) {
|
|
193
|
+
if (this.config.type === "remote" && this.api) return 0;
|
|
194
|
+
return Infinity;
|
|
195
|
+
}
|
|
196
|
+
if (!this.pings.length) return Infinity;
|
|
197
|
+
let sorted = [...this.pings];
|
|
198
|
+
sort(sorted, x => x);
|
|
199
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Writes always go through the API, so a permission error throws to the caller on every write
|
|
203
|
+
* (and access granted in the meantime is picked up automatically). */
|
|
204
|
+
public async write<T>(run: (archives: IArchives) => Promise<T>): Promise<T> {
|
|
205
|
+
if (this.writeBlocked) {
|
|
206
|
+
throw new Error(this.writeBlocked);
|
|
207
|
+
}
|
|
208
|
+
let api = this.api;
|
|
209
|
+
if (!api) {
|
|
210
|
+
throw new Error(`Source ${this.config.url} has no API access, so it cannot accept writes`);
|
|
211
|
+
}
|
|
212
|
+
let result = await run(api);
|
|
213
|
+
this.accessCache = { hasAccess: true, time: Date.now() };
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public dispose(): void {
|
|
218
|
+
this.disposed = true;
|
|
219
|
+
if (this.pingTimer) {
|
|
220
|
+
clearInterval(this.pingTimer);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Remote storage — core design ideas
|
|
2
|
+
|
|
3
|
+
This is the reasoning behind the remote storage system (createArchives / storageServer / BlobStore). The code documents the mechanics; this documents the ideas that must survive refactors.
|
|
4
|
+
|
|
5
|
+
## Configuration updates happen only on startup
|
|
6
|
+
|
|
7
|
+
A client adopts a routing config once, during initialization, and then runs on it. Configs never automatically upgrade mid-run: the 5-minute poll picks up whatever the sources currently hold (so a developer's change spreads), but the version-gated *upgrade* — writing a newer configured version over an older stored one — happens only at startup. This is deliberate: config changes are made by a developer running code, and that developer is present at startup. It also prevents reversion loops — if a server briefly held a newer version and then went away, running clients don't cling to that phantom version; they follow what the surviving sources actually hold, and only a fresh startup with an explicitly newer configured version moves things forward again.
|
|
8
|
+
|
|
9
|
+
## Scanning + tombstones + versions make any two sources mergeable
|
|
10
|
+
|
|
11
|
+
Every store fully rescans its sources' metadata (on startup and periodically), and every write — including deletions — is ordered by last-write time. Deletions are tombstones: an empty file IS a missing file, kept as a size-0 index entry (for a week) so the deletion itself propagates and reconciles like any other write. Because everything is a timestamped write and scans are bidirectional (pull what they have, push what they're missing), any two sources can be merged in any order and converge to the same state: newest write time wins, per file. There is no operation whose loss corrupts the system — a failed background write, a missed delete, a source that was down for a day — the next scan reconciles it.
|
|
12
|
+
|
|
13
|
+
## The full source list is duplicated into every source
|
|
14
|
+
|
|
15
|
+
Each bucket stores its complete routing config (the full redundancy list) inside itself, at storage/storagerouting.json, on every source. Discovery is therefore trivial: as long as ONE node is up, a client reading it gets the full overview of the intended sources. And because clients re-discover on every startup (and re-read every 5 minutes), a developer can change the configuration in one place and clients rapidly accept it — no redeploy, no coordinated restart of the fleet.
|
|
16
|
+
|
|
17
|
+
## BulkDatabase2 index + our own disk as just another source
|
|
18
|
+
|
|
19
|
+
Each bucket's store keeps a BulkDatabase2 index of every file (path, write time, size, holding source), served from memory — existence checks and listings are extremely fast, never touching a source. The trick that keeps the index accurate: our own disk is not special-cased, it is simply the first synchronization source. The same scan/reconcile code that synchronizes remote sources also synchronizes the disk with the index, so the index self-heals from the same machinery instead of needing separate consistency logic.
|
|
20
|
+
|
|
21
|
+
## Metadata first, data second
|
|
22
|
+
|
|
23
|
+
Synchronization starts with metadata: every source's full listing (path, write time, size) is scanned into the index up front. This is fast and small, so a server very quickly knows exactly what exists everywhere — and can act as a consistent authority on the bucket (serving correct reads, listings, and existence checks by fetching bytes from whichever source holds them) long before it has downloaded the actual data. The data then follows: eagerly by default (the full sync), or — when there is simply too much data to copy — in noFullSync mode, where the disk is only a lazy read cache. Either way consistency is unchanged: the index is complete, writes still go to all the servers, and only where the bytes rest differs.
|
|
24
|
+
|
|
25
|
+
## Client writes are consistent; client reads are redundant
|
|
26
|
+
|
|
27
|
+
Clients always write to the same node — the first source whose valid window is current — and if that node is down, the write FAILS rather than going to another node. A client having a network hiccup and wrongly deciding nodes are down must never scatter its writes across the chain; that would desynchronize the sources based on one client's flaky view of the network. Reads, by contrast, fail over freely across every redundant source: sources are synchronized copies, so reading from any of them is safe. Maximum read uptime, strictly consistent writes. (Write redundancy still exists — it just lives server-side: the receiving node fans writes out downstream and reconciliation heals anything missed, all ordered by the once-stamped write time.)
|
|
28
|
+
|
|
29
|
+
## Trust instead of API keys
|
|
30
|
+
|
|
31
|
+
Machines authenticate with their certs.ts identity (proving ownership of their machine key with a signed, server-bound token), and access is granted per account to specific machineIds. No API keys are minted, copied into configs, or passed around — granting a machine access is one command on the storage machine, and revoking it is removing the trust record. The only API keys left in the system are the ones third parties force on us: backblaze and cloudflare, both resolved through getSecret.
|
|
@@ -31,6 +31,7 @@ export type AccessState = {
|
|
|
31
31
|
trustedMachines?: TrustRecord[];
|
|
32
32
|
};
|
|
33
33
|
export declare const RemoteStorageController: import("socket-function/SocketFunctionTypes").SocketRegistered<{
|
|
34
|
+
ping: () => Promise<void>;
|
|
34
35
|
authenticate: (token: AuthToken) => Promise<{
|
|
35
36
|
machineId: string;
|
|
36
37
|
ip: string;
|
|
@@ -56,6 +57,7 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
|
|
|
56
57
|
}) => Promise<{
|
|
57
58
|
data: Buffer;
|
|
58
59
|
writeTime: number;
|
|
60
|
+
size: number;
|
|
59
61
|
} | undefined>;
|
|
60
62
|
set: (account: string, bucketName: string, path: string, data: Buffer, lastModified?: number) => Promise<void>;
|
|
61
63
|
del: (account: string, bucketName: string, path: string) => Promise<void>;
|
|
@@ -69,6 +71,15 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
|
|
|
69
71
|
}) => Promise<ArchiveFileInfo[]>;
|
|
70
72
|
getChangesAfter: (account: string, bucketName: string, time: number) => Promise<ArchiveFileInfo[]>;
|
|
71
73
|
getArchivesConfig: (account: string, bucketName: string) => Promise<ArchivesConfig>;
|
|
74
|
+
getIndexInfo: (account: string, bucketName: string) => Promise<{
|
|
75
|
+
fileCount: number;
|
|
76
|
+
byteCount: number;
|
|
77
|
+
sources: {
|
|
78
|
+
debugName: string;
|
|
79
|
+
fileCount: number;
|
|
80
|
+
byteCount: number;
|
|
81
|
+
}[];
|
|
82
|
+
} | undefined>;
|
|
72
83
|
getSyncStatus: (account: string, bucketName: string) => Promise<ArchivesSyncStatus>;
|
|
73
84
|
startLargeFile: (account: string, bucketName: string, path: string) => Promise<string>;
|
|
74
85
|
uploadPart: (uploadId: string, data: Buffer) => Promise<void>;
|