sliftutils 1.7.32 → 1.7.34
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 +134 -102
- 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 +22 -14
- package/storage/remoteStorage/createArchives.ts +54 -188
- 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 +58 -189
- 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 +4 -6
- package/storage/remoteStorage/dist/storageController.ts.cache +37 -74
- package/storage/remoteStorage/dist/storageServerState.ts.cache +345 -188
- 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 +7 -6
- package/storage/remoteStorage/storageController.ts +36 -82
- package/storage/remoteStorage/storageServer.ts +20 -34
- package/storage/remoteStorage/storageServerState.d.ts +41 -5
- package/storage/remoteStorage/storageServerState.ts +355 -216
|
@@ -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,33 +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";
|
|
17
20
|
import { broadcastRoutingChanged } from "./storageController";
|
|
18
21
|
import type { AccessRequest, TrustRecord } from "./storageController";
|
|
19
22
|
import { getArg } from "./cliArgs";
|
|
20
23
|
|
|
21
|
-
// The storage server's global server-side state. hostStorageServer sets the config once at
|
|
22
|
-
// startup; everything else (system storages, buckets) is a global cache created lazily on first
|
|
23
|
-
// use, throwing if the config was never set.
|
|
24
|
-
//
|
|
25
|
-
// Buckets have no separate registry: each bucket's configuration (a RemoteConfig) lives inside the
|
|
26
|
-
// bucket itself, at ROUTING_FILE. A bucket exists iff that file exists. Writing it creates the
|
|
27
|
-
// bucket (building its store, which may immediately start synchronization scans); overwriting it —
|
|
28
|
-
// directly or via a file pulled in from a synchronization source — rebuilds the store with the new
|
|
29
|
-
// sources (cancelling the old store's scans).
|
|
30
|
-
|
|
31
24
|
export type StorageServerConfig = {
|
|
32
25
|
domain: string;
|
|
33
26
|
port: number;
|
|
34
27
|
rootDomain: string;
|
|
35
|
-
// user@externalIp of the storage machine, for generated ssh commands
|
|
36
28
|
sshTarget: string;
|
|
37
|
-
// Absolute command that runs the grantAccess CLI on the storage machine (admin args appended)
|
|
38
29
|
serverCommand: string;
|
|
39
|
-
// The server's root storage folder (absolute)
|
|
40
30
|
folder: string;
|
|
41
31
|
};
|
|
42
32
|
|
|
@@ -50,15 +40,10 @@ export function getStorageServerConfig(): StorageServerConfig {
|
|
|
50
40
|
}
|
|
51
41
|
return config;
|
|
52
42
|
}
|
|
53
|
-
// For self-detection: code that CAN run outside the storage server (createArchives) uses this to
|
|
54
|
-
// check "is this URL our own process?" without throwing.
|
|
55
43
|
export function getStorageServerConfigOptional(): StorageServerConfig | undefined {
|
|
56
44
|
return config;
|
|
57
45
|
}
|
|
58
46
|
|
|
59
|
-
// When set, all write-path operations (creating files, appending large uploads, creating buckets)
|
|
60
|
-
// throw this message. Reads, findInfo, and deletes still work — so clients can free space. Managed
|
|
61
|
-
// by hostStorageServer's disk-space monitor.
|
|
62
47
|
let writesRejectedReason: string | undefined;
|
|
63
48
|
export function setWritesRejectedReason(reason: string | undefined): void {
|
|
64
49
|
writesRejectedReason = reason;
|
|
@@ -70,12 +55,6 @@ export function assertWritesAllowed(): void {
|
|
|
70
55
|
if (writesRejectedReason) throw new Error(writesRejectedReason);
|
|
71
56
|
}
|
|
72
57
|
|
|
73
|
-
// The system storages (trust, requests) are the same kind of thing — a JSON key/value store under
|
|
74
|
-
// <folder>/system2/<name> — so they share one cache, keyed by name. ("system2" because the layout
|
|
75
|
-
// changed when bucket configs moved into the buckets themselves; old "system"/"buckets" folders
|
|
76
|
-
// are simply ignored.)
|
|
77
|
-
// setTrustedMachines is called by consumers BEFORE starting the server, so when the config isn't
|
|
78
|
-
// set yet we fall back to the same --folder arg the CLI starts the server with.
|
|
79
58
|
function getStorageFolder(): string {
|
|
80
59
|
let config = getStorageServerConfigOptional();
|
|
81
60
|
if (config) return config.folder;
|
|
@@ -107,7 +86,95 @@ export function getRequests(): Promise<IStorage<AccessRequest[]>> {
|
|
|
107
86
|
return getSystemStorage<AccessRequest[]>("requests");
|
|
108
87
|
}
|
|
109
88
|
|
|
110
|
-
|
|
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
|
+
|
|
111
178
|
export async function setTrustedMachines(config: { account: string; machineIds: string[] }): Promise<void> {
|
|
112
179
|
let trust = await getTrust();
|
|
113
180
|
let prefix = `${config.account}|`;
|
|
@@ -128,70 +195,49 @@ export async function setTrustedMachines(config: { account: string; machineIds:
|
|
|
128
195
|
}
|
|
129
196
|
}
|
|
130
197
|
|
|
131
|
-
// ── buckets ──
|
|
132
|
-
|
|
133
198
|
export type LoadedBucket = {
|
|
134
199
|
account: string;
|
|
135
200
|
bucketName: string;
|
|
136
|
-
// The bucket's parsed routing config (the contents of ROUTING_FILE)
|
|
137
201
|
routing: RemoteConfig;
|
|
138
|
-
// JSON of routing, for change detection
|
|
139
202
|
routingJSON: string;
|
|
140
|
-
// ALL of our own entries in routing.sources (this bucket on this server). More than one when
|
|
141
|
-
// the bucket's options change over time - one entry per valid window (e.g. immutable dropped,
|
|
142
|
-
// or fast enabled, starting at a scheduled instant).
|
|
143
203
|
selfEntries: HostedConfig[];
|
|
144
|
-
// The entry valid when the store was built - the structural options (rawDisk, which entries
|
|
145
|
-
// are downstream). Per-write options (fast/immutable) are instead evaluated at each write's
|
|
146
|
-
// time, see getWriteConfig/assertMutable. undefined when the config doesn't mention us.
|
|
147
204
|
self: HostedConfig | undefined;
|
|
148
205
|
store: IBucketStore;
|
|
149
|
-
// The store plan minus its valid windows (see StorePlan.structureKey): when only windows
|
|
150
|
-
// change, the store is updated in place instead of rebuilt
|
|
151
206
|
structureKey: string;
|
|
152
207
|
};
|
|
153
208
|
|
|
154
209
|
const buckets = new Map<string, Promise<LoadedBucket | undefined>>();
|
|
155
210
|
|
|
156
|
-
// setTimeout cannot represent longer delays; more distant window boundaries re-check after this
|
|
157
211
|
const MAX_REBUILD_TIMER_DELAY = 2 ** 31 - 1;
|
|
158
|
-
// Rebuild slightly after the boundary, so the newly-valid entry is unambiguously active
|
|
159
212
|
const REBUILD_BOUNDARY_BUFFER = 1000;
|
|
213
|
+
const WRITE_STATS_FLUSH_INTERVAL = 5 * 60 * 1000;
|
|
160
214
|
|
|
161
215
|
function getBucketFolder(account: string, bucketName: string): string {
|
|
162
216
|
return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName);
|
|
163
217
|
}
|
|
164
218
|
|
|
165
|
-
// Ports we are listening on beyond the main config port - a deploy-takeover successor serves on a
|
|
166
|
-
// temporary alternate port, and must recognize alternate-port source URLs as itself
|
|
167
219
|
const extraListenPorts = new Set<number>();
|
|
168
220
|
export function addExtraListenPort(port: number): void {
|
|
169
221
|
extraListenPorts.add(port);
|
|
170
222
|
}
|
|
223
|
+
export function removeExtraListenPort(port: number): void {
|
|
224
|
+
extraListenPorts.delete(port);
|
|
225
|
+
}
|
|
171
226
|
function findSelfIndexes(routing: RemoteConfig, account: string, bucketName: string): number[] {
|
|
172
227
|
let { domain, port } = getStorageServerConfig();
|
|
173
228
|
let indexes: number[] = [];
|
|
174
|
-
// The takeover's alternate port is the OTHER process of the switchover, on OUR machine with
|
|
175
|
-
// OUR disk - self for sync purposes on both sides (the old node must not scan/push its own
|
|
176
|
-
// successor; the shared folder plus the switchover disk scans reconcile them)
|
|
177
|
-
let takeoverAltPort = getTakeoverAltPort();
|
|
178
229
|
for (let i = 0; i < routing.sources.length; i++) {
|
|
179
230
|
let source = routing.sources[i];
|
|
180
231
|
if (typeof source === "string" || source.type !== "remote") continue;
|
|
181
232
|
let parsed = parseHostedUrl(source.url);
|
|
182
233
|
if (parsed.address !== domain || parsed.account !== account || parsed.bucketName !== bucketName) continue;
|
|
183
|
-
if (parsed.port === port || extraListenPorts.has(parsed.port)
|
|
234
|
+
if (parsed.port === port || extraListenPorts.has(parsed.port)) {
|
|
184
235
|
indexes.push(i);
|
|
185
236
|
}
|
|
186
237
|
}
|
|
187
238
|
return indexes;
|
|
188
239
|
}
|
|
189
240
|
|
|
190
|
-
// The entry whose valid window contains the time (among the entries covering the key's route,
|
|
191
|
-
// when one is given - a server can hold different shards with different options). Half-open
|
|
192
|
-
// ([start, end)), so clean breaks resolve unambiguously. Falls back to the nearest window when
|
|
193
|
-
// none contains the time - gaps shouldn't exist, but a write must still resolve to SOME
|
|
194
|
-
// configuration.
|
|
195
241
|
function selectEntryAt(entries: HostedConfig[], time: number, route?: number): HostedConfig | undefined {
|
|
196
242
|
if (route !== undefined) {
|
|
197
243
|
let covering = entries.filter(x => routeContains(x.route, route));
|
|
@@ -213,9 +259,6 @@ function selectEntryAt(entries: HostedConfig[], time: number, route?: number): H
|
|
|
213
259
|
return best;
|
|
214
260
|
}
|
|
215
261
|
|
|
216
|
-
// Bucket options can change over time (multiple self entries with different valid windows), and
|
|
217
|
-
// the structural ones (rawDisk, the downstream list) are baked into the store - so the store is
|
|
218
|
-
// rebuilt shortly after each of our window boundaries passes, making the newly-valid entry apply.
|
|
219
262
|
function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
220
263
|
let now = Date.now();
|
|
221
264
|
let boundaries = loaded.selfEntries.flatMap(x => x.validWindow).filter(t => t > now);
|
|
@@ -224,7 +267,6 @@ function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
|
224
267
|
let key = `${loaded.account}/${loaded.bucketName}`;
|
|
225
268
|
let timer = setTimeout(() => {
|
|
226
269
|
void (async () => {
|
|
227
|
-
// A routing change may have already replaced this store; only IT gets a boundary timer
|
|
228
270
|
let current = await buckets.get(key);
|
|
229
271
|
if (current !== loaded) return;
|
|
230
272
|
if (Date.now() < nextBoundary) {
|
|
@@ -237,19 +279,10 @@ function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
|
237
279
|
(timer as { unref?: () => void }).unref?.();
|
|
238
280
|
}
|
|
239
281
|
|
|
240
|
-
// Extra scans around each of OUR valid-window starts (a deploy switchover is just this too - its
|
|
241
|
-
// remap adds valid windows), catching last-moment writes the previous window's owner accepted near
|
|
242
|
-
// the boundary. By the final scan the previous owner has flushed everything (fast writes never
|
|
243
|
-
// delay past a window's end), so nothing is missed.
|
|
244
282
|
const BOUNDARY_SCAN_OFFSETS = [-30 * 1000, 2 * 1000, 30 * 1000];
|
|
245
|
-
// How far back a boundary scan asks the previous owner for changes: anything older has already
|
|
246
|
-
// arrived through normal synchronization, and this covers the longest a write can sit unflushed
|
|
247
283
|
const BOUNDARY_SCAN_LOOKBACK = DEFAULT_FAST_WRITE_DELAY + WINDOW_END_FLUSH_MARGIN;
|
|
248
|
-
// The catch-up scan for a window that was already active when we built the store
|
|
249
284
|
const STARTUP_BOUNDARY_SCAN_DELAY = 30 * 1000;
|
|
250
285
|
|
|
251
|
-
// Each (bucket, boundary, offset) is scheduled exactly once, surviving store rebuilds - the timer
|
|
252
|
-
// re-resolves the bucket (and recomputes owners from the then-current routing) when it fires
|
|
253
286
|
const scheduledBoundaryScans = new Set<string>();
|
|
254
287
|
const boundaryScansRunning = new Set<string>();
|
|
255
288
|
|
|
@@ -263,10 +296,6 @@ function scheduleBoundaryScans(loaded: LoadedBucket): void {
|
|
|
263
296
|
starts.add(start);
|
|
264
297
|
}
|
|
265
298
|
}
|
|
266
|
-
// A window we are ALREADY inside when the store is built (startup, or a config that made us
|
|
267
|
-
// valid immediately) got none of the lead-up scans - so one runs shortly after, in case we
|
|
268
|
-
// came up just as the previous owner is realizing it must shut down. Only recent boundaries
|
|
269
|
-
// qualify: older trailing writes have long since propagated through normal synchronization.
|
|
270
299
|
for (let self of loaded.selfEntries) {
|
|
271
300
|
let [start, end] = self.validWindow;
|
|
272
301
|
if (!(start <= now && now < end) || start <= 0) continue;
|
|
@@ -288,8 +317,6 @@ function scheduleBoundaryScans(loaded: LoadedBucket): void {
|
|
|
288
317
|
scheduledBoundaryScans.add(scheduleKey);
|
|
289
318
|
let arm = () => {
|
|
290
319
|
let timer = setTimeout(() => {
|
|
291
|
-
// Timer length is capped (far-future boundaries), so re-arm until the time
|
|
292
|
-
// actually arrives
|
|
293
320
|
if (Date.now() < at) {
|
|
294
321
|
arm();
|
|
295
322
|
return;
|
|
@@ -306,7 +333,6 @@ function scheduleBoundaryScans(loaded: LoadedBucket): void {
|
|
|
306
333
|
async function runBoundaryScan(bucketKey: string, windowStart: number, offset: number, offsetLabel?: string): Promise<void> {
|
|
307
334
|
let label = `bucket ${bucketKey}, window starting ${new Date(windowStart).toISOString()}, offset ${offsetLabel || `${offset / 1000}s`}`;
|
|
308
335
|
if (boundaryScansRunning.has(bucketKey)) {
|
|
309
|
-
// Boundary scans never run in parallel; a slow one simply swallows later scheduled points
|
|
310
336
|
console.log(`Skipping boundary scan (${label}): the previous boundary scan is still running`);
|
|
311
337
|
return;
|
|
312
338
|
}
|
|
@@ -316,23 +342,17 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
316
342
|
if (!loaded) return;
|
|
317
343
|
let store = loaded.store;
|
|
318
344
|
if (!(store instanceof BlobStore)) return;
|
|
319
|
-
|
|
320
|
-
// changed since this scan was scheduled
|
|
321
|
-
let effective = applyDeployRemap(loaded.routing);
|
|
345
|
+
let effective = loaded.routing;
|
|
322
346
|
let selfIndexes = findSelfIndexes(effective, loaded.account, loaded.bucketName);
|
|
323
347
|
let selfIndexSet = new Set(selfIndexes);
|
|
324
348
|
let selves = selfIndexes.map(i => effective.sources[i] as HostedConfig).filter(x => x.validWindow[0] === windowStart);
|
|
325
349
|
if (!selves.length) return;
|
|
326
350
|
let prevTime = windowStart - 1;
|
|
327
351
|
let needDiskScan = false;
|
|
328
|
-
// sourceIndex -> the slice of our route that source owned in the previous window
|
|
329
352
|
let remoteOwners = new Map<number, [number, number]>();
|
|
330
353
|
for (let self of selves) {
|
|
331
354
|
let selfRoute = self.route || FULL_ROUTE;
|
|
332
355
|
let idx = effective.sources.indexOf(self);
|
|
333
|
-
// These scans only run on the write node - the first source valid at the new window's
|
|
334
|
-
// start for our route. An earlier entry fully covering our route means writes go there,
|
|
335
|
-
// and IT does the boundary scans.
|
|
336
356
|
let shadowed = false;
|
|
337
357
|
for (let j = 0; j < idx; j++) {
|
|
338
358
|
let other = effective.sources[j];
|
|
@@ -346,8 +366,6 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
346
366
|
}
|
|
347
367
|
}
|
|
348
368
|
if (shadowed) continue;
|
|
349
|
-
// The previous window's owners: walking the config in order (write-target order),
|
|
350
|
-
// each source claims whatever slice of our route no earlier source already owns
|
|
351
369
|
let uncovered: [number, number][] = [[selfRoute[0], selfRoute[1]]];
|
|
352
370
|
for (let j = 0; j < effective.sources.length && uncovered.length; j++) {
|
|
353
371
|
let other = effective.sources[j];
|
|
@@ -370,8 +388,6 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
370
388
|
uncovered = remaining;
|
|
371
389
|
if (!claimed) continue;
|
|
372
390
|
if (selfIndexSet.has(j)) {
|
|
373
|
-
// The previous owner shares our storage (same machine + bucket, e.g. the other
|
|
374
|
-
// process of a deploy switchover), so a disk rescan sees its writes
|
|
375
391
|
needDiskScan = true;
|
|
376
392
|
} else {
|
|
377
393
|
let existing = remoteOwners.get(j);
|
|
@@ -400,57 +416,27 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
400
416
|
}
|
|
401
417
|
|
|
402
418
|
type StorePlan = {
|
|
403
|
-
effective: RemoteConfig;
|
|
404
419
|
selfEntries: HostedConfig[];
|
|
405
420
|
self: HostedConfig | undefined;
|
|
406
421
|
rawDisk: boolean;
|
|
407
|
-
// The BlobStore sources this plan builds: [0] is the disk, the rest are downstream configs
|
|
408
422
|
sourceSpecs: { sourceConfig?: HostedConfig | BackblazeConfig; validWindow: [number, number]; route?: [number, number]; noFullSync?: boolean }[];
|
|
409
423
|
readerDiskLimit?: number;
|
|
410
|
-
// Everything about the plan EXCEPT the valid windows. Two plans with equal structure keys
|
|
411
|
-
// differ only in windows, which the running store applies in place - it must survive the
|
|
412
|
-
// routine config evolution of reducing the last forever-window and appending a new entry,
|
|
413
|
-
// instead of being disposed and rebuilt (losing sync state and rescanning everything).
|
|
414
424
|
structureKey: string;
|
|
415
425
|
};
|
|
416
426
|
|
|
417
|
-
// The stable identity of a source endpoint: its config with the in-place-updatable parts
|
|
418
|
-
// (windows, routes) stripped, so a source is recognized across config changes
|
|
419
427
|
function sourceIdentity(sourceConfig: HostedConfig | BackblazeConfig | undefined): string {
|
|
420
428
|
if (!sourceConfig) return "disk";
|
|
421
429
|
return JSON.stringify({ ...sourceConfig, validWindow: undefined, route: undefined });
|
|
422
430
|
}
|
|
423
431
|
|
|
424
432
|
function computeStorePlan(account: string, bucketName: string, routing: RemoteConfig): StorePlan {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// the STORED config - so version guards, change detection, and synchronization never see (or
|
|
428
|
-
// persist) the remap
|
|
429
|
-
let effective = applyDeployRemap(routing);
|
|
430
|
-
let selfIndexes = findSelfIndexes(effective, account, bucketName);
|
|
431
|
-
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);
|
|
432
435
|
let self = selectEntryAt(selfEntries, Date.now());
|
|
433
436
|
let selfIndex = -1;
|
|
434
437
|
if (self) {
|
|
435
|
-
selfIndex =
|
|
436
|
-
}
|
|
437
|
-
// Our own disk is the base source; only the routing entries DOWNSTREAM from us (after our
|
|
438
|
-
// currently-valid entry) become synchronization sources. Upstream sources sync from us, so
|
|
439
|
-
// writing to or scanning them would just echo our own data back (and make our availability
|
|
440
|
-
// depend on theirs). A server NOT in the list has been removed from the bucket: it keeps
|
|
441
|
-
// its disk data (the config may re-add it), but stops all synchronization - no one
|
|
442
|
-
// contacts a removed source, and scanning/pushing as if we were still one would fight the
|
|
443
|
-
// real chain. The config-level validWindow rides on each ArchivesSource; the disk source
|
|
444
|
-
// shares our currently-valid entry's window (it holds our copy of the data).
|
|
445
|
-
// Our own disk gets no route filter: everything it holds is ours to index and serve.
|
|
446
|
-
// Being removed from the config entirely is a valid window of NOTHING ([0, 0]): fast
|
|
447
|
-
// writes flush immediately (nothing may sit in memory on a node that's been cut out),
|
|
448
|
-
// while the disk data and index stay served.
|
|
449
|
-
// Internally every self entry is the SAME store - one process listening on one or more ports
|
|
450
|
-
// (a takeover's alternate-port middle window included). So the disk window merges all
|
|
451
|
-
// contiguous own windows: an options boundary or the port split must never force a pointless
|
|
452
|
-
// internal flush between two windows that are both us. Only the DYING process of a takeover
|
|
453
|
-
// clips its end - from the write handoff on, the data belongs to the successor process.
|
|
438
|
+
selfIndex = routing.sources.indexOf(self);
|
|
439
|
+
}
|
|
454
440
|
let diskWindow: [number, number] = [0, 0];
|
|
455
441
|
if (self) {
|
|
456
442
|
let [start, end] = self.validWindow;
|
|
@@ -467,10 +453,6 @@ function computeStorePlan(account: string, bucketName: string, routing: RemoteCo
|
|
|
467
453
|
}
|
|
468
454
|
}
|
|
469
455
|
}
|
|
470
|
-
let clip = getOwnWindowEndClip();
|
|
471
|
-
if (clip !== undefined && clip < end) {
|
|
472
|
-
end = clip;
|
|
473
|
-
}
|
|
474
456
|
diskWindow = [start, end];
|
|
475
457
|
}
|
|
476
458
|
let ownIndexes = new Set(selfIndexes);
|
|
@@ -478,27 +460,20 @@ function computeStorePlan(account: string, bucketName: string, routing: RemoteCo
|
|
|
478
460
|
validWindow: diskWindow,
|
|
479
461
|
}];
|
|
480
462
|
if (selfIndex !== -1) {
|
|
481
|
-
for (let i = selfIndex + 1; i <
|
|
482
|
-
let source =
|
|
463
|
+
for (let i = selfIndex + 1; i < routing.sources.length; i++) {
|
|
464
|
+
let source = routing.sources[i];
|
|
483
465
|
if (typeof source === "string" || ownIndexes.has(i)) continue;
|
|
484
|
-
// Disjoint-shard sources never talk to each other; a partial overlap syncs only
|
|
485
|
-
// the intersection (scans ignore the rest, writes only send matching keys)
|
|
486
466
|
let sharedRoute = routeIntersection(self?.route, source.route);
|
|
487
467
|
if (!sharedRoute) continue;
|
|
488
|
-
// A bounded-cache server (noFullSync on our own entry) must not full-sync from
|
|
489
|
-
// ANY source, or its disk would fill regardless of the limit
|
|
490
468
|
sourceSpecs.push({ sourceConfig: source, validWindow: source.validWindow, route: sharedRoute, noFullSync: source.noFullSync || self?.noFullSync });
|
|
491
469
|
}
|
|
492
470
|
}
|
|
493
471
|
let rawDisk = !!self?.rawDisk;
|
|
494
|
-
// Only what the running store genuinely cannot change in place: the store TYPE (rawDisk) and
|
|
495
|
-
// whether the disk-limit poll runs. Source additions/removals and window/route changes are all
|
|
496
|
-
// applied live via updateSources - a config change must never destroy the running store.
|
|
497
472
|
let structureKey = JSON.stringify({
|
|
498
473
|
rawDisk,
|
|
499
474
|
readerDiskLimit: self?.readerDiskLimit,
|
|
500
475
|
});
|
|
501
|
-
return {
|
|
476
|
+
return { selfEntries, self, rawDisk, sourceSpecs, readerDiskLimit: self?.readerDiskLimit, structureKey };
|
|
502
477
|
}
|
|
503
478
|
|
|
504
479
|
function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan): LoadedBucket {
|
|
@@ -523,12 +498,11 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig,
|
|
|
523
498
|
}));
|
|
524
499
|
store = new BlobStore(folder, sources, {
|
|
525
500
|
onIndexChanged: key => {
|
|
526
|
-
// Fires for our own routing writes AND routing files pulled in via synchronization
|
|
527
|
-
// (only ever newer ones, see IArchives.set) — either way, apply the new config
|
|
528
501
|
if (key !== ROUTING_FILE) return;
|
|
529
502
|
void scheduleRoutingReload(account, bucketName);
|
|
530
503
|
},
|
|
531
504
|
readerDiskLimit: plan.readerDiskLimit,
|
|
505
|
+
onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
|
|
532
506
|
});
|
|
533
507
|
}
|
|
534
508
|
let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store, structureKey: plan.structureKey };
|
|
@@ -537,10 +511,29 @@ function buildBucket(account: string, bucketName: string, routing: RemoteConfig,
|
|
|
537
511
|
return loaded;
|
|
538
512
|
}
|
|
539
513
|
|
|
540
|
-
|
|
541
|
-
|
|
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));
|
|
542
529
|
if (!data) return undefined;
|
|
543
|
-
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);
|
|
544
537
|
}
|
|
545
538
|
|
|
546
539
|
export function getLoadedBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
|
|
@@ -549,8 +542,6 @@ export function getLoadedBucket(account: string, bucketName: string): Promise<Lo
|
|
|
549
542
|
if (!loaded) {
|
|
550
543
|
loaded = loadBucket(account, bucketName);
|
|
551
544
|
buckets.set(key, loaded);
|
|
552
|
-
// Missing buckets are not cached — they can be created at any time (guarding against a
|
|
553
|
-
// concurrent create having already replaced our entry)
|
|
554
545
|
void loaded.then(bucket => {
|
|
555
546
|
if (!bucket && buckets.get(key) === loaded) {
|
|
556
547
|
buckets.delete(key);
|
|
@@ -565,8 +556,6 @@ export function getLoadedBucket(account: string, bucketName: string): Promise<Lo
|
|
|
565
556
|
}
|
|
566
557
|
|
|
567
558
|
|
|
568
|
-
// Routing reloads are serialized per bucket, so concurrent writes/syncs can't rebuild the same
|
|
569
|
-
// store twice in parallel
|
|
570
559
|
const routingReloads = new Map<string, Promise<void>>();
|
|
571
560
|
function scheduleRoutingReload(account: string, bucketName: string, config?: { force?: boolean; reason?: string }): Promise<void> {
|
|
572
561
|
let key = `${account}/${bucketName}`;
|
|
@@ -581,18 +570,11 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
581
570
|
let key = `${account}/${bucketName}`;
|
|
582
571
|
let loaded = await buckets.get(key);
|
|
583
572
|
if (!loaded) return;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
let data = await new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
|
|
587
|
-
if (!data) return;
|
|
588
|
-
let routing = parseRoutingData(data);
|
|
573
|
+
let routing = await readRoutingFromDisk(account, bucketName);
|
|
574
|
+
if (!routing) return;
|
|
589
575
|
if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
|
|
590
576
|
let reason = config?.force && (config.reason || "forced") || "routing config changed";
|
|
591
577
|
let plan = computeStorePlan(account, bucketName, routing);
|
|
592
|
-
// Config changes are applied to the RUNNING store in place - windows/routes mutate, added
|
|
593
|
-
// sources start syncing, removed sources' slots go dead - so the index, pending fast writes
|
|
594
|
-
// (re-capped to the new deadline), and unaffected sync loops all survive. The store is only
|
|
595
|
-
// destroyed for what it structurally cannot express (a rawDisk flip).
|
|
596
578
|
if (plan.structureKey === loaded.structureKey && loaded.store instanceof BlobStore) {
|
|
597
579
|
console.log(`Applying the routing config to the running store for bucket ${key} (${reason})`);
|
|
598
580
|
loaded.store.updateSources(plan.sourceSpecs.map(spec => {
|
|
@@ -623,9 +605,6 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
623
605
|
broadcastRoutingChanged();
|
|
624
606
|
}
|
|
625
607
|
|
|
626
|
-
// Per-write options are evaluated at the WRITE's time (and the key's route), not the current
|
|
627
|
-
// config: the write time is stamped exactly once (at the first node that receives the write) and
|
|
628
|
-
// propagates with the data, so every node resolves the same self entry - and the same options.
|
|
629
608
|
function getWriteConfig(bucket: LoadedBucket, writeTime: number, route: number): { fast?: boolean; writeDelay?: number } {
|
|
630
609
|
let self = selectEntryAt(bucket.selfEntries, writeTime, route);
|
|
631
610
|
return { fast: self?.fast, writeDelay: self?.writeDelay };
|
|
@@ -639,8 +618,6 @@ export async function assertMutable(bucket: LoadedBucket, filePath: string, writ
|
|
|
639
618
|
}
|
|
640
619
|
}
|
|
641
620
|
|
|
642
|
-
// Wrong-target rejections are rare and important (they ARE the switchover handoff), but a burst of
|
|
643
|
-
// racing writes at a boundary must not flood the log - so at most one line a minute
|
|
644
621
|
const WRONG_TARGET_LOG_THROTTLE = 60 * 1000;
|
|
645
622
|
let lastWrongTargetLog = 0;
|
|
646
623
|
function logWrongTargetRejection(message: string): void {
|
|
@@ -649,13 +626,10 @@ function logWrongTargetRejection(message: string): void {
|
|
|
649
626
|
console.log(message);
|
|
650
627
|
}
|
|
651
628
|
|
|
652
|
-
// Routing writes are serialized per bucket, so two concurrent creates can't both build a store
|
|
653
|
-
// (the loser's store would leak with its sync loops running forever)
|
|
654
629
|
const routingWrites = new Map<string, Promise<void>>();
|
|
655
630
|
|
|
656
631
|
async function writeRoutingConfig(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
657
632
|
let key = `${account}/${bucketName}`;
|
|
658
|
-
// Validates before storing anything, so a bad config can't brick the bucket
|
|
659
633
|
let incoming = parseRoutingData(data);
|
|
660
634
|
let loaded = await getLoadedBucket(account, bucketName);
|
|
661
635
|
if (!loaded) {
|
|
@@ -665,16 +639,26 @@ async function writeRoutingConfig(account: string, bucketName: string, data: Buf
|
|
|
665
639
|
console.log(`Created bucket ${key}`);
|
|
666
640
|
return;
|
|
667
641
|
}
|
|
668
|
-
// A changed config must carry a higher version than the one currently stored — otherwise two
|
|
669
|
-
// clients with different configs at the same version would keep clobbering each other. Same
|
|
670
|
-
// content at any version is a harmless no-op and is allowed through.
|
|
671
642
|
if (JSON.stringify(incoming) !== loaded.routingJSON && getConfigVersion(incoming) <= getConfigVersion(loaded.routing)) {
|
|
672
|
-
|
|
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);
|
|
673
646
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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++;
|
|
657
|
+
}
|
|
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 });
|
|
678
662
|
await scheduleRoutingReload(account, bucketName);
|
|
679
663
|
}
|
|
680
664
|
|
|
@@ -683,8 +667,6 @@ export async function writeBucketFile(account: string, bucketName: string, fileP
|
|
|
683
667
|
if (filePath === ROUTING_FILE) {
|
|
684
668
|
let key = `${account}/${bucketName}`;
|
|
685
669
|
let run = (routingWrites.get(key) || Promise.resolve()).then(() => writeRoutingConfig(account, bucketName, data, config));
|
|
686
|
-
// The chain must survive a failed write, so the stored link swallows the error (the caller
|
|
687
|
-
// still gets it from run)
|
|
688
670
|
routingWrites.set(key, run.then(() => { }, () => { }));
|
|
689
671
|
return await run;
|
|
690
672
|
}
|
|
@@ -692,13 +674,7 @@ export async function writeBucketFile(account: string, bucketName: string, fileP
|
|
|
692
674
|
if (!loaded) {
|
|
693
675
|
throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
694
676
|
}
|
|
695
|
-
// The write time is determined exactly once, HERE at the first node that receives the write;
|
|
696
|
-
// it propagates as lastModified, so every node evaluates the bucket options at the same instant
|
|
697
677
|
let writeTime = config?.lastModified || Date.now();
|
|
698
|
-
// Only freshly-stamped writes are checked: a write with an explicit lastModified is
|
|
699
|
-
// synchronization/backfill (old data legitimately lands on sources whose window has moved on),
|
|
700
|
-
// but a fresh write reaching us outside our windows/routes means the client resolved its
|
|
701
|
-
// target from a stale view - it must re-resolve and retry at the correct source.
|
|
702
678
|
let route = getRoute(filePath);
|
|
703
679
|
if (!config?.lastModified && loaded.selfEntries.length) {
|
|
704
680
|
let timeValid = loaded.selfEntries.filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
|
|
@@ -719,9 +695,7 @@ export function getBucketConfig(bucket: LoadedBucket): ArchivesConfig {
|
|
|
719
695
|
let progress = bucket.store.getSyncProgress?.();
|
|
720
696
|
return {
|
|
721
697
|
supportsChangesAfter: !!bucket.store.getChangesAfter,
|
|
722
|
-
|
|
723
|
-
// deploy takeover, since the remap is never written into the routing file itself
|
|
724
|
-
remoteConfig: applyDeployRemap(bucket.routing),
|
|
698
|
+
remoteConfig: bucket.routing,
|
|
725
699
|
index: progress?.index,
|
|
726
700
|
indexSources: progress?.sources,
|
|
727
701
|
readerDiskLimit: progress?.readerDiskLimit,
|
|
@@ -732,65 +706,235 @@ export function getBucketConfig(bucket: LoadedBucket): ArchivesConfig {
|
|
|
732
706
|
export async function rebuildAllLoadedBuckets(): Promise<void> {
|
|
733
707
|
for (let key of [...buckets.keys()]) {
|
|
734
708
|
let slash = key.indexOf("/");
|
|
735
|
-
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" });
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
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;
|
|
736
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);
|
|
737
731
|
}
|
|
738
732
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
+
}
|
|
744
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);
|
|
745
789
|
});
|
|
746
790
|
|
|
791
|
+
export type BucketDiskInfo = {
|
|
792
|
+
totalBytes: number;
|
|
793
|
+
freeBytes: number;
|
|
794
|
+
usedBytes: number;
|
|
795
|
+
};
|
|
747
796
|
export type ServerBucketInfo = {
|
|
748
797
|
bucketName: string;
|
|
749
|
-
// Loaded in memory (created or accessed since startup), so its synchronization is running
|
|
750
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;
|
|
751
805
|
config?: ArchivesConfig;
|
|
752
806
|
error?: string;
|
|
753
807
|
};
|
|
754
808
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
+
|
|
821
|
+
export type ActiveBucketInfo = {
|
|
822
|
+
folder: string;
|
|
823
|
+
/** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded */
|
|
824
|
+
routing: RemoteConfig;
|
|
825
|
+
/** Our own entries in that config, and the one currently valid */
|
|
826
|
+
selfEntries: HostedConfig[];
|
|
827
|
+
self?: HostedConfig;
|
|
828
|
+
config: ArchivesConfig;
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
/** The live in-memory state of ONE bucket, answered without touching the disk (no routing file read, no statfs, no stored write stats). Returns an error string when the bucket is not loaded here, which is the normal state for a bucket nothing has accessed since startup. */
|
|
832
|
+
export async function getActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
833
|
+
let key = `${account}/${bucketName}`;
|
|
834
|
+
let loadedPromise = buckets.get(key);
|
|
835
|
+
if (!loadedPromise) {
|
|
836
|
+
return `Bucket ${key} is not loaded on this server, so it has no live state (it loads on first access)`;
|
|
837
|
+
}
|
|
838
|
+
let loaded: LoadedBucket | undefined;
|
|
839
|
+
try {
|
|
840
|
+
loaded = await loadedPromise;
|
|
841
|
+
} catch (e) {
|
|
842
|
+
return `Bucket ${key} failed to load on this server: ${String((e as Error).stack ?? e).slice(0, 500)}`;
|
|
843
|
+
}
|
|
844
|
+
if (!loaded) {
|
|
845
|
+
return `Bucket ${key} does not exist on this server`;
|
|
846
|
+
}
|
|
847
|
+
return toActiveBucketInfo(loaded);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function toActiveBucketInfo(loaded: LoadedBucket): ActiveBucketInfo {
|
|
851
|
+
return {
|
|
852
|
+
folder: getBucketFolder(loaded.account, loaded.bucketName),
|
|
853
|
+
routing: loaded.routing,
|
|
854
|
+
selfEntries: loaded.selfEntries,
|
|
855
|
+
self: loaded.self,
|
|
856
|
+
config: getBucketConfig(loaded),
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Loads a bucket that exists on this server's disk into memory, which starts its synchronization and window timers, and returns its live state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-loaded buckets just return their state. */
|
|
861
|
+
export async function activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
862
|
+
let key = `${account}/${bucketName}`;
|
|
863
|
+
let wasLoaded = buckets.has(key);
|
|
864
|
+
let loaded: LoadedBucket | undefined;
|
|
865
|
+
try {
|
|
866
|
+
loaded = await getLoadedBucket(account, bucketName);
|
|
867
|
+
} catch (e) {
|
|
868
|
+
return `Bucket ${key} failed to load on this server: ${String((e as Error).stack ?? e).slice(0, 500)}`;
|
|
869
|
+
}
|
|
870
|
+
if (!loaded) {
|
|
871
|
+
return `Bucket ${key} does not exist on this server (no routing file in ${getBucketFolder(account, bucketName)})`;
|
|
872
|
+
}
|
|
873
|
+
if (!wasLoaded) {
|
|
874
|
+
console.log(`Activated bucket ${key} on request: it is now loaded and synchronizing`);
|
|
875
|
+
}
|
|
876
|
+
return toActiveBucketInfo(loaded);
|
|
877
|
+
}
|
|
878
|
+
|
|
759
879
|
export async function listAccountBuckets(account: string): Promise<ServerBucketInfo[]> {
|
|
880
|
+
let start = Date.now();
|
|
881
|
+
let timings = new Map<string, number>();
|
|
882
|
+
async function timed<T>(name: string, run: () => Promise<T>): Promise<T> {
|
|
883
|
+
let began = Date.now();
|
|
884
|
+
try {
|
|
885
|
+
return await run();
|
|
886
|
+
} finally {
|
|
887
|
+
timings.set(name, (timings.get(name) || 0) + (Date.now() - began));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
760
890
|
let accountFolder = path.join(getStorageServerConfig().folder, "buckets2", account);
|
|
761
891
|
let names: string[];
|
|
762
892
|
try {
|
|
763
|
-
names = await fs.promises.readdir(accountFolder);
|
|
893
|
+
names = await timed("readdir", () => fs.promises.readdir(accountFolder));
|
|
764
894
|
} catch {
|
|
765
895
|
return [];
|
|
766
896
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
897
|
+
try {
|
|
898
|
+
return await Promise.all(names.map(async bucketName => {
|
|
899
|
+
let key = `${account}/${bucketName}`;
|
|
900
|
+
let folder = getBucketFolder(account, bucketName);
|
|
901
|
+
let base: ServerBucketInfo = { bucketName, active: false, folder };
|
|
902
|
+
let [disk, writeStats] = await Promise.all([
|
|
903
|
+
timed("statfs", () => getDiskInfo(folder)).catch((e: Error) => {
|
|
904
|
+
base.diskError = String(e.stack ?? e).slice(0, 500);
|
|
905
|
+
return undefined;
|
|
906
|
+
}),
|
|
907
|
+
timed("writeStats", () => getBucketWriteStats(key)),
|
|
908
|
+
]);
|
|
909
|
+
base.disk = disk;
|
|
910
|
+
base.writeStats = writeStats;
|
|
911
|
+
const loadedPromise = buckets.get(key);
|
|
912
|
+
if (loadedPromise) {
|
|
913
|
+
try {
|
|
914
|
+
let loaded = await timed("awaitLoaded", () => loadedPromise);
|
|
915
|
+
if (loaded) {
|
|
916
|
+
return { ...base, active: true, config: getBucketConfig(loaded) };
|
|
917
|
+
}
|
|
918
|
+
} catch (e) {
|
|
919
|
+
return { ...base, active: true, error: String((e as Error).stack ?? e).slice(0, 500) };
|
|
920
|
+
}
|
|
921
|
+
}
|
|
771
922
|
try {
|
|
772
|
-
|
|
773
|
-
if (
|
|
774
|
-
|
|
775
|
-
continue;
|
|
923
|
+
const data = await timed("readRoutingFile", () => readRoutingFile(folder));
|
|
924
|
+
if (!data) {
|
|
925
|
+
return { ...base, error: `No routing file (${ROUTING_FILE})` };
|
|
776
926
|
}
|
|
927
|
+
let parsed = await timed("parseRouting", async () => parseRoutingData(data));
|
|
928
|
+
return { ...base, config: { remoteConfig: parsed } };
|
|
777
929
|
} catch (e) {
|
|
778
|
-
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
try {
|
|
783
|
-
let data = await new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
|
|
784
|
-
if (!data) {
|
|
785
|
-
results.push({ bucketName, active: false, error: `No routing file (${ROUTING_FILE})` });
|
|
786
|
-
continue;
|
|
930
|
+
return { ...base, error: String((e as Error).stack ?? e).slice(0, 500) };
|
|
787
931
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
}
|
|
932
|
+
}));
|
|
933
|
+
} finally {
|
|
934
|
+
// The parts run concurrently, so these are summed wall times per part, not a breakdown of the total
|
|
935
|
+
let parts = [...timings].map(([name, ms]) => `${name} ${ms}ms`).join(", ");
|
|
936
|
+
console.log(`listAccountBuckets(${account}) took ${Date.now() - start}ms for ${names.length} buckets: ${parts}`);
|
|
792
937
|
}
|
|
793
|
-
return results;
|
|
794
938
|
}
|
|
795
939
|
|
|
796
940
|
export async function deleteBucketFile(account: string, bucketName: string, filePath: string): Promise<void> {
|
|
@@ -802,12 +946,8 @@ export async function deleteBucketFile(account: string, bucketName: string, file
|
|
|
802
946
|
await loaded.store.del(filePath, getWriteConfig(loaded, Date.now(), getRoute(filePath)));
|
|
803
947
|
}
|
|
804
948
|
|
|
805
|
-
// ── local IArchives access ──
|
|
806
|
-
|
|
807
949
|
const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
|
|
808
950
|
|
|
809
|
-
// The in-process IArchives for a bucket hosted by THIS server — used when a RemoteConfig source
|
|
810
|
-
// URL points at ourselves, so we don't talk to ourselves over HTTPS. One singleton per bucket.
|
|
811
951
|
class ArchivesLocalBucket implements IArchives {
|
|
812
952
|
constructor(private account: string, private bucketName: string) { }
|
|
813
953
|
|
|
@@ -875,7 +1015,6 @@ class ArchivesLocalBucket implements IArchives {
|
|
|
875
1015
|
}
|
|
876
1016
|
public async getConfig(): Promise<ArchivesConfig> {
|
|
877
1017
|
let bucket = await this.getBucket();
|
|
878
|
-
// Missing buckets say true, matching what they become once created (the default store type)
|
|
879
1018
|
if (!bucket) return { supportsChangesAfter: true };
|
|
880
1019
|
return getBucketConfig(bucket);
|
|
881
1020
|
}
|