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.
Files changed (116) hide show
  1. package/builders/extensionBuild.ts +1 -1
  2. package/bundler/bundleEntry.ts +1 -3
  3. package/bundler/bundler.ts +2 -4
  4. package/bundler/sourceMaps.ts +1 -2
  5. package/examplestorage/browser.tsx +1 -2
  6. package/examplestorage/exampleserver.ts +1 -2
  7. package/index.d.ts +106 -99
  8. package/misc/dist/environment.ts.cache +2 -2
  9. package/misc/dist/getSecret.ts.cache +2 -2
  10. package/misc/dist/strings.ts.cache +2 -2
  11. package/misc/dist/zip.ts.cache +2 -2
  12. package/misc/https/dist/certs.ts.cache +2 -2
  13. package/misc/https/dist/dns.ts.cache +7 -16
  14. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  15. package/misc/https/dns.d.ts +1 -4
  16. package/misc/https/dns.ts +5 -14
  17. package/misc/https/hostServer.d.ts +2 -0
  18. package/misc/https/hostServer.ts +6 -7
  19. package/misc/https/httpsCerts.ts +4 -10
  20. package/misc/openrouter.ts +1 -2
  21. package/package.json +1 -1
  22. package/render-utils/FullscreenModal.tsx +1 -4
  23. package/render-utils/GenericFormat.tsx +2 -8
  24. package/render-utils/Input.tsx +5 -13
  25. package/render-utils/InputLabel.tsx +4 -6
  26. package/render-utils/InputPicker.tsx +1 -2
  27. package/render-utils/URLParam.ts +2 -3
  28. package/render-utils/dist/observer.tsx.cache +2 -2
  29. package/render-utils/mobxTyped.ts +2 -3
  30. package/render-utils/niceStringify.ts +2 -4
  31. package/storage/ArchivesDisk.ts +4 -14
  32. package/storage/BulkDatabase2/BulkDatabase2.d.ts +1 -2
  33. package/storage/BulkDatabase2/BulkDatabase2.ts +4 -11
  34. package/storage/BulkDatabase2/BulkDatabaseBase.ts +42 -114
  35. package/storage/BulkDatabase2/BulkDatabaseFormat.ts +31 -91
  36. package/storage/BulkDatabase2/BulkDatabaseMerge.ts +19 -57
  37. package/storage/BulkDatabase2/BulkDatabaseReader.ts +4 -12
  38. package/storage/BulkDatabase2/LoadedIndex.ts +6 -18
  39. package/storage/BulkDatabase2/WriteOverlay.ts +3 -8
  40. package/storage/BulkDatabase2/blockCache.ts +12 -28
  41. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +40 -103
  42. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +19 -50
  43. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +18 -50
  44. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +7 -15
  45. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +9 -21
  46. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +6 -11
  47. package/storage/BulkDatabase2/dist/blockCache.ts.cache +15 -31
  48. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +10 -29
  49. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +10 -18
  50. package/storage/BulkDatabase2/dist/streamLog.ts.cache +17 -43
  51. package/storage/BulkDatabase2/dist/syncClient.ts.cache +11 -27
  52. package/storage/BulkDatabase2/mergeLock.ts +7 -26
  53. package/storage/BulkDatabase2/mergeMarkers.ts +7 -15
  54. package/storage/BulkDatabase2/streamLog.ts +14 -40
  55. package/storage/BulkDatabase2/syncClient.ts +8 -24
  56. package/storage/FileFolderAPI.tsx +28 -75
  57. package/storage/IArchives.d.ts +3 -3
  58. package/storage/IArchives.ts +21 -50
  59. package/storage/IStorage.ts +2 -4
  60. package/storage/PendingManager.tsx +2 -3
  61. package/storage/TransactionStorage.ts +11 -24
  62. package/storage/backblaze.ts +12 -43
  63. package/storage/dist/ArchivesDisk.ts.cache +7 -17
  64. package/storage/dist/FileFolderAPI.tsx.cache +25 -63
  65. package/storage/dist/IArchives.ts.cache +10 -20
  66. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  67. package/storage/dist/JSONStorage.ts.cache +2 -2
  68. package/storage/dist/PendingManager.tsx.cache +5 -6
  69. package/storage/dist/TransactionStorage.ts.cache +14 -27
  70. package/storage/dist/backblaze.ts.cache +15 -46
  71. package/storage/dist/embeddingFormats.ts.cache +11 -24
  72. package/storage/dist/fileSystemPointer.ts.cache +4 -7
  73. package/storage/dist/remoteFileStorage.ts.cache +17 -36
  74. package/storage/embeddingFormats.ts +11 -27
  75. package/storage/fileSystemPointer.ts +2 -7
  76. package/storage/proxydatabase/dist/ivfEmbeddingDatabase.ts.cache +8 -18
  77. package/storage/proxydatabase/dist/transactionSet.ts.cache +4 -7
  78. package/storage/proxydatabase/inMemoryDatabase.ts +1 -3
  79. package/storage/proxydatabase/ivfEmbeddingDatabase.ts +8 -23
  80. package/storage/proxydatabase/transactionSet.ts +2 -5
  81. package/storage/remoteFileServer.ts +20 -48
  82. package/storage/remoteFileStorage.ts +14 -33
  83. package/storage/remoteStorage/ArchivesRemote.d.ts +1 -4
  84. package/storage/remoteStorage/ArchivesRemote.ts +11 -26
  85. package/storage/remoteStorage/ArchivesUrl.ts +2 -4
  86. package/storage/remoteStorage/accessPage.tsx +1 -5
  87. package/storage/remoteStorage/blobStore.d.ts +5 -13
  88. package/storage/remoteStorage/blobStore.ts +52 -130
  89. package/storage/remoteStorage/createArchives.d.ts +9 -13
  90. package/storage/remoteStorage/createArchives.ts +43 -187
  91. package/storage/remoteStorage/deployTakeover.d.ts +18 -32
  92. package/storage/remoteStorage/deployTakeover.ts +70 -384
  93. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +12 -24
  94. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +5 -7
  95. package/storage/remoteStorage/dist/blobStore.ts.cache +47 -117
  96. package/storage/remoteStorage/dist/cliArgs.ts.cache +2 -2
  97. package/storage/remoteStorage/dist/createArchives.ts.cache +54 -186
  98. package/storage/remoteStorage/dist/deployTakeover.ts.cache +81 -387
  99. package/storage/remoteStorage/dist/intermediateSources.ts.cache +119 -0
  100. package/storage/remoteStorage/dist/remoteConfig.ts.cache +36 -24
  101. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +20 -40
  102. package/storage/remoteStorage/dist/storageClientController.ts.cache +32 -0
  103. package/storage/remoteStorage/dist/storageController.ts.cache +30 -64
  104. package/storage/remoteStorage/dist/storageServerState.ts.cache +269 -186
  105. package/storage/remoteStorage/grantAccessCli.ts +1 -3
  106. package/storage/remoteStorage/intermediateSources.d.ts +21 -0
  107. package/storage/remoteStorage/intermediateSources.ts +117 -0
  108. package/storage/remoteStorage/remoteConfig.ts +33 -20
  109. package/storage/remoteStorage/sourceWrapper.d.ts +8 -19
  110. package/storage/remoteStorage/sourceWrapper.ts +19 -41
  111. package/storage/remoteStorage/storageClientController.ts +1 -3
  112. package/storage/remoteStorage/storageController.d.ts +5 -3
  113. package/storage/remoteStorage/storageController.ts +12 -83
  114. package/storage/remoteStorage/storageServer.ts +20 -34
  115. package/storage/remoteStorage/storageServerState.d.ts +28 -6
  116. 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 { applyDeployRemap, getTakeoverAltPort, getOwnWindowEndClip, onTakeoverEvent } from "./deployTakeover";
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
- /** Makes machineIds the complete trust list for the account: machines not in the list lose access, machines already trusted keep their existing record, and missing ones are added. */
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) || parsed.port === takeoverAltPort) {
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
- // Owners are recomputed at fire time - the routing (or the takeover remap) may have
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
- // The deploy-takeover remap is an INTERPRETATION overlay: everything behavioral (self entries,
425
- // windows, downstream sources) uses the remapped view, while loaded.routing/routingJSON stay
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 = effective.sources.indexOf(self);
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 < effective.sources.length; i++) {
481
- let source = effective.sources[i];
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 { effective, selfEntries, self, rawDisk, sourceSpecs, readerDiskLimit: self?.readerDiskLimit, structureKey };
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
- async function loadBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
540
- let data = await new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
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 buildBucket(account, bucketName, parseRoutingData(data));
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
- // The routing config is only ever read off our own disk - it is never pulled from another
595
- // source (it reaches us exclusively as a version-validated write, which lands on disk first)
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
- notifyRoutingChanged();
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
- notifyRoutingChanged();
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
- notifyRoutingChanged();
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
- throw new Error(`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)}`);
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
- // Routing writes bypass fast mode (the config must apply immediately and survive a crash)
685
- // and ignore immutable (the routing file must always stay updatable). An older lastModified
686
- // no-ops inside set, in which case the reload below sees no change.
687
- await loaded.store.set(ROUTING_FILE, data, { lastModified: config?.lastModified });
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
- // The remapped (interpretation) view: getConfig is how clients learn of an in-progress
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: "deploy takeover state changed" });
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
- onTakeoverEvent(event => {
750
- if (event === "remapChanged") {
751
- // The rebuild applies the remapped windows, and scheduleBoundaryScans (part of the
752
- // rebuild) schedules the handoff scans around the new windows' starts
753
- void rebuildAllLoadedBuckets();
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
- /** Every bucket the account has on this server, active or not, each with its configuration.
766
- * Inactive buckets are inspected straight from disk WITHOUT loading them - loading would start
767
- * their synchronization, and old invalid buckets must stay inert (their parse error is reported
768
- * instead). */
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
- let results: ServerBucketInfo[] = [];
778
- for (let bucketName of names) {
779
- let loadedPromise = buckets.get(`${account}/${bucketName}`);
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
- results.push({ bucketName, active: true, config: getBucketConfig(loaded) });
785
- continue;
847
+ return { ...base, active: true, config: getBucketConfig(loaded) };
786
848
  }
787
849
  } catch (e) {
788
- results.push({ bucketName, active: true, error: String((e as Error).stack ?? e).slice(0, 500) });
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 new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
854
+ let data = await readRoutingFile(folder);
794
855
  if (!data) {
795
- results.push({ bucketName, active: false, error: `No routing file (${ROUTING_FILE})` });
796
- continue;
856
+ return { ...base, error: `No routing file (${ROUTING_FILE})` };
797
857
  }
798
- results.push({ bucketName, active: false, config: { remoteConfig: parseRoutingData(data) } });
858
+ return { ...base, config: { remoteConfig: parseRoutingData(data) } };
799
859
  } catch (e) {
800
- results.push({ bucketName, active: false, error: String((e as Error).stack ?? e).slice(0, 500) });
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
  }