sliftutils 1.7.89 → 1.7.91

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 (41) hide show
  1. package/index.d.ts +36 -81
  2. package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
  3. package/misc/https/dist/dns.ts.cache +2 -2
  4. package/package.json +1 -1
  5. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
  6. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
  7. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
  8. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
  9. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
  10. package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
  11. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
  12. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
  13. package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
  14. package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
  15. package/storage/dist/ArchivesDisk.ts.cache +2 -2
  16. package/storage/dist/IArchives.ts.cache +2 -2
  17. package/storage/dist/archiveHelpers.ts.cache +87 -0
  18. package/storage/dist/backblaze.ts.cache +2 -2
  19. package/storage/remoteStorage/accessStats.d.ts +12 -0
  20. package/storage/remoteStorage/accessStats.ts +47 -0
  21. package/storage/remoteStorage/blobStore.d.ts +3 -1
  22. package/storage/remoteStorage/blobStore.ts +5 -0
  23. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +2 -2
  24. package/storage/remoteStorage/dist/accessStats.ts.cache +42 -3
  25. package/storage/remoteStorage/dist/blobStore.ts.cache +7 -4
  26. package/storage/remoteStorage/dist/createArchives.ts.cache +2 -2
  27. package/storage/remoteStorage/dist/intermediateManagement.ts.cache +56 -50
  28. package/storage/remoteStorage/dist/storageController.ts.cache +47 -58
  29. package/storage/remoteStorage/dist/storageServerState.ts.cache +119 -248
  30. package/storage/remoteStorage/dist/storeConfig.ts.cache +2 -2
  31. package/storage/remoteStorage/dist/storePlan.ts.cache +4 -56
  32. package/storage/remoteStorage/dist/storeSync.ts.cache +6 -5
  33. package/storage/remoteStorage/intermediateManagement.d.ts +2 -3
  34. package/storage/remoteStorage/intermediateManagement.ts +53 -46
  35. package/storage/remoteStorage/storageController.ts +35 -44
  36. package/storage/remoteStorage/storageServerState.d.ts +17 -57
  37. package/storage/remoteStorage/storageServerState.ts +112 -279
  38. package/storage/remoteStorage/storeConfig.d.ts +2 -0
  39. package/storage/remoteStorage/storeConfig.ts +2 -0
  40. package/storage/remoteStorage/storePlan.d.ts +0 -20
  41. package/storage/remoteStorage/storePlan.ts +1 -69
@@ -1,13 +1,14 @@
1
1
  import { lazy } from "socket-function/src/caching";
2
2
  import { runInfinitePoll } from "socket-function/src/batching";
3
3
  import { RemoteConfig, SourceConfig, HostedConfig } from "../IArchives";
4
- import { ROUTING_FILE, getConfigVersion, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort } from "./remoteConfig";
4
+ import { ROUTING_FILE, getConfigVersion, serializeRemoteConfig, parseRoutingData, parseHostedUrl, replaceHostedUrlPort } from "./remoteConfig";
5
5
  import { injectIntermediateSource, expireIntermediateSources, getIntermediateSources, nextIntermediateVersion, INTERMEDIATE_EXPIRE_GRACE } from "./intermediateSources";
6
6
  import { getTakeoverIntermediate } from "./deployTakeover";
7
7
  import { getStorageServerConfig } from "./serverConfig";
8
8
  import { createApiArchives, listServerActiveBucketKeys } from "./createArchives";
9
9
  import { findSelfIndexes, previousWindowOwners } from "./storePlan";
10
- import { BucketState, getBucketStates, getLoadedBucket, writeRoutingConfig } from "./storageServerState";
10
+ import { getActiveBucketKeys, getStore, activateBucket, writeRoutingConfig } from "./storageServerState";
11
+ import { readRoutingFromDisk, readNewestRoutingFile } from "./bucketDisk";
11
12
  import { DEFAULT_FAST_WRITE_DELAY } from "./ArchivesDelayed";
12
13
  import { WINDOW_END_FLUSH_MARGIN } from "./blobStore";
13
14
 
@@ -26,19 +27,20 @@ const INTERMEDIATE_MAINTAIN_INTERVAL = 60 * 1000;
26
27
  const scheduledBoundaryScans = new Set<string>();
27
28
  const boundaryScansRunning = new Set<string>();
28
29
 
29
- /** Called whenever a bucket's state is (re)built: arms the scans its upcoming window boundaries need. Each scan is scheduled once - the key includes the boundary it is for - so re-arming on every config change is harmless. */
30
- export function scheduleBoundaryWork(loaded: BucketState): void {
31
- let key = `${loaded.account}/${loaded.bucketName}`;
30
+ /** Called every time a store applies a routing config to itself (see BlobStore's onRoutingApplied): arms the scans the config's upcoming window boundaries need. Each scan is scheduled once - the key includes the boundary it is for - so re-arming on every config application is harmless. */
31
+ export function scheduleBoundaryWork(account: string, bucketName: string, routing: RemoteConfig): void {
32
+ let key = `${account}/${bucketName}`;
33
+ let selfEntries = findSelfIndexes(routing, account, bucketName).map(i => routing.sources[i] as HostedConfig);
32
34
  let now = Date.now();
33
35
  let starts = new Set<number>();
34
- for (let self of loaded.selfEntries) {
36
+ for (let self of selfEntries) {
35
37
  let start = self.validWindow[0];
36
38
  if (start > now && start < Number.MAX_SAFE_INTEGER) {
37
39
  starts.add(start);
38
40
  }
39
41
  }
40
42
  // A window we have only just taken over, on a server that has only just started: the boundary happened while we were not running, so its scan still has to happen
41
- for (let self of loaded.selfEntries) {
43
+ for (let self of selfEntries) {
42
44
  let [start, end] = self.validWindow;
43
45
  if (!(start <= now && now < end) || start <= 0) continue;
44
46
  if (now - start > BOUNDARY_SCAN_LOOKBACK) continue;
@@ -46,7 +48,7 @@ export function scheduleBoundaryWork(loaded: BucketState): void {
46
48
  if (scheduledBoundaryScans.has(scheduleKey)) continue;
47
49
  scheduledBoundaryScans.add(scheduleKey);
48
50
  let timer = setTimeout(() => {
49
- void runBoundaryScan(loaded, start, STARTUP_BOUNDARY_SCAN_DELAY, "startup+30s").catch((e: Error) => console.error(`Boundary scan for bucket ${key} failed: ${e.stack ?? e}`));
51
+ void runBoundaryScan(account, bucketName, start, STARTUP_BOUNDARY_SCAN_DELAY, "startup+30s").catch((e: Error) => console.error(`Boundary scan for bucket ${key} failed: ${e.stack ?? e}`));
50
52
  }, STARTUP_BOUNDARY_SCAN_DELAY);
51
53
  (timer as { unref?: () => void }).unref?.();
52
54
  }
@@ -64,7 +66,7 @@ export function scheduleBoundaryWork(loaded: BucketState): void {
64
66
  arm();
65
67
  return;
66
68
  }
67
- void runBoundaryScan(loaded, start, offset).catch((e: Error) => console.error(`Boundary scan for bucket ${key} failed: ${e.stack ?? e}`));
69
+ void runBoundaryScan(account, bucketName, start, offset).catch((e: Error) => console.error(`Boundary scan for bucket ${key} failed: ${e.stack ?? e}`));
68
70
  }, Math.min(at - Date.now(), MAX_TIMER_DELAY));
69
71
  (timer as { unref?: () => void }).unref?.();
70
72
  };
@@ -73,9 +75,9 @@ export function scheduleBoundaryWork(loaded: BucketState): void {
73
75
  }
74
76
  }
75
77
 
76
- /** Collects the writes that landed on the previous window's owners just before a boundary, into the store that has just taken over. Who owned what is worked out purely from the config (see previousWindowOwners); this only does the pulling. */
77
- async function runBoundaryScan(loaded: BucketState, windowStart: number, offset: number, offsetLabel?: string): Promise<void> {
78
- let key = `${loaded.account}/${loaded.bucketName}`;
78
+ /** Collects the writes that landed on the previous window's owners just before a boundary, into the store that has just taken over. Who owned what is worked out purely from the config (see previousWindowOwners); this only does the pulling. The config is read off the disk at FIRE time, not captured when the scan was armed - it may have changed in between, and the newest copy is the one the handover actually happened under. */
79
+ async function runBoundaryScan(account: string, bucketName: string, windowStart: number, offset: number, offsetLabel?: string): Promise<void> {
80
+ let key = `${account}/${bucketName}`;
79
81
  let label = `bucket ${key}, window starting ${new Date(windowStart).toISOString()}, offset ${offsetLabel || `${offset / 1000}s`}`;
80
82
  if (boundaryScansRunning.has(key)) {
81
83
  console.log(`Skipping boundary scan (${label}): the previous boundary scan is still running`);
@@ -83,23 +85,22 @@ async function runBoundaryScan(loaded: BucketState, windowStart: number, offset:
83
85
  }
84
86
  boundaryScansRunning.add(key);
85
87
  try {
86
- let effective = loaded.routing;
88
+ let effective = await readRoutingFromDisk(account, bucketName);
87
89
  if (!effective) return;
88
- let handovers = previousWindowOwners(effective, windowStart, findSelfIndexes(effective, loaded.account, loaded.bucketName));
90
+ let handovers = previousWindowOwners(effective, windowStart, findSelfIndexes(effective, account, bucketName));
89
91
  if (!handovers.length) return;
90
92
  console.log(`Boundary scan (${label}): ${handovers.length} route(s) taking over: ${handovers.map(x => `${JSON.stringify(x.route)} from ${x.scanOwnDisk && "our own disk" || ""}${x.scanOwnDisk && x.remotes.size && " + " || ""}${[...x.remotes.keys()].map(i => `source ${i}`).join(", ")}`).join("; ")}`);
91
93
  let since = windowStart - BOUNDARY_SCAN_LOOKBACK;
92
94
  for (let handover of handovers) {
93
- let selfStore = loaded.stores.find(x => x.name === handover.name);
94
- if (!selfStore) continue;
95
+ let selfStore = getStore(account, bucketName, handover.name);
95
96
  if (handover.scanOwnDisk) {
96
- await selfStore.store.rescanBase();
97
+ await selfStore.rescanBase();
97
98
  }
98
99
  for (let [sourceIndex, route] of handover.remotes) {
99
100
  let ownerSource = effective.sources[sourceIndex];
100
101
  if (typeof ownerSource === "string") continue;
101
102
  try {
102
- await selfStore.store.boundaryScanRemote(createApiArchives(ownerSource), { since, route });
103
+ await selfStore.boundaryScanRemote(createApiArchives(ownerSource), { since, route });
103
104
  } catch (e) {
104
105
  console.error(`Boundary scan (${label}) of previous-window owner (source index ${sourceIndex}) failed: ${(e as Error).stack ?? e}`);
105
106
  }
@@ -133,66 +134,69 @@ export function reinjectIntermediates(current: RemoteConfig | undefined, incomin
133
134
  // Per bucket, not global: a switchover has to write every bucket's windows at once, and a shared throttle would let only one bucket through per interval
134
135
  const lastOwnConfigWrite = new Map<string, number>();
135
136
 
136
- async function writeOwnRoutingConfig(loaded: BucketState, updated: RemoteConfig, reason: string): Promise<void> {
137
- let key = `${loaded.account}/${loaded.bucketName}`;
137
+ async function writeOwnRoutingConfig(config: { account: string; bucketName: string; storeName: string; current: RemoteConfig; updated: RemoteConfig; reason: string }): Promise<void> {
138
+ let { account, bucketName, current, updated, reason } = config;
139
+ let key = `${account}/${bucketName}`;
138
140
  let sinceLast = Date.now() - (lastOwnConfigWrite.get(key) || 0);
139
141
  if (sinceLast < OWN_CONFIG_WRITE_THROTTLE) {
140
142
  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.`);
141
143
  return;
142
144
  }
143
145
  lastOwnConfigWrite.set(key, Date.now());
144
- // A switchover writes on top of whatever the bucket is running; with no config at all there is nothing to switch over
145
- if (!loaded.routing) return;
146
- let currentVersion = getConfigVersion(loaded.routing);
146
+ let currentVersion = getConfigVersion(current);
147
147
  let version = nextIntermediateVersion(currentVersion);
148
148
  let next: RemoteConfig = { ...updated, version };
149
149
  console.log(`Writing ${ROUTING_FILE} for bucket ${key} (${reason}), version ${currentVersion} -> ${version}: ${JSON.stringify(next)}`);
150
150
  let data = Buffer.from(serializeRemoteConfig(next));
151
- // Into one of our own stores - it applies it and its peers pull it - and directly to the other servers, which is what makes a switchover take effect in seconds rather than at the next poll
152
- let ourStore = loaded.stores[0];
153
- if (ourStore) {
154
- await writeRoutingConfig(loaded.account, loaded.bucketName, ourStore.name, data);
155
- }
156
- await propagateRoutingConfig(loaded, next, data);
151
+ // Into the store holding the newest copy - it applies it and its peers pull it - and directly to the other servers, which is what makes a switchover take effect in seconds rather than at the next poll
152
+ await writeRoutingConfig(account, bucketName, config.storeName, data);
153
+ await propagateRoutingConfig({ account, bucketName, current, next, data });
157
154
  }
158
155
 
159
- async function propagateRoutingConfig(loaded: BucketState, next: RemoteConfig, data: Buffer): Promise<void> {
156
+ async function propagateRoutingConfig(config: { account: string; bucketName: string; current: RemoteConfig; next: RemoteConfig; data: Buffer }): Promise<void> {
160
157
  let targets = new Map<string, SourceConfig>();
161
- for (let source of [...loaded.routing?.sources || [], ...next.sources]) {
158
+ for (let source of [...config.current.sources, ...config.next.sources]) {
162
159
  if (typeof source === "string" || source.intermediate) continue;
163
160
  if (source.type === "remote") {
164
161
  let parsed = parseHostedUrl(source.url);
165
- if (parsed.account !== loaded.account || parsed.bucketName !== loaded.bucketName) continue;
162
+ if (parsed.account !== config.account || parsed.bucketName !== config.bucketName) continue;
166
163
  }
167
164
  if (targets.has(source.url)) continue;
168
165
  targets.set(source.url, source);
169
166
  }
170
167
  for (let [url, source] of targets) {
171
168
  try {
172
- await createApiArchives(source).set(ROUTING_FILE, data);
173
- console.log(`Wrote ${ROUTING_FILE} for bucket ${loaded.account}/${loaded.bucketName} to ${url}`);
169
+ await createApiArchives(source).set(ROUTING_FILE, config.data);
170
+ console.log(`Wrote ${ROUTING_FILE} for bucket ${config.account}/${config.bucketName} to ${url}`);
174
171
  } catch (e) {
175
172
  console.error(`Propagating our routing config update to ${url} failed: ${(e as Error).stack ?? e}`);
176
173
  }
177
174
  }
178
175
  }
179
176
 
180
- /** Keeps every loaded bucket's switchover windows current: gives our alternate port its window while a takeover is in flight, and takes expired ones back out. */
177
+ /** Keeps every active bucket's switchover windows current: gives our alternate port its window while a takeover is in flight, and takes expired ones back out. */
181
178
  async function maintainIntermediates(): Promise<void> {
182
179
  let { domain, port } = getStorageServerConfig();
183
180
  let takeover = getTakeoverIntermediate();
184
181
  let written: string[] = [];
185
182
  let alreadyCorrect: string[] = [];
186
183
  let notOurs: string[] = [];
187
- // ONLY buckets already in memory. A switchover must never load a bucket: loading starts its synchronization, and buckets nothing has touched (legacy ones especially) have no writes to hand off in the first place. One that does get used mid-switchover loads on that access, and the next pass gives it its window.
188
- let states = getBucketStates();
189
- for (let loaded of states) {
190
- let key = `${loaded.account}/${loaded.bucketName}`;
191
- let routing = loaded.routing;
192
- if (!routing) continue;
184
+ // ONLY buckets already active. A switchover must never activate a bucket: that starts its synchronization, and buckets nothing has touched (legacy ones especially) have no writes to hand off in the first place. One that does get used mid-switchover activates on that access, and the next pass gives it its window.
185
+ let activeKeys = getActiveBucketKeys();
186
+ for (let { account, bucketName } of activeKeys) {
187
+ let key = `${account}/${bucketName}`;
188
+ let newest = await readNewestRoutingFile(account, bucketName);
189
+ if (!newest) continue;
190
+ let routing: RemoteConfig;
191
+ try {
192
+ routing = parseRoutingData(newest.data);
193
+ } catch (e) {
194
+ console.error(`Skipping switchover maintenance for bucket ${key}: its routing config could not be parsed (${(e as Error).message})`);
195
+ continue;
196
+ }
193
197
  let expired = expireIntermediateSources(routing, Date.now());
194
198
  if (JSON.stringify(expired) !== JSON.stringify(routing)) {
195
- await writeOwnRoutingConfig(loaded, expired, `switchover windows expired more than ${INTERMEDIATE_EXPIRE_GRACE / 1000}s ago`);
199
+ await writeOwnRoutingConfig({ account, bucketName, storeName: newest.name, current: routing, updated: expired, reason: `switchover windows expired more than ${INTERMEDIATE_EXPIRE_GRACE / 1000}s ago` });
196
200
  written.push(key);
197
201
  continue;
198
202
  }
@@ -216,16 +220,16 @@ async function maintainIntermediates(): Promise<void> {
216
220
  alreadyCorrect.push(key);
217
221
  continue;
218
222
  }
219
- 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()}`);
223
+ await writeOwnRoutingConfig({ account, bucketName, storeName: newest.name, current: routing, updated: injected, reason: `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()}` });
220
224
  written.push(key);
221
225
  }
222
226
  if (!takeover) return;
223
- if (!states.length) {
227
+ if (!activeKeys.length) {
224
228
  console.log(`No active buckets, so no config files to write the intermediate into (a bucket is only active once something uses it)`);
225
229
  return;
226
230
  }
227
231
  if (!written.length) {
228
- console.log(`Wrote no config files: of ${states.length} active buckets, ${alreadyCorrect.length} already contain the intermediate and ${notOurs.length} are not hosted by us`);
232
+ console.log(`Wrote no config files: of ${activeKeys.length} active buckets, ${alreadyCorrect.length} already contain the intermediate and ${notOurs.length} are not hosted by us`);
229
233
  }
230
234
  }
231
235
 
@@ -247,7 +251,10 @@ async function activatePredecessorBuckets(): Promise<void> {
247
251
  console.log(`Our predecessor on ${url} has ${keys.length} active buckets, activating them here so their config files get the intermediate: ${keys.map(x => `${x.account}/${x.bucketName}`).join(", ")}`);
248
252
  for (let { account, bucketName } of keys) {
249
253
  try {
250
- await getLoadedBucket(account, bucketName);
254
+ let result = await activateBucket(account, bucketName);
255
+ if (typeof result === "string") {
256
+ console.error(`Activating bucket ${account}/${bucketName} (active on our predecessor) failed: ${result}`);
257
+ }
251
258
  } catch (e) {
252
259
  console.error(`Activating bucket ${account}/${bucketName} (active on our predecessor) failed: ${(e as Error).stack ?? e}`);
253
260
  }
@@ -9,11 +9,12 @@ import { getCommonName, getPublicIdentifier, getOwnMachineId, verify, verifyMach
9
9
  import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, FindConfig, SourceConfig, IMMUTABLE_CACHE_TIME } from "../IArchives";
10
10
  import { ROUTING_FILE, getRoute, routeContains } from "./remoteConfig";
11
11
  import {
12
- getLoadedBucket, requireBucket, findBucketStore, readBucketInternal, writeRoutingConfig,
13
- getBucketConfig, bucketSyncStatus, bucketIndexTotals, LoadedStore, ActiveBucketInfo,
14
- listAccountBuckets, ServerBucketInfo, clearAccountWriteStats,
15
- getActiveBucket, activateBucket, getActiveBucketKeys,
12
+ findBucketStore, getStore, getBucketStores, readBucketInternal, writeRoutingConfig,
13
+ getBucketArchivesConfig, bucketSyncStatus, debugBucketIndexTotals, ActiveBucketInfo,
14
+ debugListAccountBuckets, ServerBucketInfo,
15
+ debugGetActiveBucket, activateBucket, getActiveBucketKeys,
16
16
  } from "./storageServerState";
17
+ import { debugClearAccountWriteStats } from "./accessStats";
17
18
  import { getStorageServerConfig, getTrust, getRequests, assertWritesAllowed } from "./serverConfig";
18
19
  import { BlobStore } from "./blobStore";
19
20
  import { getRoutingFileResult } from "./bucketDisk";
@@ -332,13 +333,13 @@ class RemoteStorageControllerBase {
332
333
  }
333
334
  @assertValidArgs
334
335
  async getArchivesConfig(config: { account: string; bucketName: string }): Promise<ArchivesConfig> {
335
- return getBucketConfig(await requireBucket(config.account, config.bucketName));
336
+ return await getBucketArchivesConfig(config.account, config.bucketName);
336
337
  }
337
338
  @assertValidArgs
338
339
  async listBuckets(config: { account: string }): Promise<ServerBucketInfo[]> {
339
340
  let start = Date.now();
340
341
  try {
341
- return await listAccountBuckets(config.account);
342
+ return await debugListAccountBuckets(config.account);
342
343
  } finally {
343
344
  // The access hook (and the storage it initializes) runs before this, so a large gap between this and the caller's timing is the hook
344
345
  console.log(`listBuckets(${config.account}) call body took ${Date.now() - start}ms`);
@@ -347,7 +348,7 @@ class RemoteStorageControllerBase {
347
348
  /** The live, in-memory state of one bucket, or a string saying why it is unavailable. Answered without touching the disk, so it is cheap - but only works while the bucket is loaded here. */
348
349
  @assertValidArgs
349
350
  async getActiveBucket(config: { account: string; bucketName: string }): Promise<ActiveBucketInfo | string> {
350
- return await getActiveBucket(config.account, config.bucketName);
351
+ return await debugGetActiveBucket(config.account, config.bucketName);
351
352
  }
352
353
  /** Loads a bucket that exists on this server into memory (starting its synchronization) and returns its live state, or a string saying why it could not be loaded. */
353
354
  @assertValidArgs
@@ -358,7 +359,7 @@ class RemoteStorageControllerBase {
358
359
  @assertValidArgs
359
360
  async clearWriteStats(config: { account: string }): Promise<{ clearedBuckets: number }> {
360
361
  clearAccountAccessStats(config.account);
361
- return { clearedBuckets: clearAccountWriteStats(config.account) };
362
+ return { clearedBuckets: debugClearAccountWriteStats(config.account) };
362
363
  }
363
364
  /** In-memory totals per operation type since startup (or the last clearWriteStats). */
364
365
  @assertValidArgs
@@ -372,22 +373,22 @@ class RemoteStorageControllerBase {
372
373
  }
373
374
  @assertValidArgs
374
375
  async getIndexInfo(config: { account: string; bucketName: string }): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } | undefined> {
375
- return await bucketIndexTotals(await requireBucket(config.account, config.bucketName));
376
+ return await debugBucketIndexTotals(config.account, config.bucketName);
376
377
  }
377
378
  @assertValidArgs
378
379
  async getSyncStatus(config: { account: string; bucketName: string }): Promise<ArchivesSyncStatus> {
379
- return await bucketSyncStatus(await requireBucket(config.account, config.bucketName));
380
+ return await bucketSyncStatus(config.account, config.bucketName);
380
381
  }
381
382
 
382
383
  @assertValidArgs
383
384
  async startLargeFile(config: { account: string; bucketName: string; path: string; sourceConfig: SourceConfig; lastModified?: number; forceSetImmutable?: boolean; noChecks?: boolean; internal?: boolean }): Promise<string> {
384
385
  assertWritesAllowed();
385
- let target = await findBucketStore(config.account, config.bucketName, config.sourceConfig);
386
+ let store = findBucketStore(config.account, config.bucketName, config.sourceConfig);
386
387
  // The store validates the write here, before any bytes move (see BlobStore.startLargeUpload) - a large file is a set, and its SetConfig decides whether it is even allowed
387
388
  let write = { path: config.path, lastModified: config.lastModified, forceSetImmutable: config.forceSetImmutable, noChecks: config.noChecks, internal: config.internal };
388
- let id = await target.store.startLargeUpload(write);
389
+ let id = await store.startLargeUpload(write);
389
390
  // The store name pins the upload's parts and finish to the same store the start picked (in-flight uploads survive a bucket rebuild: the part data lives in that store's folder, which a rebuilt store still opens)
390
- largeUploadInfo.set(id, { ...write, account: config.account, bucketName: config.bucketName, storeName: target.name });
391
+ largeUploadInfo.set(id, { ...write, account: config.account, bucketName: config.bucketName, storeName: config.sourceConfig.name });
391
392
  return id;
392
393
  }
393
394
  async uploadPart(config: { uploadId: string; data: Buffer }): Promise<void> {
@@ -395,28 +396,21 @@ class RemoteStorageControllerBase {
395
396
  let info = largeUploadInfo.get(config.uploadId);
396
397
  if (!info) throw new Error(`Unknown large upload ${config.uploadId}`);
397
398
  trackAccess({ account: info.account, operation: "uploadPart", path: `${info.bucketName}/${info.path}`, size: config.data.length });
398
- let target = await findUploadStore(info);
399
- await target.store.appendLargeUpload({ id: config.uploadId, data: Buffer.from(config.data) });
399
+ await getStore(info.account, info.bucketName, info.storeName).appendLargeUpload({ id: config.uploadId, data: Buffer.from(config.data) });
400
400
  }
401
401
  async finishLargeFile(config: { uploadId: string }): Promise<void> {
402
402
  assertWritesAllowed();
403
403
  let info = largeUploadInfo.get(config.uploadId);
404
404
  if (!info) throw new Error(`Unknown large upload ${config.uploadId}`);
405
405
  largeUploadInfo.delete(config.uploadId);
406
- let target = await findUploadStore(info);
407
- await target.store.finishLargeUpload({ id: config.uploadId, path: info.path, lastModified: info.lastModified, forceSetImmutable: info.forceSetImmutable, noChecks: info.noChecks, internal: info.internal });
406
+ await getStore(info.account, info.bucketName, info.storeName).finishLargeUpload({ id: config.uploadId, path: info.path, lastModified: info.lastModified, forceSetImmutable: info.forceSetImmutable, noChecks: info.noChecks, internal: info.internal });
408
407
  }
409
- /** Best-effort cleanup: an upload whose bucket or store has since vanished has nothing left to cancel. */
408
+ /** Best-effort cleanup: an unknown upload has nothing left to cancel. */
410
409
  async cancelLargeFile(config: { uploadId: string }): Promise<void> {
411
410
  let info = largeUploadInfo.get(config.uploadId);
412
411
  if (!info) return;
413
412
  largeUploadInfo.delete(config.uploadId);
414
- let bucket = await getLoadedBucket(info.account, info.bucketName);
415
- if (!bucket) return;
416
- const storeName = info.storeName;
417
- let target = bucket.stores.find(x => x.name === storeName);
418
- if (!target) return;
419
- await target.store.cancelLargeUpload({ id: config.uploadId });
413
+ await getStore(info.account, info.bucketName, info.storeName).cancelLargeUpload({ id: config.uploadId });
420
414
  }
421
415
 
422
416
  // IMPORTANT: We can never expose enumeration (listing, prefix search, changes feeds) over this public HTTP endpoint - only exact-key reads. Enumeration would be a massive security risk (public buckets rely on unguessable keys staying unguessable), and could also crash the client by sending them too much data. Listings exist only on the authenticated API (findInfo etc, behind accountAccess).
@@ -442,20 +436,26 @@ class RemoteStorageControllerBase {
442
436
  assertValidName(account, "account");
443
437
  assertValidName(bucketName, "bucket name");
444
438
  assertValidPath(filePath);
445
- let bucket = await getLoadedBucket(account, bucketName);
446
- if (!bucket) {
439
+ let bucketStores = await getBucketStores(account, bucketName);
440
+ if (!bucketStores.length) {
447
441
  return setHTTPResultHeaders(Buffer.from(""), { status: "404" });
448
442
  }
449
- if (!bucket.self?.public) {
443
+ // Each store's policy comes from its own config, which it has only read once init ran
444
+ await Promise.all(bucketStores.map(x => x.store.init()));
445
+ let policies = bucketStores.map(x => ({ store: x.store, policy: x.store.storeConfig.current() }));
446
+ if (!policies.some(x => x.policy.public)) {
450
447
  throw new Error(`Bucket ${account}/${bucketName} is not public, so its files cannot be read over plain URLs`);
451
448
  }
452
449
  // Anonymous URL reads carry no source selection - the file's route picks the store, and a route no store here covers is simply not found (the routing file itself lives outside every store)
453
- let httpStore: LoadedStore | undefined;
450
+ let httpStore: BlobStore | undefined;
451
+ let immutable = false;
454
452
  let info: { writeTime: number; size: number } | undefined;
455
453
  if (filePath !== ROUTING_FILE) {
456
454
  let route = getRoute(filePath);
457
- httpStore = bucket.stores.find(s => s.entries.some(e => routeContains(e.route, route)));
458
- info = httpStore && await httpStore.store.getInfo({ path: filePath }) || undefined;
455
+ let serving = policies.find(x => x.policy.public && routeContains(x.policy.route, route));
456
+ httpStore = serving?.store;
457
+ immutable = !!serving?.policy.immutable;
458
+ info = httpStore && await httpStore.getInfo({ path: filePath }) || undefined;
459
459
  } else {
460
460
  info = await getRoutingFileResult(account, bucketName);
461
461
  }
@@ -469,7 +469,8 @@ class RemoteStorageControllerBase {
469
469
  "Last-Modified": new Date(info.writeTime).toUTCString(),
470
470
  "Accept-Ranges": "bytes",
471
471
  };
472
- if (bucket.self?.immutable) {
472
+ // The serving store's own policy - and never for the routing file, which changing is the whole point of
473
+ if (immutable) {
473
474
  headers["Cache-Control"] = `max-age=${IMMUTABLE_CACHE_TIME / 1000}`;
474
475
  }
475
476
  let ifModifiedSince = request?.headers["if-modified-since"];
@@ -502,7 +503,7 @@ class RemoteStorageControllerBase {
502
503
  }
503
504
  let result: { data: Buffer; writeTime: number; size: number } | undefined;
504
505
  if (httpStore) {
505
- result = await httpStore.store.get2({ path: filePath, range });
506
+ result = await httpStore.get2({ path: filePath, range });
506
507
  } else {
507
508
  result = await getRoutingFileResult(account, bucketName);
508
509
  if (result && range) {
@@ -520,23 +521,13 @@ class RemoteStorageControllerBase {
520
521
  }
521
522
  }
522
523
 
523
- /** The one resolution every data call does: the client's selected sourceConfig maps to exactly one store (loading the bucket - and so instantiating its stores - if needed), and fn runs on it. findBucketStore throws for missing buckets and unmatched configs. */
524
+ /** The one resolution every data call does: the client's selected sourceConfig NAMES exactly one store (created on first use), and fn runs on it. */
524
525
  async function withStore<T>(config: { account: string; bucketName: string; sourceConfig: SourceConfig }, fn: (store: BlobStore) => Promise<T>): Promise<T> {
525
- let target = await findBucketStore(config.account, config.bucketName, config.sourceConfig);
526
- return await fn(target.store);
526
+ return await fn(findBucketStore(config.account, config.bucketName, config.sourceConfig));
527
527
  }
528
528
 
529
529
  const largeUploadInfo = new Map<string, { account: string; bucketName: string; path: string; lastModified?: number; forceSetImmutable?: boolean; noChecks?: boolean; internal?: boolean; storeName: string }>();
530
530
 
531
- async function findUploadStore(info: { account: string; bucketName: string; storeName: string }): Promise<LoadedStore> {
532
- let bucket = await requireBucket(info.account, info.bucketName);
533
- let target = bucket.stores.find(x => x.name === info.storeName);
534
- if (!target) {
535
- throw new Error(`The store ${JSON.stringify(info.storeName)} this upload targets no longer exists on bucket ${info.account}/${info.bucketName} (available: ${JSON.stringify(bucket.stores.map(x => x.name))})`);
536
- }
537
- return target;
538
- }
539
-
540
531
  const accountAccess: SocketFunctionHook = async (context) => {
541
532
  let start = Date.now();
542
533
  let config = context.call.args[0] as { account?: string } | undefined;
@@ -1,38 +1,17 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
3
  import { BlobStore } from "./blobStore";
4
- import { RemoteConfig, HostedConfig, SourceConfig, IArchives, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
4
+ import { RemoteConfig, SourceConfig, IArchives, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
5
5
  import { BucketDiskInfo } from "./bucketDisk";
6
- import { SelfSummary } from "./storePlan";
7
- /**
8
- * What this server HAS, as opposed to what any of it does. Four things, and nothing else:
9
- *
10
- * - the stores, by folder (getStore) - made once, and self-configuring from there
11
- * - which of them a request means (findBucketStore), which is only ever a name
12
- * - what exists on disk, for listings and for the pages that report it
13
- *
14
- * It decides nothing about routes, windows or peers - a store reads its own config and works that
15
- * out itself (BlobStore.applyRoutingConfig) - and it schedules nothing: switchovers and the handovers
16
- * they cause live in intermediateManagement.
17
- */
18
- export type LoadedStore = {
6
+ import { BucketWriteStats } from "./accessStats";
7
+ export declare function getStore(account: string, bucketName: string, name: string): BlobStore;
8
+ /** The store serving a request: the one the client's selected entry NAMES. Account, name, and bucket ARE the folder, so this is a direct lookup, never a search of what exists - and a name this server has never seen is CREATED, never rejected, because asking for a name is the instruction to have that store (one name, one folder, one index; it configures itself once the routing config lands in it). Nothing else about the request is compared - not the window, not the route, not the flags - which is the whole point of naming it: a client a config version behind on some flag still reaches the right store. */
9
+ export declare function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): BlobStore;
10
+ /** The stores of a bucket as the DISK records them (a bucket is nothing more than the store folders sharing its name), opened - so the ones that weren't running yet start synchronizing. Empty when the bucket does not exist here. */
11
+ export declare function getBucketStores(account: string, bucketName: string): Promise<{
19
12
  name: string;
20
- entries: HostedConfig[];
21
- folder: string;
22
13
  store: BlobStore;
23
- };
24
- export type BucketState = {
25
- account: string;
26
- bucketName: string;
27
- routing?: RemoteConfig;
28
- selfEntries: HostedConfig[];
29
- self: SelfSummary | undefined;
30
- stores: LoadedStore[];
31
- };
32
- /** The loaded bucket, loading it (which instantiates its stores and starts their synchronization) if needed. A bucket that does not exist on this server throws - callers never see undefined buckets. */
33
- export declare function requireBucket(account: string, bucketName: string): Promise<BucketState>;
34
- /** The store serving a request: the one the client's selected entry NAMES. Nothing else is compared - not the window, not the route, not the flags - because the name is what says which storage this is, and everything else is policy the store itself applies to the request. A client a config version behind on some flag therefore still reaches the right store, which is the whole point of naming it. */
35
- export declare function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): Promise<LoadedStore>;
14
+ }[]>;
36
15
  /** Internal (store-to-store) reads skip store selection entirely: the caller is another store whose index says this MACHINE holds the bytes - the persisted holder identity is just a URL, which cannot name a store. Whichever store's folder has the newest copy answers. */
37
16
  export declare function readBucketInternal(account: string, bucketName: string, config: {
38
17
  path: string;
@@ -46,9 +25,9 @@ export declare function readBucketInternal(account: string, bucketName: string,
46
25
  writeTime: number;
47
26
  size: number;
48
27
  } | undefined>;
49
- export declare function getBucketConfig(bucket: BucketState): ArchivesConfig;
50
- export declare function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyncStatus>;
51
- export declare function bucketIndexTotals(bucket: BucketState): Promise<{
28
+ export declare function getBucketArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig>;
29
+ export declare function bucketSyncStatus(account: string, bucketName: string): Promise<ArchivesSyncStatus>;
30
+ export declare function debugBucketIndexTotals(account: string, bucketName: string): Promise<{
52
31
  fileCount: number;
53
32
  byteCount: number;
54
33
  sources: {
@@ -57,14 +36,8 @@ export declare function bucketIndexTotals(bucket: BucketState): Promise<{
57
36
  byteCount: number;
58
37
  }[];
59
38
  }>;
60
- /** The bucket states currently in memory - what the switchover machinery walks. Only what is loaded: loading a bucket starts its synchronization, and a bucket nothing has touched has no in-flight writes to hand over anyway. */
61
- export declare function getBucketStates(): BucketState[];
62
39
  /** A cached IArchives for a persisted source identity: a routing URL (hosted/backblaze) or a disk folder path - the form BlobStore's sources list stores. Configuration (valid windows, routes) decides WHEN a source should be used; for reading bytes the index says a source holds, the URL alone is enough - even for sources no longer in any config. */
63
40
  export declare function resolveSourceArchives(url: string): IArchives;
64
- export declare function getStore(account: string, bucketName: string, name: string): BlobStore;
65
- export declare function getLoadedBucket(account: string, bucketName: string): Promise<BucketState | undefined>;
66
- /** Re-reads the bucket off the disk, replacing the cached snapshot: what a routing-config write calls, because that write can CREATE a store (a new name gets a new folder) and the snapshot would keep answering with the stores it saw at load time. Existing stores are reused (getStore caches by folder), so this only picks up folder changes - it never restarts a store. */
67
- export declare function reloadBucket(account: string, bucketName: string): Promise<BucketState | undefined>;
68
41
  /**
69
42
  * Writing the routing config is a write like any other: it goes into a store, and the store applies
70
43
  * it to itself and lets its peers pull it. The only thing that happens here is picking WHICH store,
@@ -77,7 +50,7 @@ export declare function reloadBucket(account: string, bucketName: string): Promi
77
50
  export declare function writeRoutingConfig(account: string, bucketName: string, name: string, data: Buffer, config?: {
78
51
  lastModified?: number;
79
52
  }): Promise<void>;
80
- /** Which buckets this process currently has loaded - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
53
+ /** Which buckets this process currently has active (some store of theirs was opened) - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
81
54
  export declare function getActiveBucketKeys(): {
82
55
  account: string;
83
56
  bucketName: string;
@@ -96,25 +69,12 @@ export type ServerBucketInfo = {
96
69
  };
97
70
  export type ActiveBucketInfo = {
98
71
  folder: string;
99
- /** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded. Absent when none of its stores has one yet. */
72
+ /** The bucket's routing config, the newest copy among its stores. Absent when none of them has one yet. */
100
73
  routing?: RemoteConfig;
101
- /** Our own entries in that config, and their summarized current role (routes union + flags) */
102
- selfEntries: HostedConfig[];
103
- self?: SelfSummary;
104
74
  config: ArchivesConfig;
105
75
  };
106
- /** The live in-memory state of ONE bucket, answered without touching the disk (no routing file read, no statfs, no stored write stats). Returns an error string when the bucket is not loaded here, which is the normal state for a bucket nothing has accessed since startup. */
107
- export declare function getActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string>;
108
- /** Loads a bucket that exists on this server's disk into memory, which starts its synchronization and window timers, and returns its live state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-loaded buckets just return their state. */
76
+ /** The state of ONE active bucket. Returns an error string when the bucket is not active here, which is the normal state for a bucket nothing has accessed since startup. */
77
+ export declare function debugGetActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string>;
78
+ /** Loads every store of a bucket that exists on this server's disk into memory, which starts their synchronization and window timers, and returns the bucket's state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-active buckets just return their state. */
109
79
  export declare function activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string>;
110
- export declare function listAccountBuckets(account: string): Promise<ServerBucketInfo[]>;
111
- export type BucketWriteStats = {
112
- /** Every set call the bucket accepted */
113
- originalWrites: number;
114
- originalBytes: number;
115
- /** 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). */
116
- flushedWrites: number;
117
- flushedBytes: number;
118
- };
119
- /** Zeroes the write statistics of every bucket in the account. */
120
- export declare function clearAccountWriteStats(account: string): number;
80
+ export declare function debugListAccountBuckets(account: string): Promise<ServerBucketInfo[]>;