sliftutils 1.7.32 → 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 +105 -100
  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 +48 -189
  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 +4 -6
  103. package/storage/remoteStorage/dist/storageController.ts.cache +11 -73
  104. package/storage/remoteStorage/dist/storageServerState.ts.cache +265 -178
  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 +4 -5
  113. package/storage/remoteStorage/storageController.ts +10 -81
  114. package/storage/remoteStorage/storageServer.ts +20 -34
  115. package/storage/remoteStorage/storageServerState.d.ts +28 -5
  116. package/storage/remoteStorage/storageServerState.ts +271 -207
@@ -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 { 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";
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
- /** 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
+
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) || parsed.port === takeoverAltPort) {
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
- // Owners are recomputed at fire time - the routing (or the takeover remap) may have
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
- // The deploy-takeover remap is an INTERPRETATION overlay: everything behavioral (self entries,
426
- // windows, downstream sources) uses the remapped view, while loaded.routing/routingJSON stay
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 = effective.sources.indexOf(self);
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 < effective.sources.length; i++) {
482
- let source = effective.sources[i];
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 { effective, selfEntries, self, rawDisk, sourceSpecs, readerDiskLimit: self?.readerDiskLimit, structureKey };
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
- async function loadBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
541
- 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));
542
529
  if (!data) return undefined;
543
- 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);
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
- // The routing config is only ever read off our own disk - it is never pulled from another
585
- // source (it reaches us exclusively as a version-validated write, which lands on disk first)
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
- 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++;
673
657
  }
674
- // Routing writes bypass fast mode (the config must apply immediately and survive a crash)
675
- // and ignore immutable (the routing file must always stay updatable). An older lastModified
676
- // no-ops inside set, in which case the reload below sees no change.
677
- 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 });
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
- // The remapped (interpretation) view: getConfig is how clients learn of an in-progress
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,30 +706,118 @@ 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: "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" });
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;
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
+ }
736
751
  }
737
752
  }
738
753
 
739
- onTakeoverEvent(event => {
740
- if (event === "remapChanged") {
741
- // The rebuild applies the remapped windows, and scheduleBoundaryScans (part of the
742
- // rebuild) schedules the handoff scans around the new windows' starts
743
- void rebuildAllLoadedBuckets();
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()}`);
744
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
- /** Every bucket the account has on this server, active or not, each with its configuration.
756
- * Inactive buckets are inspected straight from disk WITHOUT loading them - loading would start
757
- * their synchronization, and old invalid buckets must stay inert (their parse error is reported
758
- * 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
+
759
821
  export async function listAccountBuckets(account: string): Promise<ServerBucketInfo[]> {
760
822
  let accountFolder = path.join(getStorageServerConfig().folder, "buckets2", account);
761
823
  let names: string[];
@@ -764,33 +826,40 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
764
826
  } catch {
765
827
  return [];
766
828
  }
767
- let results: ServerBucketInfo[] = [];
768
- for (let bucketName of names) {
769
- 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);
770
843
  if (loadedPromise) {
771
844
  try {
772
845
  let loaded = await loadedPromise;
773
846
  if (loaded) {
774
- results.push({ bucketName, active: true, config: getBucketConfig(loaded) });
775
- continue;
847
+ return { ...base, active: true, config: getBucketConfig(loaded) };
776
848
  }
777
849
  } catch (e) {
778
- results.push({ bucketName, active: true, error: String((e as Error).stack ?? e).slice(0, 500) });
779
- continue;
850
+ return { ...base, active: true, error: String((e as Error).stack ?? e).slice(0, 500) };
780
851
  }
781
852
  }
782
853
  try {
783
- let data = await new ArchivesDisk(getBucketFolder(account, bucketName)).get(ROUTING_FILE);
854
+ let data = await readRoutingFile(folder);
784
855
  if (!data) {
785
- results.push({ bucketName, active: false, error: `No routing file (${ROUTING_FILE})` });
786
- continue;
856
+ return { ...base, error: `No routing file (${ROUTING_FILE})` };
787
857
  }
788
- results.push({ bucketName, active: false, config: { remoteConfig: parseRoutingData(data) } });
858
+ return { ...base, config: { remoteConfig: parseRoutingData(data) } };
789
859
  } catch (e) {
790
- 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) };
791
861
  }
792
- }
793
- return results;
862
+ }));
794
863
  }
795
864
 
796
865
  export async function deleteBucketFile(account: string, bucketName: string, filePath: string): Promise<void> {
@@ -802,12 +871,8 @@ export async function deleteBucketFile(account: string, bucketName: string, file
802
871
  await loaded.store.del(filePath, getWriteConfig(loaded, Date.now(), getRoute(filePath)));
803
872
  }
804
873
 
805
- // ── local IArchives access ──
806
-
807
874
  const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
808
875
 
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
876
  class ArchivesLocalBucket implements IArchives {
812
877
  constructor(private account: string, private bucketName: string) { }
813
878
 
@@ -875,7 +940,6 @@ class ArchivesLocalBucket implements IArchives {
875
940
  }
876
941
  public async getConfig(): Promise<ArchivesConfig> {
877
942
  let bucket = await this.getBucket();
878
- // Missing buckets say true, matching what they become once created (the default store type)
879
943
  if (!bucket) return { supportsChangesAfter: true };
880
944
  return getBucketConfig(bucket);
881
945
  }