sliftutils 1.7.29 → 1.7.33
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/builders/extensionBuild.ts +1 -1
- package/bundler/bundleEntry.ts +1 -3
- package/bundler/bundler.ts +2 -4
- package/bundler/sourceMaps.ts +1 -2
- package/examplestorage/browser.tsx +1 -2
- package/examplestorage/exampleserver.ts +1 -2
- package/index.d.ts +106 -99
- package/misc/dist/environment.ts.cache +2 -2
- package/misc/dist/getSecret.ts.cache +2 -2
- package/misc/dist/strings.ts.cache +2 -2
- package/misc/dist/zip.ts.cache +2 -2
- package/misc/https/dist/certs.ts.cache +2 -2
- package/misc/https/dist/dns.ts.cache +7 -16
- package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
- package/misc/https/dns.d.ts +1 -4
- package/misc/https/dns.ts +5 -14
- package/misc/https/hostServer.d.ts +2 -0
- package/misc/https/hostServer.ts +6 -7
- package/misc/https/httpsCerts.ts +4 -10
- package/misc/openrouter.ts +1 -2
- package/package.json +1 -1
- package/render-utils/FullscreenModal.tsx +1 -4
- package/render-utils/GenericFormat.tsx +2 -8
- package/render-utils/Input.tsx +5 -13
- package/render-utils/InputLabel.tsx +4 -6
- package/render-utils/InputPicker.tsx +1 -2
- package/render-utils/URLParam.ts +2 -3
- package/render-utils/dist/observer.tsx.cache +2 -2
- package/render-utils/mobxTyped.ts +2 -3
- package/render-utils/niceStringify.ts +2 -4
- package/storage/ArchivesDisk.ts +4 -14
- package/storage/BulkDatabase2/BulkDatabase2.d.ts +1 -2
- package/storage/BulkDatabase2/BulkDatabase2.ts +4 -11
- package/storage/BulkDatabase2/BulkDatabaseBase.ts +42 -114
- package/storage/BulkDatabase2/BulkDatabaseFormat.ts +31 -91
- package/storage/BulkDatabase2/BulkDatabaseMerge.ts +19 -57
- package/storage/BulkDatabase2/BulkDatabaseReader.ts +4 -12
- package/storage/BulkDatabase2/LoadedIndex.ts +6 -18
- package/storage/BulkDatabase2/WriteOverlay.ts +3 -8
- package/storage/BulkDatabase2/blockCache.ts +12 -28
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +40 -103
- package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +19 -50
- package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +18 -50
- package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +7 -15
- package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +9 -21
- package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +6 -11
- package/storage/BulkDatabase2/dist/blockCache.ts.cache +15 -31
- package/storage/BulkDatabase2/dist/mergeLock.ts.cache +10 -29
- package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +10 -18
- package/storage/BulkDatabase2/dist/streamLog.ts.cache +17 -43
- package/storage/BulkDatabase2/dist/syncClient.ts.cache +11 -27
- package/storage/BulkDatabase2/mergeLock.ts +7 -26
- package/storage/BulkDatabase2/mergeMarkers.ts +7 -15
- package/storage/BulkDatabase2/streamLog.ts +14 -40
- package/storage/BulkDatabase2/syncClient.ts +8 -24
- package/storage/FileFolderAPI.tsx +28 -75
- package/storage/IArchives.d.ts +3 -3
- package/storage/IArchives.ts +21 -50
- package/storage/IStorage.ts +2 -4
- package/storage/PendingManager.tsx +2 -3
- package/storage/TransactionStorage.ts +11 -24
- package/storage/backblaze.ts +12 -43
- package/storage/dist/ArchivesDisk.ts.cache +7 -17
- package/storage/dist/FileFolderAPI.tsx.cache +25 -63
- package/storage/dist/IArchives.ts.cache +10 -20
- package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
- package/storage/dist/JSONStorage.ts.cache +2 -2
- package/storage/dist/PendingManager.tsx.cache +5 -6
- package/storage/dist/TransactionStorage.ts.cache +14 -27
- package/storage/dist/backblaze.ts.cache +15 -46
- package/storage/dist/embeddingFormats.ts.cache +11 -24
- package/storage/dist/fileSystemPointer.ts.cache +4 -7
- package/storage/dist/remoteFileStorage.ts.cache +17 -36
- package/storage/embeddingFormats.ts +11 -27
- package/storage/fileSystemPointer.ts +2 -7
- package/storage/proxydatabase/dist/ivfEmbeddingDatabase.ts.cache +8 -18
- package/storage/proxydatabase/dist/transactionSet.ts.cache +4 -7
- package/storage/proxydatabase/inMemoryDatabase.ts +1 -3
- package/storage/proxydatabase/ivfEmbeddingDatabase.ts +8 -23
- package/storage/proxydatabase/transactionSet.ts +2 -5
- package/storage/remoteFileServer.ts +20 -48
- package/storage/remoteFileStorage.ts +14 -33
- package/storage/remoteStorage/ArchivesRemote.d.ts +1 -4
- package/storage/remoteStorage/ArchivesRemote.ts +11 -26
- package/storage/remoteStorage/ArchivesUrl.ts +2 -4
- package/storage/remoteStorage/accessPage.tsx +1 -5
- package/storage/remoteStorage/blobStore.d.ts +5 -13
- package/storage/remoteStorage/blobStore.ts +52 -130
- package/storage/remoteStorage/createArchives.d.ts +9 -13
- package/storage/remoteStorage/createArchives.ts +43 -187
- package/storage/remoteStorage/deployTakeover.d.ts +18 -32
- package/storage/remoteStorage/deployTakeover.ts +70 -384
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +12 -24
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +5 -7
- package/storage/remoteStorage/dist/blobStore.ts.cache +47 -117
- package/storage/remoteStorage/dist/cliArgs.ts.cache +2 -2
- package/storage/remoteStorage/dist/createArchives.ts.cache +54 -186
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +81 -387
- package/storage/remoteStorage/dist/intermediateSources.ts.cache +119 -0
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +36 -24
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +20 -40
- package/storage/remoteStorage/dist/storageClientController.ts.cache +32 -0
- package/storage/remoteStorage/dist/storageController.ts.cache +30 -64
- package/storage/remoteStorage/dist/storageServerState.ts.cache +269 -186
- package/storage/remoteStorage/grantAccessCli.ts +1 -3
- package/storage/remoteStorage/intermediateSources.d.ts +21 -0
- package/storage/remoteStorage/intermediateSources.ts +117 -0
- package/storage/remoteStorage/remoteConfig.ts +33 -20
- package/storage/remoteStorage/sourceWrapper.d.ts +8 -19
- package/storage/remoteStorage/sourceWrapper.ts +19 -41
- package/storage/remoteStorage/storageClientController.ts +1 -3
- package/storage/remoteStorage/storageController.d.ts +5 -3
- package/storage/remoteStorage/storageController.ts +12 -83
- package/storage/remoteStorage/storageServer.ts +20 -34
- package/storage/remoteStorage/storageServerState.d.ts +28 -6
- package/storage/remoteStorage/storageServerState.ts +275 -221
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import { lazy } from "socket-function/src/caching";
|
|
4
|
+
import { runInfinitePoll } from "socket-function/src/batching";
|
|
3
5
|
import { getFileStorageNested2 } from "../FileFolderAPI";
|
|
4
6
|
import { TransactionStorage } from "../TransactionStorage";
|
|
5
7
|
import { JSONStorage } from "../JSONStorage";
|
|
@@ -10,32 +12,21 @@ import {
|
|
|
10
12
|
ArchivesSyncStatus,
|
|
11
13
|
STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, FULL_ROUTE,
|
|
12
14
|
} from "../IArchives";
|
|
13
|
-
import { ROUTING_FILE, parseRoutingData, parseHostedUrl, buildFileUrl, getConfigVersion, getRoute, routeContains, routeIntersection } from "./remoteConfig";
|
|
14
|
-
import {
|
|
15
|
+
import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort, buildFileUrl, getConfigVersion, getRoute, routeContains, routeIntersection } from "./remoteConfig";
|
|
16
|
+
import { injectIntermediateSource, expireIntermediateSources, getIntermediateSources, findSplitUrl, nextIntermediateVersion, INTERMEDIATE_EXPIRE_GRACE } from "./intermediateSources";
|
|
17
|
+
import { getTakeoverIntermediate } from "./deployTakeover";
|
|
15
18
|
import { createApiArchives } from "./createArchives";
|
|
16
19
|
import type { IStorage } from "../IStorage";
|
|
20
|
+
import { broadcastRoutingChanged } from "./storageController";
|
|
17
21
|
import type { AccessRequest, TrustRecord } from "./storageController";
|
|
18
22
|
import { getArg } from "./cliArgs";
|
|
19
23
|
|
|
20
|
-
// The storage server's global server-side state. hostStorageServer sets the config once at
|
|
21
|
-
// startup; everything else (system storages, buckets) is a global cache created lazily on first
|
|
22
|
-
// use, throwing if the config was never set.
|
|
23
|
-
//
|
|
24
|
-
// Buckets have no separate registry: each bucket's configuration (a RemoteConfig) lives inside the
|
|
25
|
-
// bucket itself, at ROUTING_FILE. A bucket exists iff that file exists. Writing it creates the
|
|
26
|
-
// bucket (building its store, which may immediately start synchronization scans); overwriting it —
|
|
27
|
-
// directly or via a file pulled in from a synchronization source — rebuilds the store with the new
|
|
28
|
-
// sources (cancelling the old store's scans).
|
|
29
|
-
|
|
30
24
|
export type StorageServerConfig = {
|
|
31
25
|
domain: string;
|
|
32
26
|
port: number;
|
|
33
27
|
rootDomain: string;
|
|
34
|
-
// user@externalIp of the storage machine, for generated ssh commands
|
|
35
28
|
sshTarget: string;
|
|
36
|
-
// Absolute command that runs the grantAccess CLI on the storage machine (admin args appended)
|
|
37
29
|
serverCommand: string;
|
|
38
|
-
// The server's root storage folder (absolute)
|
|
39
30
|
folder: string;
|
|
40
31
|
};
|
|
41
32
|
|
|
@@ -49,15 +40,10 @@ export function getStorageServerConfig(): StorageServerConfig {
|
|
|
49
40
|
}
|
|
50
41
|
return config;
|
|
51
42
|
}
|
|
52
|
-
// For self-detection: code that CAN run outside the storage server (createArchives) uses this to
|
|
53
|
-
// check "is this URL our own process?" without throwing.
|
|
54
43
|
export function getStorageServerConfigOptional(): StorageServerConfig | undefined {
|
|
55
44
|
return config;
|
|
56
45
|
}
|
|
57
46
|
|
|
58
|
-
// When set, all write-path operations (creating files, appending large uploads, creating buckets)
|
|
59
|
-
// throw this message. Reads, findInfo, and deletes still work — so clients can free space. Managed
|
|
60
|
-
// by hostStorageServer's disk-space monitor.
|
|
61
47
|
let writesRejectedReason: string | undefined;
|
|
62
48
|
export function setWritesRejectedReason(reason: string | undefined): void {
|
|
63
49
|
writesRejectedReason = reason;
|
|
@@ -69,12 +55,6 @@ export function assertWritesAllowed(): void {
|
|
|
69
55
|
if (writesRejectedReason) throw new Error(writesRejectedReason);
|
|
70
56
|
}
|
|
71
57
|
|
|
72
|
-
// The system storages (trust, requests) are the same kind of thing — a JSON key/value store under
|
|
73
|
-
// <folder>/system2/<name> — so they share one cache, keyed by name. ("system2" because the layout
|
|
74
|
-
// changed when bucket configs moved into the buckets themselves; old "system"/"buckets" folders
|
|
75
|
-
// are simply ignored.)
|
|
76
|
-
// setTrustedMachines is called by consumers BEFORE starting the server, so when the config isn't
|
|
77
|
-
// set yet we fall back to the same --folder arg the CLI starts the server with.
|
|
78
58
|
function getStorageFolder(): string {
|
|
79
59
|
let config = getStorageServerConfigOptional();
|
|
80
60
|
if (config) return config.folder;
|
|
@@ -106,7 +86,95 @@ export function getRequests(): Promise<IStorage<AccessRequest[]>> {
|
|
|
106
86
|
return getSystemStorage<AccessRequest[]>("requests");
|
|
107
87
|
}
|
|
108
88
|
|
|
109
|
-
|
|
89
|
+
export type BucketWriteStats = {
|
|
90
|
+
/** Every set call the bucket accepted */
|
|
91
|
+
originalWrites: number;
|
|
92
|
+
originalBytes: number;
|
|
93
|
+
/** What actually reached the sources. Fast writes coalesce repeated writes to the same key, so this is lower than the original counts (and is what the disk actually did). */
|
|
94
|
+
flushedWrites: number;
|
|
95
|
+
flushedBytes: number;
|
|
96
|
+
};
|
|
97
|
+
function emptyWriteStats(): BucketWriteStats {
|
|
98
|
+
return { originalWrites: 0, originalBytes: 0, flushedWrites: 0, flushedBytes: 0 };
|
|
99
|
+
}
|
|
100
|
+
function addWriteStats(a: BucketWriteStats, b: BucketWriteStats): BucketWriteStats {
|
|
101
|
+
return {
|
|
102
|
+
originalWrites: a.originalWrites + b.originalWrites,
|
|
103
|
+
originalBytes: a.originalBytes + b.originalBytes,
|
|
104
|
+
flushedWrites: a.flushedWrites + b.flushedWrites,
|
|
105
|
+
flushedBytes: a.flushedBytes + b.flushedBytes,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function getWriteStatsStorage(): Promise<IStorage<BucketWriteStats>> {
|
|
109
|
+
return getSystemStorage<BucketWriteStats>("writeStats");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Counted in memory as deltas since the last flush, so counting a write never touches the disk. The flush reads the stored totals and adds the delta, which also makes it correct across restarts.
|
|
113
|
+
const writeStatDeltas = new Map<string, BucketWriteStats>();
|
|
114
|
+
|
|
115
|
+
async function flushWriteStats(): Promise<void> {
|
|
116
|
+
if (!writeStatDeltas.size) return;
|
|
117
|
+
let pending = [...writeStatDeltas];
|
|
118
|
+
writeStatDeltas.clear();
|
|
119
|
+
let storage = await getWriteStatsStorage();
|
|
120
|
+
for (let [key, delta] of pending) {
|
|
121
|
+
try {
|
|
122
|
+
await storage.set(key, addWriteStats(await storage.get(key) || emptyWriteStats(), delta));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// Put the delta back, so a failed flush loses nothing
|
|
125
|
+
writeStatDeltas.set(key, addWriteStats(writeStatDeltas.get(key) || emptyWriteStats(), delta));
|
|
126
|
+
console.error(`Flushing write stats for bucket ${key} failed: ${(e as Error).stack ?? e}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const startWriteStatsFlushing = lazy(() => {
|
|
132
|
+
runInfinitePoll(WRITE_STATS_FLUSH_INTERVAL, flushWriteStats);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function countBucketWrite(key: string, kind: "original" | "flushed", bytes: number): void {
|
|
136
|
+
let delta = writeStatDeltas.get(key);
|
|
137
|
+
if (!delta) {
|
|
138
|
+
delta = emptyWriteStats();
|
|
139
|
+
writeStatDeltas.set(key, delta);
|
|
140
|
+
}
|
|
141
|
+
if (kind === "original") {
|
|
142
|
+
delta.originalWrites++;
|
|
143
|
+
delta.originalBytes += bytes;
|
|
144
|
+
} else {
|
|
145
|
+
delta.flushedWrites++;
|
|
146
|
+
delta.flushedBytes += bytes;
|
|
147
|
+
}
|
|
148
|
+
startWriteStatsFlushing();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function getBucketWriteStats(key: string): Promise<BucketWriteStats> {
|
|
152
|
+
let storage = await getWriteStatsStorage();
|
|
153
|
+
let stored = await storage.get(key) || emptyWriteStats();
|
|
154
|
+
let delta = writeStatDeltas.get(key);
|
|
155
|
+
return delta && addWriteStats(stored, delta) || stored;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Zeroes the write statistics of every bucket in the account, including counts not yet flushed. */
|
|
159
|
+
export async function clearAccountWriteStats(account: string): Promise<number> {
|
|
160
|
+
let storage = await getWriteStatsStorage();
|
|
161
|
+
let prefix = `${account}/`;
|
|
162
|
+
let cleared = 0;
|
|
163
|
+
for (let key of await storage.getKeys()) {
|
|
164
|
+
if (!key.startsWith(prefix)) continue;
|
|
165
|
+
writeStatDeltas.delete(key);
|
|
166
|
+
await storage.remove(key);
|
|
167
|
+
cleared++;
|
|
168
|
+
}
|
|
169
|
+
for (let key of [...writeStatDeltas.keys()]) {
|
|
170
|
+
if (!key.startsWith(prefix)) continue;
|
|
171
|
+
writeStatDeltas.delete(key);
|
|
172
|
+
cleared++;
|
|
173
|
+
}
|
|
174
|
+
console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
|
|
175
|
+
return cleared;
|
|
176
|
+
}
|
|
177
|
+
|
|
110
178
|
export async function setTrustedMachines(config: { account: string; machineIds: string[] }): Promise<void> {
|
|
111
179
|
let trust = await getTrust();
|
|
112
180
|
let prefix = `${config.account}|`;
|
|
@@ -127,70 +195,49 @@ export async function setTrustedMachines(config: { account: string; machineIds:
|
|
|
127
195
|
}
|
|
128
196
|
}
|
|
129
197
|
|
|
130
|
-
// ── buckets ──
|
|
131
|
-
|
|
132
198
|
export type LoadedBucket = {
|
|
133
199
|
account: string;
|
|
134
200
|
bucketName: string;
|
|
135
|
-
// The bucket's parsed routing config (the contents of ROUTING_FILE)
|
|
136
201
|
routing: RemoteConfig;
|
|
137
|
-
// JSON of routing, for change detection
|
|
138
202
|
routingJSON: string;
|
|
139
|
-
// ALL of our own entries in routing.sources (this bucket on this server). More than one when
|
|
140
|
-
// the bucket's options change over time - one entry per valid window (e.g. immutable dropped,
|
|
141
|
-
// or fast enabled, starting at a scheduled instant).
|
|
142
203
|
selfEntries: HostedConfig[];
|
|
143
|
-
// The entry valid when the store was built - the structural options (rawDisk, which entries
|
|
144
|
-
// are downstream). Per-write options (fast/immutable) are instead evaluated at each write's
|
|
145
|
-
// time, see getWriteConfig/assertMutable. undefined when the config doesn't mention us.
|
|
146
204
|
self: HostedConfig | undefined;
|
|
147
205
|
store: IBucketStore;
|
|
148
|
-
// The store plan minus its valid windows (see StorePlan.structureKey): when only windows
|
|
149
|
-
// change, the store is updated in place instead of rebuilt
|
|
150
206
|
structureKey: string;
|
|
151
207
|
};
|
|
152
208
|
|
|
153
209
|
const buckets = new Map<string, Promise<LoadedBucket | undefined>>();
|
|
154
210
|
|
|
155
|
-
// setTimeout cannot represent longer delays; more distant window boundaries re-check after this
|
|
156
211
|
const MAX_REBUILD_TIMER_DELAY = 2 ** 31 - 1;
|
|
157
|
-
// Rebuild slightly after the boundary, so the newly-valid entry is unambiguously active
|
|
158
212
|
const REBUILD_BOUNDARY_BUFFER = 1000;
|
|
213
|
+
const WRITE_STATS_FLUSH_INTERVAL = 5 * 60 * 1000;
|
|
159
214
|
|
|
160
215
|
function getBucketFolder(account: string, bucketName: string): string {
|
|
161
216
|
return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName);
|
|
162
217
|
}
|
|
163
218
|
|
|
164
|
-
// Ports we are listening on beyond the main config port - a deploy-takeover successor serves on a
|
|
165
|
-
// temporary alternate port, and must recognize alternate-port source URLs as itself
|
|
166
219
|
const extraListenPorts = new Set<number>();
|
|
167
220
|
export function addExtraListenPort(port: number): void {
|
|
168
221
|
extraListenPorts.add(port);
|
|
169
222
|
}
|
|
223
|
+
export function removeExtraListenPort(port: number): void {
|
|
224
|
+
extraListenPorts.delete(port);
|
|
225
|
+
}
|
|
170
226
|
function findSelfIndexes(routing: RemoteConfig, account: string, bucketName: string): number[] {
|
|
171
227
|
let { domain, port } = getStorageServerConfig();
|
|
172
228
|
let indexes: number[] = [];
|
|
173
|
-
// The takeover's alternate port is the OTHER process of the switchover, on OUR machine with
|
|
174
|
-
// OUR disk - self for sync purposes on both sides (the old node must not scan/push its own
|
|
175
|
-
// successor; the shared folder plus the switchover disk scans reconcile them)
|
|
176
|
-
let takeoverAltPort = getTakeoverAltPort();
|
|
177
229
|
for (let i = 0; i < routing.sources.length; i++) {
|
|
178
230
|
let source = routing.sources[i];
|
|
179
231
|
if (typeof source === "string" || source.type !== "remote") continue;
|
|
180
232
|
let parsed = parseHostedUrl(source.url);
|
|
181
233
|
if (parsed.address !== domain || parsed.account !== account || parsed.bucketName !== bucketName) continue;
|
|
182
|
-
if (parsed.port === port || extraListenPorts.has(parsed.port)
|
|
234
|
+
if (parsed.port === port || extraListenPorts.has(parsed.port)) {
|
|
183
235
|
indexes.push(i);
|
|
184
236
|
}
|
|
185
237
|
}
|
|
186
238
|
return indexes;
|
|
187
239
|
}
|
|
188
240
|
|
|
189
|
-
// The entry whose valid window contains the time (among the entries covering the key's route,
|
|
190
|
-
// when one is given - a server can hold different shards with different options). Half-open
|
|
191
|
-
// ([start, end)), so clean breaks resolve unambiguously. Falls back to the nearest window when
|
|
192
|
-
// none contains the time - gaps shouldn't exist, but a write must still resolve to SOME
|
|
193
|
-
// configuration.
|
|
194
241
|
function selectEntryAt(entries: HostedConfig[], time: number, route?: number): HostedConfig | undefined {
|
|
195
242
|
if (route !== undefined) {
|
|
196
243
|
let covering = entries.filter(x => routeContains(x.route, route));
|
|
@@ -212,9 +259,6 @@ function selectEntryAt(entries: HostedConfig[], time: number, route?: number): H
|
|
|
212
259
|
return best;
|
|
213
260
|
}
|
|
214
261
|
|
|
215
|
-
// Bucket options can change over time (multiple self entries with different valid windows), and
|
|
216
|
-
// the structural ones (rawDisk, the downstream list) are baked into the store - so the store is
|
|
217
|
-
// rebuilt shortly after each of our window boundaries passes, making the newly-valid entry apply.
|
|
218
262
|
function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
219
263
|
let now = Date.now();
|
|
220
264
|
let boundaries = loaded.selfEntries.flatMap(x => x.validWindow).filter(t => t > now);
|
|
@@ -223,7 +267,6 @@ function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
|
223
267
|
let key = `${loaded.account}/${loaded.bucketName}`;
|
|
224
268
|
let timer = setTimeout(() => {
|
|
225
269
|
void (async () => {
|
|
226
|
-
// A routing change may have already replaced this store; only IT gets a boundary timer
|
|
227
270
|
let current = await buckets.get(key);
|
|
228
271
|
if (current !== loaded) return;
|
|
229
272
|
if (Date.now() < nextBoundary) {
|
|
@@ -236,19 +279,10 @@ function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
|
236
279
|
(timer as { unref?: () => void }).unref?.();
|
|
237
280
|
}
|
|
238
281
|
|
|
239
|
-
// Extra scans around each of OUR valid-window starts (a deploy switchover is just this too - its
|
|
240
|
-
// remap adds valid windows), catching last-moment writes the previous window's owner accepted near
|
|
241
|
-
// the boundary. By the final scan the previous owner has flushed everything (fast writes never
|
|
242
|
-
// delay past a window's end), so nothing is missed.
|
|
243
282
|
const BOUNDARY_SCAN_OFFSETS = [-30 * 1000, 2 * 1000, 30 * 1000];
|
|
244
|
-
// How far back a boundary scan asks the previous owner for changes: anything older has already
|
|
245
|
-
// arrived through normal synchronization, and this covers the longest a write can sit unflushed
|
|
246
283
|
const BOUNDARY_SCAN_LOOKBACK = DEFAULT_FAST_WRITE_DELAY + WINDOW_END_FLUSH_MARGIN;
|
|
247
|
-
// The catch-up scan for a window that was already active when we built the store
|
|
248
284
|
const STARTUP_BOUNDARY_SCAN_DELAY = 30 * 1000;
|
|
249
285
|
|
|
250
|
-
// Each (bucket, boundary, offset) is scheduled exactly once, surviving store rebuilds - the timer
|
|
251
|
-
// re-resolves the bucket (and recomputes owners from the then-current routing) when it fires
|
|
252
286
|
const scheduledBoundaryScans = new Set<string>();
|
|
253
287
|
const boundaryScansRunning = new Set<string>();
|
|
254
288
|
|
|
@@ -262,10 +296,6 @@ function scheduleBoundaryScans(loaded: LoadedBucket): void {
|
|
|
262
296
|
starts.add(start);
|
|
263
297
|
}
|
|
264
298
|
}
|
|
265
|
-
// A window we are ALREADY inside when the store is built (startup, or a config that made us
|
|
266
|
-
// valid immediately) got none of the lead-up scans - so one runs shortly after, in case we
|
|
267
|
-
// came up just as the previous owner is realizing it must shut down. Only recent boundaries
|
|
268
|
-
// qualify: older trailing writes have long since propagated through normal synchronization.
|
|
269
299
|
for (let self of loaded.selfEntries) {
|
|
270
300
|
let [start, end] = self.validWindow;
|
|
271
301
|
if (!(start <= now && now < end) || start <= 0) continue;
|
|
@@ -287,8 +317,6 @@ function scheduleBoundaryScans(loaded: LoadedBucket): void {
|
|
|
287
317
|
scheduledBoundaryScans.add(scheduleKey);
|
|
288
318
|
let arm = () => {
|
|
289
319
|
let timer = setTimeout(() => {
|
|
290
|
-
// Timer length is capped (far-future boundaries), so re-arm until the time
|
|
291
|
-
// actually arrives
|
|
292
320
|
if (Date.now() < at) {
|
|
293
321
|
arm();
|
|
294
322
|
return;
|
|
@@ -305,7 +333,6 @@ function scheduleBoundaryScans(loaded: LoadedBucket): void {
|
|
|
305
333
|
async function runBoundaryScan(bucketKey: string, windowStart: number, offset: number, offsetLabel?: string): Promise<void> {
|
|
306
334
|
let label = `bucket ${bucketKey}, window starting ${new Date(windowStart).toISOString()}, offset ${offsetLabel || `${offset / 1000}s`}`;
|
|
307
335
|
if (boundaryScansRunning.has(bucketKey)) {
|
|
308
|
-
// Boundary scans never run in parallel; a slow one simply swallows later scheduled points
|
|
309
336
|
console.log(`Skipping boundary scan (${label}): the previous boundary scan is still running`);
|
|
310
337
|
return;
|
|
311
338
|
}
|
|
@@ -315,23 +342,17 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
315
342
|
if (!loaded) return;
|
|
316
343
|
let store = loaded.store;
|
|
317
344
|
if (!(store instanceof BlobStore)) return;
|
|
318
|
-
|
|
319
|
-
// changed since this scan was scheduled
|
|
320
|
-
let effective = applyDeployRemap(loaded.routing);
|
|
345
|
+
let effective = loaded.routing;
|
|
321
346
|
let selfIndexes = findSelfIndexes(effective, loaded.account, loaded.bucketName);
|
|
322
347
|
let selfIndexSet = new Set(selfIndexes);
|
|
323
348
|
let selves = selfIndexes.map(i => effective.sources[i] as HostedConfig).filter(x => x.validWindow[0] === windowStart);
|
|
324
349
|
if (!selves.length) return;
|
|
325
350
|
let prevTime = windowStart - 1;
|
|
326
351
|
let needDiskScan = false;
|
|
327
|
-
// sourceIndex -> the slice of our route that source owned in the previous window
|
|
328
352
|
let remoteOwners = new Map<number, [number, number]>();
|
|
329
353
|
for (let self of selves) {
|
|
330
354
|
let selfRoute = self.route || FULL_ROUTE;
|
|
331
355
|
let idx = effective.sources.indexOf(self);
|
|
332
|
-
// These scans only run on the write node - the first source valid at the new window's
|
|
333
|
-
// start for our route. An earlier entry fully covering our route means writes go there,
|
|
334
|
-
// and IT does the boundary scans.
|
|
335
356
|
let shadowed = false;
|
|
336
357
|
for (let j = 0; j < idx; j++) {
|
|
337
358
|
let other = effective.sources[j];
|
|
@@ -345,8 +366,6 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
345
366
|
}
|
|
346
367
|
}
|
|
347
368
|
if (shadowed) continue;
|
|
348
|
-
// The previous window's owners: walking the config in order (write-target order),
|
|
349
|
-
// each source claims whatever slice of our route no earlier source already owns
|
|
350
369
|
let uncovered: [number, number][] = [[selfRoute[0], selfRoute[1]]];
|
|
351
370
|
for (let j = 0; j < effective.sources.length && uncovered.length; j++) {
|
|
352
371
|
let other = effective.sources[j];
|
|
@@ -369,8 +388,6 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
369
388
|
uncovered = remaining;
|
|
370
389
|
if (!claimed) continue;
|
|
371
390
|
if (selfIndexSet.has(j)) {
|
|
372
|
-
// The previous owner shares our storage (same machine + bucket, e.g. the other
|
|
373
|
-
// process of a deploy switchover), so a disk rescan sees its writes
|
|
374
391
|
needDiskScan = true;
|
|
375
392
|
} else {
|
|
376
393
|
let existing = remoteOwners.get(j);
|
|
@@ -399,57 +416,27 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
399
416
|
}
|
|
400
417
|
|
|
401
418
|
type StorePlan = {
|
|
402
|
-
effective: RemoteConfig;
|
|
403
419
|
selfEntries: HostedConfig[];
|
|
404
420
|
self: HostedConfig | undefined;
|
|
405
421
|
rawDisk: boolean;
|
|
406
|
-
// The BlobStore sources this plan builds: [0] is the disk, the rest are downstream configs
|
|
407
422
|
sourceSpecs: { sourceConfig?: HostedConfig | BackblazeConfig; validWindow: [number, number]; route?: [number, number]; noFullSync?: boolean }[];
|
|
408
423
|
readerDiskLimit?: number;
|
|
409
|
-
// Everything about the plan EXCEPT the valid windows. Two plans with equal structure keys
|
|
410
|
-
// differ only in windows, which the running store applies in place - it must survive the
|
|
411
|
-
// routine config evolution of reducing the last forever-window and appending a new entry,
|
|
412
|
-
// instead of being disposed and rebuilt (losing sync state and rescanning everything).
|
|
413
424
|
structureKey: string;
|
|
414
425
|
};
|
|
415
426
|
|
|
416
|
-
// The stable identity of a source endpoint: its config with the in-place-updatable parts
|
|
417
|
-
// (windows, routes) stripped, so a source is recognized across config changes
|
|
418
427
|
function sourceIdentity(sourceConfig: HostedConfig | BackblazeConfig | undefined): string {
|
|
419
428
|
if (!sourceConfig) return "disk";
|
|
420
429
|
return JSON.stringify({ ...sourceConfig, validWindow: undefined, route: undefined });
|
|
421
430
|
}
|
|
422
431
|
|
|
423
432
|
function computeStorePlan(account: string, bucketName: string, routing: RemoteConfig): StorePlan {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
// the STORED config - so version guards, change detection, and synchronization never see (or
|
|
427
|
-
// persist) the remap
|
|
428
|
-
let effective = applyDeployRemap(routing);
|
|
429
|
-
let selfIndexes = findSelfIndexes(effective, account, bucketName);
|
|
430
|
-
let selfEntries = selfIndexes.map(i => effective.sources[i] as HostedConfig);
|
|
433
|
+
let selfIndexes = findSelfIndexes(routing, account, bucketName);
|
|
434
|
+
let selfEntries = selfIndexes.map(i => routing.sources[i] as HostedConfig);
|
|
431
435
|
let self = selectEntryAt(selfEntries, Date.now());
|
|
432
436
|
let selfIndex = -1;
|
|
433
437
|
if (self) {
|
|
434
|
-
selfIndex =
|
|
435
|
-
}
|
|
436
|
-
// Our own disk is the base source; only the routing entries DOWNSTREAM from us (after our
|
|
437
|
-
// currently-valid entry) become synchronization sources. Upstream sources sync from us, so
|
|
438
|
-
// writing to or scanning them would just echo our own data back (and make our availability
|
|
439
|
-
// depend on theirs). A server NOT in the list has been removed from the bucket: it keeps
|
|
440
|
-
// its disk data (the config may re-add it), but stops all synchronization - no one
|
|
441
|
-
// contacts a removed source, and scanning/pushing as if we were still one would fight the
|
|
442
|
-
// real chain. The config-level validWindow rides on each ArchivesSource; the disk source
|
|
443
|
-
// shares our currently-valid entry's window (it holds our copy of the data).
|
|
444
|
-
// Our own disk gets no route filter: everything it holds is ours to index and serve.
|
|
445
|
-
// Being removed from the config entirely is a valid window of NOTHING ([0, 0]): fast
|
|
446
|
-
// writes flush immediately (nothing may sit in memory on a node that's been cut out),
|
|
447
|
-
// while the disk data and index stay served.
|
|
448
|
-
// Internally every self entry is the SAME store - one process listening on one or more ports
|
|
449
|
-
// (a takeover's alternate-port middle window included). So the disk window merges all
|
|
450
|
-
// contiguous own windows: an options boundary or the port split must never force a pointless
|
|
451
|
-
// internal flush between two windows that are both us. Only the DYING process of a takeover
|
|
452
|
-
// clips its end - from the write handoff on, the data belongs to the successor process.
|
|
438
|
+
selfIndex = routing.sources.indexOf(self);
|
|
439
|
+
}
|
|
453
440
|
let diskWindow: [number, number] = [0, 0];
|
|
454
441
|
if (self) {
|
|
455
442
|
let [start, end] = self.validWindow;
|
|
@@ -466,10 +453,6 @@ function computeStorePlan(account: string, bucketName: string, routing: RemoteCo
|
|
|
466
453
|
}
|
|
467
454
|
}
|
|
468
455
|
}
|
|
469
|
-
let clip = getOwnWindowEndClip();
|
|
470
|
-
if (clip !== undefined && clip < end) {
|
|
471
|
-
end = clip;
|
|
472
|
-
}
|
|
473
456
|
diskWindow = [start, end];
|
|
474
457
|
}
|
|
475
458
|
let ownIndexes = new Set(selfIndexes);
|
|
@@ -477,27 +460,20 @@ function computeStorePlan(account: string, bucketName: string, routing: RemoteCo
|
|
|
477
460
|
validWindow: diskWindow,
|
|
478
461
|
}];
|
|
479
462
|
if (selfIndex !== -1) {
|
|
480
|
-
for (let i = selfIndex + 1; i <
|
|
481
|
-
let source =
|
|
463
|
+
for (let i = selfIndex + 1; i < routing.sources.length; i++) {
|
|
464
|
+
let source = routing.sources[i];
|
|
482
465
|
if (typeof source === "string" || ownIndexes.has(i)) continue;
|
|
483
|
-
// Disjoint-shard sources never talk to each other; a partial overlap syncs only
|
|
484
|
-
// the intersection (scans ignore the rest, writes only send matching keys)
|
|
485
466
|
let sharedRoute = routeIntersection(self?.route, source.route);
|
|
486
467
|
if (!sharedRoute) continue;
|
|
487
|
-
// A bounded-cache server (noFullSync on our own entry) must not full-sync from
|
|
488
|
-
// ANY source, or its disk would fill regardless of the limit
|
|
489
468
|
sourceSpecs.push({ sourceConfig: source, validWindow: source.validWindow, route: sharedRoute, noFullSync: source.noFullSync || self?.noFullSync });
|
|
490
469
|
}
|
|
491
470
|
}
|
|
492
471
|
let rawDisk = !!self?.rawDisk;
|
|
493
|
-
// Only what the running store genuinely cannot change in place: the store TYPE (rawDisk) and
|
|
494
|
-
// whether the disk-limit poll runs. Source additions/removals and window/route changes are all
|
|
495
|
-
// applied live via updateSources - a config change must never destroy the running store.
|
|
496
472
|
let structureKey = JSON.stringify({
|
|
497
473
|
rawDisk,
|
|
498
474
|
readerDiskLimit: self?.readerDiskLimit,
|
|
499
475
|
});
|
|
500
|
-
return {
|
|
476
|
+
return { selfEntries, self, rawDisk, sourceSpecs, readerDiskLimit: self?.readerDiskLimit, structureKey };
|
|
501
477
|
}
|
|
502
478
|
|
|
503
479
|
function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan): LoadedBucket {
|
|
@@ -522,12 +498,11 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig,
|
|
|
522
498
|
}));
|
|
523
499
|
store = new BlobStore(folder, sources, {
|
|
524
500
|
onIndexChanged: key => {
|
|
525
|
-
// Fires for our own routing writes AND routing files pulled in via synchronization
|
|
526
|
-
// (only ever newer ones, see IArchives.set) — either way, apply the new config
|
|
527
501
|
if (key !== ROUTING_FILE) return;
|
|
528
502
|
void scheduleRoutingReload(account, bucketName);
|
|
529
503
|
},
|
|
530
504
|
readerDiskLimit: plan.readerDiskLimit,
|
|
505
|
+
onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
|
|
531
506
|
});
|
|
532
507
|
}
|
|
533
508
|
let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store, structureKey: plan.structureKey };
|
|
@@ -536,10 +511,29 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig,
|
|
|
536
511
|
return loaded;
|
|
537
512
|
}
|
|
538
513
|
|
|
539
|
-
|
|
540
|
-
|
|
514
|
+
/** 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. */
|
|
515
|
+
function getRoutingFilePath(folder: string): string {
|
|
516
|
+
return path.join(folder, "files", ROUTING_FILE);
|
|
517
|
+
}
|
|
518
|
+
async function readRoutingFile(folder: string): Promise<Buffer | undefined> {
|
|
519
|
+
try {
|
|
520
|
+
return await fs.promises.readFile(getRoutingFilePath(folder));
|
|
521
|
+
} catch (e) {
|
|
522
|
+
if ((e as { code?: string }).code === "ENOENT") return undefined;
|
|
523
|
+
throw e;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function readRoutingFromDisk(account: string, bucketName: string): Promise<RemoteConfig | undefined> {
|
|
528
|
+
let data = await readRoutingFile(getBucketFolder(account, bucketName));
|
|
541
529
|
if (!data) return undefined;
|
|
542
|
-
return
|
|
530
|
+
return parseRoutingData(data);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function loadBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
|
|
534
|
+
let routing = await readRoutingFromDisk(account, bucketName);
|
|
535
|
+
if (!routing) return undefined;
|
|
536
|
+
return buildBucket(account, bucketName, routing);
|
|
543
537
|
}
|
|
544
538
|
|
|
545
539
|
export function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
|
|
@@ -548,8 +542,6 @@ export function getLoadedBucket(account: string, bucketName: string): Promise<Lo
|
|
|
548
542
|
if (!loaded) {
|
|
549
543
|
loaded = loadBucket(account, bucketName);
|
|
550
544
|
buckets.set(key, loaded);
|
|
551
|
-
// Missing buckets are not cached — they can be created at any time (guarding against a
|
|
552
|
-
// concurrent create having already replaced our entry)
|
|
553
545
|
void loaded.then(bucket => {
|
|
554
546
|
if (!bucket && buckets.get(key) === loaded) {
|
|
555
547
|
buckets.delete(key);
|
|
@@ -563,20 +555,7 @@ export function getLoadedBucket(account: string, bucketName: string): Promise<Lo
|
|
|
563
555
|
return loaded;
|
|
564
556
|
}
|
|
565
557
|
|
|
566
|
-
// Called whenever any bucket's routing config is applied (created, updated in place, or rebuilt).
|
|
567
|
-
// storageController wires this to its connected-client broadcast, so every client is pushed the
|
|
568
|
-
// change immediately - never waiting for a poll. (A callback, not an import - storageController
|
|
569
|
-
// imports us, so importing it back would be a cycle.)
|
|
570
|
-
let routingChangedBroadcaster: (() => void) | undefined;
|
|
571
|
-
export function setRoutingChangedBroadcaster(broadcaster: () => void): void {
|
|
572
|
-
routingChangedBroadcaster = broadcaster;
|
|
573
|
-
}
|
|
574
|
-
function notifyRoutingChanged(): void {
|
|
575
|
-
routingChangedBroadcaster?.();
|
|
576
|
-
}
|
|
577
558
|
|
|
578
|
-
// Routing reloads are serialized per bucket, so concurrent writes/syncs can't rebuild the same
|
|
579
|
-
// store twice in parallel
|
|
580
559
|
const routingReloads = new Map<string, Promise<void>>();
|
|
581
560
|
function scheduleRoutingReload(account: string, bucketName: string, config?: { force?: boolean; reason?: string }): Promise<void> {
|
|
582
561
|
let key = `${account}/${bucketName}`;
|
|
@@ -591,18 +570,11 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
591
570
|
let key = `${account}/${bucketName}`;
|
|
592
571
|
let loaded = await buckets.get(key);
|
|
593
572
|
if (!loaded) return;
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
let data = await new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
|
|
597
|
-
if (!data) return;
|
|
598
|
-
let routing = parseRoutingData(data);
|
|
573
|
+
let routing = await readRoutingFromDisk(account, bucketName);
|
|
574
|
+
if (!routing) return;
|
|
599
575
|
if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
|
|
600
576
|
let reason = config?.force && (config.reason || "forced") || "routing config changed";
|
|
601
577
|
let plan = computeStorePlan(account, bucketName, routing);
|
|
602
|
-
// Config changes are applied to the RUNNING store in place - windows/routes mutate, added
|
|
603
|
-
// sources start syncing, removed sources' slots go dead - so the index, pending fast writes
|
|
604
|
-
// (re-capped to the new deadline), and unaffected sync loops all survive. The store is only
|
|
605
|
-
// destroyed for what it structurally cannot express (a rawDisk flip).
|
|
606
578
|
if (plan.structureKey === loaded.structureKey && loaded.store instanceof BlobStore) {
|
|
607
579
|
console.log(`Applying the routing config to the running store for bucket ${key} (${reason})`);
|
|
608
580
|
loaded.store.updateSources(plan.sourceSpecs.map(spec => {
|
|
@@ -622,7 +594,7 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
622
594
|
buckets.set(key, Promise.resolve(updated));
|
|
623
595
|
scheduleWindowBoundaryRebuild(updated);
|
|
624
596
|
scheduleBoundaryScans(updated);
|
|
625
|
-
|
|
597
|
+
broadcastRoutingChanged();
|
|
626
598
|
return;
|
|
627
599
|
}
|
|
628
600
|
console.log(`Rebuilding the store for bucket ${key} (${reason})`);
|
|
@@ -630,12 +602,9 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
630
602
|
await loaded.store.dispose();
|
|
631
603
|
}
|
|
632
604
|
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing, plan)));
|
|
633
|
-
|
|
605
|
+
broadcastRoutingChanged();
|
|
634
606
|
}
|
|
635
607
|
|
|
636
|
-
// Per-write options are evaluated at the WRITE's time (and the key's route), not the current
|
|
637
|
-
// config: the write time is stamped exactly once (at the first node that receives the write) and
|
|
638
|
-
// propagates with the data, so every node resolves the same self entry - and the same options.
|
|
639
608
|
function getWriteConfig(bucket: LoadedBucket, writeTime: number, route: number): { fast?: boolean; writeDelay?: number } {
|
|
640
609
|
let self = selectEntryAt(bucket.selfEntries, writeTime, route);
|
|
641
610
|
return { fast: self?.fast, writeDelay: self?.writeDelay };
|
|
@@ -649,8 +618,6 @@ export async function assertMutable(bucket: LoadedBucket, filePath: string, writ
|
|
|
649
618
|
}
|
|
650
619
|
}
|
|
651
620
|
|
|
652
|
-
// Wrong-target rejections are rare and important (they ARE the switchover handoff), but a burst of
|
|
653
|
-
// racing writes at a boundary must not flood the log - so at most one line a minute
|
|
654
621
|
const WRONG_TARGET_LOG_THROTTLE = 60 * 1000;
|
|
655
622
|
let lastWrongTargetLog = 0;
|
|
656
623
|
function logWrongTargetRejection(message: string): void {
|
|
@@ -659,32 +626,39 @@ function logWrongTargetRejection(message: string): void {
|
|
|
659
626
|
console.log(message);
|
|
660
627
|
}
|
|
661
628
|
|
|
662
|
-
// Routing writes are serialized per bucket, so two concurrent creates can't both build a store
|
|
663
|
-
// (the loser's store would leak with its sync loops running forever)
|
|
664
629
|
const routingWrites = new Map<string, Promise<void>>();
|
|
665
630
|
|
|
666
631
|
async function writeRoutingConfig(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
667
632
|
let key = `${account}/${bucketName}`;
|
|
668
|
-
// Validates before storing anything, so a bad config can't brick the bucket
|
|
669
633
|
let incoming = parseRoutingData(data);
|
|
670
634
|
let loaded = await getLoadedBucket(account, bucketName);
|
|
671
635
|
if (!loaded) {
|
|
672
636
|
await new ArchivesDisk(getBucketFolder(account, bucketName)).set(ROUTING_FILE, data, { lastModified: config?.lastModified });
|
|
673
637
|
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, incoming)));
|
|
674
|
-
|
|
638
|
+
broadcastRoutingChanged();
|
|
675
639
|
console.log(`Created bucket ${key}`);
|
|
676
640
|
return;
|
|
677
641
|
}
|
|
678
|
-
// A changed config must carry a higher version than the one currently stored — otherwise two
|
|
679
|
-
// clients with different configs at the same version would keep clobbering each other. Same
|
|
680
|
-
// content at any version is a harmless no-op and is allowed through.
|
|
681
642
|
if (JSON.stringify(incoming) !== loaded.routingJSON && getConfigVersion(incoming) <= getConfigVersion(loaded.routing)) {
|
|
682
|
-
|
|
643
|
+
let message = `Cannot update routing config for bucket ${key}: the new config differs from the current one but its version (${getConfigVersion(incoming)}) is not greater than the current version (${getConfigVersion(loaded.routing)}). Increment the version to update it. Current: ${loaded.routingJSON}. Attempted: ${JSON.stringify(incoming)}`;
|
|
644
|
+
console.error(message);
|
|
645
|
+
throw new Error(message);
|
|
646
|
+
}
|
|
647
|
+
let stored = incoming;
|
|
648
|
+
let reinjected = 0;
|
|
649
|
+
for (let intermediate of getIntermediateSources(expireIntermediateSources(loaded.routing, Date.now()))) {
|
|
650
|
+
stored = injectIntermediateSource(stored, {
|
|
651
|
+
splitUrl: findSplitUrl(loaded.routing, intermediate) || intermediate.url,
|
|
652
|
+
intermediateUrl: intermediate.url,
|
|
653
|
+
start: intermediate.validWindow[0],
|
|
654
|
+
end: intermediate.validWindow[1],
|
|
655
|
+
});
|
|
656
|
+
reinjected++;
|
|
683
657
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
await loaded.store.set(ROUTING_FILE,
|
|
658
|
+
if (JSON.stringify(stored) !== JSON.stringify(incoming)) {
|
|
659
|
+
console.log(`Re-injected ${reinjected} in-flight switchover windows into the incoming routing config for bucket ${key} (version ${getConfigVersion(incoming)})`);
|
|
660
|
+
}
|
|
661
|
+
await loaded.store.set(ROUTING_FILE, Buffer.from(serializeRemoteConfig(stored)), { lastModified: config?.lastModified });
|
|
688
662
|
await scheduleRoutingReload(account, bucketName);
|
|
689
663
|
}
|
|
690
664
|
|
|
@@ -693,8 +667,6 @@ export async function writeBucketFile(account: string, bucketName: string, fileP
|
|
|
693
667
|
if (filePath === ROUTING_FILE) {
|
|
694
668
|
let key = `${account}/${bucketName}`;
|
|
695
669
|
let run = (routingWrites.get(key) || Promise.resolve()).then(() => writeRoutingConfig(account, bucketName, data, config));
|
|
696
|
-
// The chain must survive a failed write, so the stored link swallows the error (the caller
|
|
697
|
-
// still gets it from run)
|
|
698
670
|
routingWrites.set(key, run.then(() => { }, () => { }));
|
|
699
671
|
return await run;
|
|
700
672
|
}
|
|
@@ -702,13 +674,7 @@ export async function writeBucketFile(account: string, bucketName: string, fileP
|
|
|
702
674
|
if (!loaded) {
|
|
703
675
|
throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
704
676
|
}
|
|
705
|
-
// The write time is determined exactly once, HERE at the first node that receives the write;
|
|
706
|
-
// it propagates as lastModified, so every node evaluates the bucket options at the same instant
|
|
707
677
|
let writeTime = config?.lastModified || Date.now();
|
|
708
|
-
// Only freshly-stamped writes are checked: a write with an explicit lastModified is
|
|
709
|
-
// synchronization/backfill (old data legitimately lands on sources whose window has moved on),
|
|
710
|
-
// but a fresh write reaching us outside our windows/routes means the client resolved its
|
|
711
|
-
// target from a stale view - it must re-resolve and retry at the correct source.
|
|
712
678
|
let route = getRoute(filePath);
|
|
713
679
|
if (!config?.lastModified && loaded.selfEntries.length) {
|
|
714
680
|
let timeValid = loaded.selfEntries.filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
|
|
@@ -729,9 +695,7 @@ export function getBucketConfig(bucket: LoadedBucket): ArchivesConfig {
|
|
|
729
695
|
let progress = bucket.store.getSyncProgress?.();
|
|
730
696
|
return {
|
|
731
697
|
supportsChangesAfter: !!bucket.store.getChangesAfter,
|
|
732
|
-
|
|
733
|
-
// deploy takeover, since the remap is never written into the routing file itself
|
|
734
|
-
remoteConfig: applyDeployRemap(bucket.routing),
|
|
698
|
+
remoteConfig: bucket.routing,
|
|
735
699
|
index: progress?.index,
|
|
736
700
|
indexSources: progress?.sources,
|
|
737
701
|
readerDiskLimit: progress?.readerDiskLimit,
|
|
@@ -742,30 +706,118 @@ export function getBucketConfig(bucket: LoadedBucket): ArchivesConfig {
|
|
|
742
706
|
export async function rebuildAllLoadedBuckets(): Promise<void> {
|
|
743
707
|
for (let key of [...buckets.keys()]) {
|
|
744
708
|
let slash = key.indexOf("/");
|
|
745
|
-
await scheduleRoutingReload(key.slice(0, slash), key.slice(slash + 1), { force: true, reason: "
|
|
709
|
+
await scheduleRoutingReload(key.slice(0, slash), key.slice(slash + 1), { force: true, reason: "all loaded buckets were asked to rebuild" });
|
|
746
710
|
}
|
|
747
711
|
}
|
|
748
712
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
713
|
+
const OWN_CONFIG_WRITE_THROTTLE = 30 * 1000;
|
|
714
|
+
const INTERMEDIATE_MAINTAIN_INTERVAL = 60 * 1000;
|
|
715
|
+
let lastOwnConfigWrite = 0;
|
|
716
|
+
|
|
717
|
+
async function writeOwnRoutingConfig(loaded: LoadedBucket, updated: RemoteConfig, reason: string): Promise<void> {
|
|
718
|
+
let key = `${loaded.account}/${loaded.bucketName}`;
|
|
719
|
+
let sinceLast = Date.now() - lastOwnConfigWrite;
|
|
720
|
+
if (sinceLast < OWN_CONFIG_WRITE_THROTTLE) {
|
|
721
|
+
console.log(`Not writing our own routing config update for bucket ${key} (${reason}): the last one was ${sinceLast}ms ago, under the ${OWN_CONFIG_WRITE_THROTTLE}ms throttle. Retrying on the next maintenance pass. Wanted: ${JSON.stringify(updated)}`);
|
|
722
|
+
return;
|
|
754
723
|
}
|
|
724
|
+
lastOwnConfigWrite = Date.now();
|
|
725
|
+
let version = nextIntermediateVersion(getConfigVersion(loaded.routing));
|
|
726
|
+
let next: RemoteConfig = { ...updated, version };
|
|
727
|
+
console.log(`Writing our own routing config update for bucket ${key} (${reason}), version ${getConfigVersion(loaded.routing)} -> ${version}: ${JSON.stringify(next)}`);
|
|
728
|
+
let data = Buffer.from(serializeRemoteConfig(next));
|
|
729
|
+
await writeBucketFile(loaded.account, loaded.bucketName, ROUTING_FILE, data);
|
|
730
|
+
await propagateRoutingConfig(loaded, next, data);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function propagateRoutingConfig(loaded: LoadedBucket, next: RemoteConfig, data: Buffer): Promise<void> {
|
|
734
|
+
let targets = new Map<string, HostedConfig | BackblazeConfig>();
|
|
735
|
+
for (let source of [...loaded.routing.sources, ...next.sources]) {
|
|
736
|
+
if (typeof source === "string" || source.intermediate) continue;
|
|
737
|
+
if (source.type === "remote") {
|
|
738
|
+
let parsed = parseHostedUrl(source.url);
|
|
739
|
+
if (parsed.account !== loaded.account || parsed.bucketName !== loaded.bucketName) continue;
|
|
740
|
+
}
|
|
741
|
+
if (targets.has(source.url)) continue;
|
|
742
|
+
targets.set(source.url, source);
|
|
743
|
+
}
|
|
744
|
+
for (let [url, source] of targets) {
|
|
745
|
+
try {
|
|
746
|
+
await createApiArchives(source).set(ROUTING_FILE, data);
|
|
747
|
+
console.log(`Propagated our routing config update to ${url}`);
|
|
748
|
+
} catch (e) {
|
|
749
|
+
console.error(`Propagating our routing config update to ${url} failed: ${(e as Error).stack ?? e}`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function maintainIntermediates(): Promise<void> {
|
|
755
|
+
let { domain, port } = getStorageServerConfig();
|
|
756
|
+
let takeover = getTakeoverIntermediate();
|
|
757
|
+
for (let key of [...buckets.keys()]) {
|
|
758
|
+
let loadedPromise = buckets.get(key);
|
|
759
|
+
if (!loadedPromise) continue;
|
|
760
|
+
let loaded = await loadedPromise.catch(() => undefined);
|
|
761
|
+
if (!loaded) continue;
|
|
762
|
+
let expired = expireIntermediateSources(loaded.routing, Date.now());
|
|
763
|
+
if (JSON.stringify(expired) !== JSON.stringify(loaded.routing)) {
|
|
764
|
+
await writeOwnRoutingConfig(loaded, expired, `switchover windows expired more than ${INTERMEDIATE_EXPIRE_GRACE / 1000}s ago`);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (!takeover) continue;
|
|
768
|
+
let mainUrl = loaded.routing.sources.find(x => {
|
|
769
|
+
if (typeof x === "string" || x.type !== "remote" || x.intermediate) return false;
|
|
770
|
+
let parsed = parseHostedUrl(x.url);
|
|
771
|
+
return parsed.address === domain && parsed.port === port;
|
|
772
|
+
}) as HostedConfig | undefined;
|
|
773
|
+
if (!mainUrl) continue;
|
|
774
|
+
let injected = injectIntermediateSource(loaded.routing, {
|
|
775
|
+
splitUrl: mainUrl.url,
|
|
776
|
+
intermediateUrl: replaceHostedUrlPort(mainUrl.url, takeover.altPort),
|
|
777
|
+
start: takeover.start,
|
|
778
|
+
end: takeover.end,
|
|
779
|
+
});
|
|
780
|
+
if (JSON.stringify(injected) === JSON.stringify(loaded.routing)) continue;
|
|
781
|
+
await writeOwnRoutingConfig(loaded, injected, `we are a deploy successor: writes route to our alternate port ${takeover.altPort} from ${new Date(takeover.start).toISOString()} until our predecessor is killed at ${new Date(takeover.end).toISOString()}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Started by deployTakeover once we are actually a deploy successor listening on an alternate port. Until then there are no switchover windows to write or expire, so nothing polls. */
|
|
786
|
+
export const startIntermediateMaintenance = lazy(() => {
|
|
787
|
+
void maintainIntermediates().catch((e: Error) => console.error(`Maintaining switchover routing windows failed: ${e.stack ?? e}`));
|
|
788
|
+
runInfinitePoll(INTERMEDIATE_MAINTAIN_INTERVAL, maintainIntermediates);
|
|
755
789
|
});
|
|
756
790
|
|
|
791
|
+
export type BucketDiskInfo = {
|
|
792
|
+
totalBytes: number;
|
|
793
|
+
freeBytes: number;
|
|
794
|
+
usedBytes: number;
|
|
795
|
+
};
|
|
757
796
|
export type ServerBucketInfo = {
|
|
758
797
|
bucketName: string;
|
|
759
|
-
// Loaded in memory (created or accessed since startup), so its synchronization is running
|
|
760
798
|
active: boolean;
|
|
799
|
+
/** Where the bucket's data lives on this server */
|
|
800
|
+
folder: string;
|
|
801
|
+
/** The drive that folder is on. Buckets sharing a drive report the same numbers. */
|
|
802
|
+
disk?: BucketDiskInfo;
|
|
803
|
+
diskError?: string;
|
|
804
|
+
writeStats?: BucketWriteStats;
|
|
761
805
|
config?: ArchivesConfig;
|
|
762
806
|
error?: string;
|
|
763
807
|
};
|
|
764
808
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
809
|
+
async function getDiskInfo(folder: string): Promise<BucketDiskInfo> {
|
|
810
|
+
let stats = await fs.promises.statfs(folder);
|
|
811
|
+
let blockSize = Number(stats.bsize);
|
|
812
|
+
let totalBytes = Number(stats.blocks) * blockSize;
|
|
813
|
+
return {
|
|
814
|
+
totalBytes,
|
|
815
|
+
// Matches the server's own low-space check: what an unprivileged process can actually use
|
|
816
|
+
freeBytes: Number(stats.bavail) * blockSize,
|
|
817
|
+
usedBytes: totalBytes - Number(stats.bfree) * blockSize,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
769
821
|
export async function listAccountBuckets(account: string): Promise<ServerBucketInfo[]> {
|
|
770
822
|
let accountFolder = path.join(getStorageServerConfig().folder, "buckets2", account);
|
|
771
823
|
let names: string[];
|
|
@@ -774,33 +826,40 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
|
|
|
774
826
|
} catch {
|
|
775
827
|
return [];
|
|
776
828
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
let
|
|
829
|
+
return await Promise.all(names.map(async bucketName => {
|
|
830
|
+
let key = `${account}/${bucketName}`;
|
|
831
|
+
let folder = getBucketFolder(account, bucketName);
|
|
832
|
+
let base: ServerBucketInfo = { bucketName, active: false, folder };
|
|
833
|
+
let [disk, writeStats] = await Promise.all([
|
|
834
|
+
getDiskInfo(folder).catch((e: Error) => {
|
|
835
|
+
base.diskError = String(e.stack ?? e).slice(0, 500);
|
|
836
|
+
return undefined;
|
|
837
|
+
}),
|
|
838
|
+
getBucketWriteStats(key),
|
|
839
|
+
]);
|
|
840
|
+
base.disk = disk;
|
|
841
|
+
base.writeStats = writeStats;
|
|
842
|
+
let loadedPromise = buckets.get(key);
|
|
780
843
|
if (loadedPromise) {
|
|
781
844
|
try {
|
|
782
845
|
let loaded = await loadedPromise;
|
|
783
846
|
if (loaded) {
|
|
784
|
-
|
|
785
|
-
continue;
|
|
847
|
+
return { ...base, active: true, config: getBucketConfig(loaded) };
|
|
786
848
|
}
|
|
787
849
|
} catch (e) {
|
|
788
|
-
|
|
789
|
-
continue;
|
|
850
|
+
return { ...base, active: true, error: String((e as Error).stack ?? e).slice(0, 500) };
|
|
790
851
|
}
|
|
791
852
|
}
|
|
792
853
|
try {
|
|
793
|
-
let data = await
|
|
854
|
+
let data = await readRoutingFile(folder);
|
|
794
855
|
if (!data) {
|
|
795
|
-
|
|
796
|
-
continue;
|
|
856
|
+
return { ...base, error: `No routing file (${ROUTING_FILE})` };
|
|
797
857
|
}
|
|
798
|
-
|
|
858
|
+
return { ...base, config: { remoteConfig: parseRoutingData(data) } };
|
|
799
859
|
} catch (e) {
|
|
800
|
-
|
|
860
|
+
return { ...base, error: String((e as Error).stack ?? e).slice(0, 500) };
|
|
801
861
|
}
|
|
802
|
-
}
|
|
803
|
-
return results;
|
|
862
|
+
}));
|
|
804
863
|
}
|
|
805
864
|
|
|
806
865
|
export async function deleteBucketFile(account: string, bucketName: string, filePath: string): Promise<void> {
|
|
@@ -812,12 +871,8 @@ export async function deleteBucketFile(account: string, bucketName: string, file
|
|
|
812
871
|
await loaded.store.del(filePath, getWriteConfig(loaded, Date.now(), getRoute(filePath)));
|
|
813
872
|
}
|
|
814
873
|
|
|
815
|
-
// ── local IArchives access ──
|
|
816
|
-
|
|
817
874
|
const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
|
|
818
875
|
|
|
819
|
-
// The in-process IArchives for a bucket hosted by THIS server — used when a RemoteConfig source
|
|
820
|
-
// URL points at ourselves, so we don't talk to ourselves over HTTPS. One singleton per bucket.
|
|
821
876
|
class ArchivesLocalBucket implements IArchives {
|
|
822
877
|
constructor(private account: string, private bucketName: string) { }
|
|
823
878
|
|
|
@@ -885,7 +940,6 @@ class ArchivesLocalBucket implements IArchives {
|
|
|
885
940
|
}
|
|
886
941
|
public async getConfig(): Promise<ArchivesConfig> {
|
|
887
942
|
let bucket = await this.getBucket();
|
|
888
|
-
// Missing buckets say true, matching what they become once created (the default store type)
|
|
889
943
|
if (!bucket) return { supportsChangesAfter: true };
|
|
890
944
|
return getBucketConfig(bucket);
|
|
891
945
|
}
|