sliftutils 1.7.87 → 1.7.89

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 (127) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/dist/treeSummary.ts.cache +2 -2
  3. package/index.d.ts +675 -296
  4. package/misc/dist/environment.ts.cache +2 -2
  5. package/misc/dist/getSecret.ts.cache +2 -2
  6. package/misc/dist/strings.ts.cache +2 -2
  7. package/misc/dist/zip.ts.cache +2 -2
  8. package/misc/https/dist/certs.ts.cache +2 -2
  9. package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
  10. package/misc/https/dist/dns.ts.cache +2 -2
  11. package/misc/https/dist/hostServer.ts.cache +49 -21
  12. package/misc/https/dist/hostsFile.ts.cache +82 -0
  13. package/misc/https/dist/httpsCerts.ts.cache +2 -2
  14. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  15. package/misc/https/hostServer.d.ts +6 -2
  16. package/misc/https/hostServer.ts +52 -19
  17. package/misc/https/hostsFile.d.ts +7 -0
  18. package/misc/https/hostsFile.ts +66 -0
  19. package/package.json +1 -1
  20. package/render-utils/dist/observer.tsx.cache +2 -2
  21. package/storage/ArchivesDisk.d.ts +3 -6
  22. package/storage/ArchivesDisk.ts +26 -2
  23. package/storage/BulkDatabase2/BulkDatabaseReader.ts +1 -1
  24. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
  25. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
  26. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
  27. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +3 -3
  28. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
  29. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
  30. package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
  31. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
  32. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
  33. package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
  34. package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
  35. package/storage/IArchives.d.ts +37 -24
  36. package/storage/IArchives.ts +47 -62
  37. package/storage/LogMap.d.ts +56 -0
  38. package/storage/LogMap.ts +220 -0
  39. package/storage/archiveHelpers.d.ts +36 -0
  40. package/storage/archiveHelpers.ts +103 -0
  41. package/storage/backblaze.d.ts +7 -12
  42. package/storage/backblaze.ts +53 -48
  43. package/storage/dist/ArchivesDisk.ts.cache +28 -2
  44. package/storage/dist/FileFolderAPI.tsx.cache +2 -2
  45. package/storage/dist/IArchives.ts.cache +16 -52
  46. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  47. package/storage/dist/JSONStorage.ts.cache +2 -2
  48. package/storage/dist/LogMap.ts.cache +212 -0
  49. package/storage/dist/PendingManager.tsx.cache +2 -2
  50. package/storage/dist/TransactionStorage.ts.cache +2 -2
  51. package/storage/dist/backblaze.ts.cache +58 -44
  52. package/storage/dist/fileSystemPointer.ts.cache +2 -2
  53. package/storage/dist/remoteFileStorage.ts.cache +2 -2
  54. package/storage/remoteStorage/ArchivesDelayed.d.ts +66 -0
  55. package/storage/remoteStorage/ArchivesDelayed.ts +237 -0
  56. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -6
  57. package/storage/remoteStorage/ArchivesRemote.ts +26 -5
  58. package/storage/remoteStorage/ArchivesUrl.d.ts +2 -5
  59. package/storage/remoteStorage/ArchivesUrl.ts +2 -2
  60. package/storage/remoteStorage/accessPage.tsx +12 -0
  61. package/storage/remoteStorage/accessStats.d.ts +1 -1
  62. package/storage/remoteStorage/accessStats.ts +3 -3
  63. package/storage/remoteStorage/blobStore.d.ts +162 -189
  64. package/storage/remoteStorage/blobStore.ts +495 -952
  65. package/storage/remoteStorage/bucketDisk.d.ts +24 -3
  66. package/storage/remoteStorage/bucketDisk.ts +56 -22
  67. package/storage/remoteStorage/certTrustModal.d.ts +1 -0
  68. package/storage/remoteStorage/certTrustModal.tsx +48 -0
  69. package/storage/remoteStorage/cliArgs.d.ts +2 -0
  70. package/storage/remoteStorage/cliArgs.ts +5 -0
  71. package/storage/remoteStorage/createArchives.d.ts +8 -7
  72. package/storage/remoteStorage/createArchives.ts +65 -26
  73. package/storage/remoteStorage/deployTakeover.ts +1 -1
  74. package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +220 -0
  75. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +25 -5
  76. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +3 -3
  77. package/storage/remoteStorage/dist/accessPage.tsx.cache +13 -3
  78. package/storage/remoteStorage/dist/accessStats.ts.cache +6 -6
  79. package/storage/remoteStorage/dist/blobStore.ts.cache +483 -965
  80. package/storage/remoteStorage/dist/bucketDisk.ts.cache +60 -29
  81. package/storage/remoteStorage/dist/certTrustModal.tsx.cache +55 -0
  82. package/storage/remoteStorage/dist/cliArgs.ts.cache +9 -4
  83. package/storage/remoteStorage/dist/createArchives.ts.cache +63 -26
  84. package/storage/remoteStorage/dist/deployTakeover.ts.cache +5 -5
  85. package/storage/remoteStorage/dist/intermediateManagement.ts.cache +286 -0
  86. package/storage/remoteStorage/dist/intermediateSources.ts.cache +2 -2
  87. package/storage/remoteStorage/dist/remoteConfig.ts.cache +122 -56
  88. package/storage/remoteStorage/dist/serverConfig.ts.cache +4 -4
  89. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +7 -11
  90. package/storage/remoteStorage/dist/sourcesList.ts.cache +3 -6
  91. package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
  92. package/storage/remoteStorage/dist/storageController.ts.cache +27 -12
  93. package/storage/remoteStorage/dist/storageServer.ts.cache +27 -7
  94. package/storage/remoteStorage/dist/storageServerCli.ts.cache +7 -3
  95. package/storage/remoteStorage/dist/storageServerState.ts.cache +133 -696
  96. package/storage/remoteStorage/dist/storeConfig.ts.cache +52 -0
  97. package/storage/remoteStorage/dist/storePlan.ts.cache +110 -81
  98. package/storage/remoteStorage/dist/storeSources.ts.cache +51 -0
  99. package/storage/remoteStorage/dist/storeSync.ts.cache +650 -0
  100. package/storage/remoteStorage/dist/validation.ts.cache +14 -4
  101. package/storage/remoteStorage/intermediateManagement.d.ts +12 -0
  102. package/storage/remoteStorage/intermediateManagement.ts +264 -0
  103. package/storage/remoteStorage/remoteConfig.d.ts +32 -0
  104. package/storage/remoteStorage/remoteConfig.ts +115 -50
  105. package/storage/remoteStorage/serverConfig.d.ts +1 -1
  106. package/storage/remoteStorage/serverConfig.ts +1 -1
  107. package/storage/remoteStorage/sourceWrapper.ts +4 -8
  108. package/storage/remoteStorage/sourcesList.d.ts +0 -2
  109. package/storage/remoteStorage/sourcesList.ts +0 -3
  110. package/storage/remoteStorage/spec.md +18 -4
  111. package/storage/remoteStorage/storageController.d.ts +10 -0
  112. package/storage/remoteStorage/storageController.ts +29 -16
  113. package/storage/remoteStorage/storageServer.d.ts +2 -0
  114. package/storage/remoteStorage/storageServer.ts +28 -4
  115. package/storage/remoteStorage/storageServerCli.ts +5 -1
  116. package/storage/remoteStorage/storageServerState.d.ts +34 -21
  117. package/storage/remoteStorage/storageServerState.ts +147 -662
  118. package/storage/remoteStorage/storeConfig.d.ts +30 -0
  119. package/storage/remoteStorage/storeConfig.ts +62 -0
  120. package/storage/remoteStorage/storePlan.d.ts +29 -17
  121. package/storage/remoteStorage/storePlan.ts +112 -85
  122. package/storage/remoteStorage/storeSources.d.ts +11 -0
  123. package/storage/remoteStorage/storeSources.ts +47 -0
  124. package/storage/remoteStorage/storeSync.d.ts +53 -0
  125. package/storage/remoteStorage/storeSync.ts +632 -0
  126. package/storage/remoteStorage/validation.d.ts +2 -0
  127. package/storage/remoteStorage/validation.ts +10 -0
@@ -1,58 +1,49 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
- import { lazy } from "socket-function/src/caching";
4
- import { sort } from "socket-function/src/misc";
5
- import { runInfinitePoll } from "socket-function/src/batching";
6
- import { ArchivesDisk } from "../ArchivesDisk";
7
- import { BlobStore, RawDiskStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MARGIN } from "./blobStore";
3
+ import { BlobStore } from "./blobStore";
8
4
  import {
9
- RemoteConfig, HostedConfig, SourceConfig, IArchives, ArchivesSource, ArchivesConfig, ArchivesSyncStatus,
10
- ArchiveFileInfo, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig, SyncActivity, FULL_ROUTE,
5
+ RemoteConfig, HostedConfig, SourceConfig, IArchives, ArchivesConfig, ArchivesSyncStatus, SyncActivity,
11
6
  } from "../IArchives";
12
- import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort, buildFileUrl, getConfigVersion, routeIntersection, normalizeSource } from "./remoteConfig";
13
- import { injectIntermediateSource, expireIntermediateSources, getIntermediateSources, nextIntermediateVersion, INTERMEDIATE_EXPIRE_GRACE } from "./intermediateSources";
14
- import { getTakeoverIntermediate } from "./deployTakeover";
15
- import { createApiArchives, listServerActiveBucketKeys } from "./createArchives";
7
+ import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, normalizeSource } from "./remoteConfig";
8
+ import { createStoreSource, applySourceConfig } from "./storeSources";
16
9
  import { broadcastRoutingChanged } from "./storageController";
17
10
  import { getStorageServerConfig, assertWritesAllowed } from "./serverConfig";
18
- import { getBucketFolder, readRoutingFile, readRoutingFromDisk, getRoutingFileResult, getDiskInfo, BucketDiskInfo } from "./bucketDisk";
19
- import { computeStorePlan, findSelfIndexes, StorePlan, StorePlanStore, SelfSummary } from "./storePlan";
20
-
21
- // The storage server's bucket lifecycle: the map of loaded buckets (each = its routing config + one BlobStore per route), keeping them current (routing reloads, window-boundary rebuilds, boundary scans), writing/propagating routing configs, deploy-switchover maintenance, and the server-level bucket listings. Store selection (sourceConfig -> store) lives here too, as findBucketStore.
11
+ import { getBucketFolder, listAccountStoreFolders, listBucketStoreFolders, readRoutingFromDisk, getDiskInfo, BucketDiskInfo } from "./bucketDisk";
12
+ import { scheduleBoundaryWork, reinjectIntermediates } from "./intermediateManagement";
13
+ import { planSelfStores, isSelfSource, SelfSummary } from "./storePlan";
14
+
15
+ /**
16
+ * What this server HAS, as opposed to what any of it does. Four things, and nothing else:
17
+ *
18
+ * - the stores, by folder (getStore) - made once, and self-configuring from there
19
+ * - which of them a request means (findBucketStore), which is only ever a name
20
+ * - what exists on disk, for listings and for the pages that report it
21
+ *
22
+ * It decides nothing about routes, windows or peers - a store reads its own config and works that
23
+ * out itself (BlobStore.applyRoutingConfig) - and it schedules nothing: switchovers and the handovers
24
+ * they cause live in intermediateManagement.
25
+ */
22
26
 
23
27
  export type LoadedStore = {
24
- // JSON of the route (FULL_ROUTE when absent) - the ONE route this store serves
25
- routeKey: string;
26
- route?: [number, number];
28
+ // The name its entries gave it (see CommonConfig.name) - its identity here, its folder on disk, and how a request finds it
29
+ name: string;
27
30
  // The exact self entries this store serves (same route, different valid windows). Empty when we are not in the config but still serve our disk data.
28
31
  entries: HostedConfig[];
29
32
  folder: string;
30
- store: IBucketStore;
33
+ store: BlobStore;
31
34
  };
32
35
 
33
- // A bucket loaded on this server: its routing config and its per-route stores. Pure data - the stores themselves do the work.
36
+ // A bucket loaded on this server: the stores it has here, and what the newest of their configs says about us. Pure data - the stores themselves do the work, including configuring themselves.
34
37
  export type BucketState = {
35
38
  account: string;
36
39
  bucketName: string;
37
- routing: RemoteConfig;
38
- routingJSON: string;
40
+ // The newest routing config among its stores, or absent when none of them has one yet
41
+ routing?: RemoteConfig;
39
42
  selfEntries: HostedConfig[];
40
43
  self: SelfSummary | undefined;
41
44
  stores: LoadedStore[];
42
- structureKey: string;
43
45
  };
44
46
 
45
- /** JSON with sorted object keys and undefined-valued keys dropped, so two configs that mean the same thing compare equal regardless of key order or which side serialized them. */
46
- function stableStringify(value: unknown): string {
47
- if (!value || typeof value !== "object") return JSON.stringify(value);
48
- if (Array.isArray(value)) {
49
- return `[${value.map(x => stableStringify(x)).join(",")}]`;
50
- }
51
- let entries = Object.entries(value as Record<string, unknown>).filter(x => x[1] !== undefined);
52
- sort(entries, x => x[0]);
53
- return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`).join(",")}}`;
54
- }
55
-
56
47
  /** 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. */
57
48
  export async function requireBucket(account: string, bucketName: string): Promise<BucketState> {
58
49
  let bucket = await getLoadedBucket(account, bucketName);
@@ -62,23 +53,23 @@ export async function requireBucket(account: string, bucketName: string): Promis
62
53
  return bucket;
63
54
  }
64
55
 
65
- /** The stable identity of a source config for matching: everything EXCEPT the valid window, and with an intermediate resolved back to the source it was split out of. The window drifts (a switchover splits one window in two around an intermediate, then rejoins it), so a caller and this server routinely disagree on it - matching on it would wrongly reject the request. An intermediate is a transient alt-port view of another source, so it must resolve to THAT source (config.intermediate holds its url + the intermediate flag is dropped), or a request naming the intermediate would fail to match once the intermediate has rejoined and its entry is gone. The route stays in the identity (it selects the store/folder). */
66
- function sourceMatchKey(config: SourceConfig): string {
67
- let url = config.intermediate || config.url;
68
- return stableStringify({ ...config, url, intermediate: undefined, validWindow: undefined });
69
- }
70
-
71
- /** The store serving a request: the config entry the CLIENT selected, matched against the bucket's own entries by identity EXCLUDING the valid window (see sourceMatchKey). The selection never validates - the store's own window/route checks throw if the caller is stale - so honoring a window mismatch here is exactly right. Throws when nothing matches, listing what is available. */
56
+ /** 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. */
72
57
  export async function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): Promise<LoadedStore> {
73
58
  let bucket = await requireBucket(account, bucketName);
74
59
  if (!sourceConfig) {
75
- throw new Error(`No remote source configuration was provided for bucket ${account}/${bucketName}: every request must say which configured source it selected. Available: ${JSON.stringify(bucket.selfEntries)}`);
60
+ throw new Error(`No remote source configuration was provided for bucket ${account}/${bucketName}: every request must say which configured source it selected. This server has: ${JSON.stringify(bucket.stores.map(x => x.name))}`);
76
61
  }
77
- let wanted = sourceMatchKey(sourceConfig);
78
- for (let s of bucket.stores) {
79
- if (s.entries.some(e => sourceMatchKey(e) === wanted)) return s;
62
+ let store = bucket.stores.find(x => x.name === sourceConfig.name);
63
+ if (store) return store;
64
+ // The loaded bucket is a snapshot of the disk from when it loaded; a store created since (a new name in a rewritten routing config) is on the disk but not in the snapshot, so a miss re-reads the disk once before it is believed
65
+ let previousNames = bucket.stores.map(x => x.name);
66
+ let reloaded = await reloadBucket(account, bucketName);
67
+ store = reloaded?.stores.find(x => x.name === sourceConfig.name);
68
+ if (store) {
69
+ console.log(`Reloaded bucket ${account}/${bucketName} to find store ${JSON.stringify(sourceConfig.name)}: its stores changed from ${JSON.stringify(previousNames)} to ${JSON.stringify(reloaded?.stores.map(x => x.name))}`);
70
+ return store;
80
71
  }
81
- throw new Error(`No source on this server matches the requested remote configuration for bucket ${account}/${bucketName}. Requested: ${JSON.stringify(sourceConfig)}. Available: ${JSON.stringify(bucket.selfEntries)}`);
72
+ throw new Error(`This server has no store named ${JSON.stringify(sourceConfig.name)} for bucket ${account}/${bucketName} (it has: ${JSON.stringify((reloaded || bucket).stores.map(x => x.name))}). Requested: ${JSON.stringify(sourceConfig)}`);
82
73
  }
83
74
 
84
75
  /** 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. */
@@ -100,8 +91,7 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
100
91
  let syncing: SyncActivity[] = [];
101
92
  let readerDiskLimit: number | undefined;
102
93
  for (let s of bucket.stores) {
103
- let progress = s.store.getSyncProgress?.();
104
- if (!progress) continue;
94
+ let progress = s.store.getSyncProgress();
105
95
  index.fileCount += progress.index.fileCount;
106
96
  index.byteCount += progress.index.byteCount;
107
97
  indexSources.push(...progress.sources);
@@ -109,8 +99,8 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
109
99
  readerDiskLimit = readerDiskLimit || progress.readerDiskLimit;
110
100
  }
111
101
  return {
112
- // Native change-feed support = the stores keep an index (rawDisk buckets emulate getChangesAfter2 with a full listing) - clients use this to pick their scan cadence
113
- supportsChangesAfter: bucket.stores.some(s => s.store instanceof BlobStore),
102
+ // Every store is index-backed, so the change feed is always native
103
+ supportsChangesAfter: true,
114
104
  remoteConfig: bucket.routing,
115
105
  index,
116
106
  indexSources,
@@ -120,17 +110,7 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
120
110
  }
121
111
 
122
112
  export async function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyncStatus> {
123
- let supported = bucket.stores.filter(s => s.store.getSyncStatus);
124
- if (!supported.length) {
125
- throw new Error(`Bucket ${bucket.account}/${bucket.bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
126
- }
127
- let statuses = await Promise.all(supported.map(async s => {
128
- let getSyncStatus = s.store.getSyncStatus;
129
- if (!getSyncStatus) {
130
- throw new Error(`getSyncStatus disappeared from a store for ${bucket.account}/${bucket.bucketName}`);
131
- }
132
- return await getSyncStatus.call(s.store);
133
- }));
113
+ let statuses = await Promise.all(bucket.stores.map(s => s.store.getSyncStatus()));
134
114
  return {
135
115
  allScansComplete: statuses.every(x => x.allScansComplete),
136
116
  indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
@@ -138,16 +118,8 @@ export async function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyn
138
118
  };
139
119
  }
140
120
 
141
- export async function bucketIndexTotals(bucket: BucketState): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } | undefined> {
142
- let supported = bucket.stores.filter(s => s.store.computeIndexTotals);
143
- if (!supported.length) return undefined;
144
- let totals = await Promise.all(supported.map(async s => {
145
- let computeIndexTotals = s.store.computeIndexTotals;
146
- if (!computeIndexTotals) {
147
- throw new Error(`computeIndexTotals disappeared from a store for ${bucket.account}/${bucket.bucketName}`);
148
- }
149
- return await computeIndexTotals.call(s.store);
150
- }));
121
+ export async function bucketIndexTotals(bucket: BucketState): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }> {
122
+ let totals = await Promise.all(bucket.stores.map(s => s.store.computeIndexTotals()));
151
123
  return {
152
124
  fileCount: totals.reduce((sum, x) => sum + x.fileCount, 0),
153
125
  byteCount: totals.reduce((sum, x) => sum + x.byteCount, 0),
@@ -156,175 +128,12 @@ export async function bucketIndexTotals(bucket: BucketState): Promise<{ fileCoun
156
128
  }
157
129
 
158
130
  const buckets = new Map<string, Promise<BucketState | undefined>>();
131
+ // The resolved ones, for the code that walks buckets rather than asking for one (see getBucketStates)
132
+ const loadedBuckets = new Map<string, BucketState>();
159
133
 
160
- const MAX_REBUILD_TIMER_DELAY = 2 ** 31 - 1;
161
- const REBUILD_BOUNDARY_BUFFER = 1000;
162
-
163
- function scheduleWindowBoundaryRebuild(loaded: BucketState): void {
164
- let now = Date.now();
165
- let boundaries = loaded.selfEntries.flatMap(x => x.validWindow).filter(t => t > now);
166
- if (!boundaries.length) return;
167
- let nextBoundary = Math.min(...boundaries);
168
- let key = `${loaded.account}/${loaded.bucketName}`;
169
- let timer = setTimeout(() => {
170
- void (async () => {
171
- let current = await buckets.get(key);
172
- if (current !== loaded) return;
173
- if (Date.now() < nextBoundary) {
174
- scheduleWindowBoundaryRebuild(loaded);
175
- return;
176
- }
177
- await scheduleRoutingReload(loaded.account, loaded.bucketName, { force: true, reason: "a valid window boundary passed" });
178
- })();
179
- }, Math.min(nextBoundary - now + REBUILD_BOUNDARY_BUFFER, MAX_REBUILD_TIMER_DELAY));
180
- (timer as { unref?: () => void }).unref?.();
181
- }
182
-
183
- const BOUNDARY_SCAN_OFFSETS = [-30 * 1000, 2 * 1000, 30 * 1000];
184
- const BOUNDARY_SCAN_LOOKBACK = DEFAULT_FAST_WRITE_DELAY + WINDOW_END_FLUSH_MARGIN;
185
- const STARTUP_BOUNDARY_SCAN_DELAY = 30 * 1000;
186
-
187
- const scheduledBoundaryScans = new Set<string>();
188
- const boundaryScansRunning = new Set<string>();
189
-
190
- function scheduleBoundaryScans(loaded: BucketState): void {
191
- let key = `${loaded.account}/${loaded.bucketName}`;
192
- let now = Date.now();
193
- let starts = new Set<number>();
194
- for (let self of loaded.selfEntries) {
195
- let start = self.validWindow[0];
196
- if (start > now && start < Number.MAX_SAFE_INTEGER) {
197
- starts.add(start);
198
- }
199
- }
200
- for (let self of loaded.selfEntries) {
201
- let [start, end] = self.validWindow;
202
- if (!(start <= now && now < end) || start <= 0) continue;
203
- if (now - start > BOUNDARY_SCAN_LOOKBACK) continue;
204
- let scheduleKey = `${key}|${start}|startup`;
205
- if (scheduledBoundaryScans.has(scheduleKey)) continue;
206
- scheduledBoundaryScans.add(scheduleKey);
207
- let timer = setTimeout(() => {
208
- void runBoundaryScan(key, start, STARTUP_BOUNDARY_SCAN_DELAY, "startup+30s").catch((e: Error) => console.error(`Boundary scan for bucket ${key} failed: ${e.stack ?? e}`));
209
- }, STARTUP_BOUNDARY_SCAN_DELAY);
210
- (timer as { unref?: () => void }).unref?.();
211
- }
212
- for (let start of starts) {
213
- for (let offset of BOUNDARY_SCAN_OFFSETS) {
214
- let at = start + offset;
215
- if (at <= now) continue;
216
- let scheduleKey = `${key}|${start}|${offset}`;
217
- if (scheduledBoundaryScans.has(scheduleKey)) continue;
218
- scheduledBoundaryScans.add(scheduleKey);
219
- let arm = () => {
220
- let timer = setTimeout(() => {
221
- if (Date.now() < at) {
222
- arm();
223
- return;
224
- }
225
- void runBoundaryScan(key, start, offset).catch((e: Error) => console.error(`Boundary scan for bucket ${key} failed: ${e.stack ?? e}`));
226
- }, Math.min(at - Date.now(), MAX_REBUILD_TIMER_DELAY));
227
- (timer as { unref?: () => void }).unref?.();
228
- };
229
- arm();
230
- }
231
- }
232
- }
233
-
234
- async function runBoundaryScan(bucketKey: string, windowStart: number, offset: number, offsetLabel?: string): Promise<void> {
235
- let label = `bucket ${bucketKey}, window starting ${new Date(windowStart).toISOString()}, offset ${offsetLabel || `${offset / 1000}s`}`;
236
- if (boundaryScansRunning.has(bucketKey)) {
237
- console.log(`Skipping boundary scan (${label}): the previous boundary scan is still running`);
238
- return;
239
- }
240
- boundaryScansRunning.add(bucketKey);
241
- try {
242
- let loaded = await buckets.get(bucketKey);
243
- if (!loaded) return;
244
- let effective = loaded.routing;
245
- let selfIndexes = findSelfIndexes(effective, loaded.account, loaded.bucketName);
246
- let selfIndexSet = new Set(selfIndexes);
247
- let selves = selfIndexes.map(i => effective.sources[i] as HostedConfig).filter(x => x.validWindow[0] === windowStart);
248
- if (!selves.length) return;
249
- let prevTime = windowStart - 1;
250
- // Everything is per-store: each self entry's route has its own store, and that store's disk/remote boundary pulls only concern its own route slice
251
- let needDiskScan = new Set<LoadedStore>();
252
- let remoteOwners = new Map<LoadedStore, Map<number, [number, number]>>();
253
- for (let self of selves) {
254
- let selfRoute = self.route || FULL_ROUTE;
255
- let selfStore = loaded.stores.find(x => x.routeKey === JSON.stringify(selfRoute));
256
- if (!selfStore || !(selfStore.store instanceof BlobStore)) continue;
257
- let idx = effective.sources.indexOf(self);
258
- let shadowed = false;
259
- for (let j = 0; j < idx; j++) {
260
- let other = effective.sources[j];
261
- if (typeof other === "string") continue;
262
- let [ws, we] = other.validWindow;
263
- if (!(ws <= windowStart && windowStart < we)) continue;
264
- let r = other.route || FULL_ROUTE;
265
- if (r[0] <= selfRoute[0] && selfRoute[1] <= r[1]) {
266
- shadowed = true;
267
- break;
268
- }
269
- }
270
- if (shadowed) continue;
271
- let uncovered: [number, number][] = [[selfRoute[0], selfRoute[1]]];
272
- for (let j = 0; j < effective.sources.length && uncovered.length; j++) {
273
- let other = effective.sources[j];
274
- if (typeof other === "string") continue;
275
- let [ws, we] = other.validWindow;
276
- if (!(ws <= prevTime && prevTime < we)) continue;
277
- let r = other.route || FULL_ROUTE;
278
- let remaining: [number, number][] = [];
279
- let claimed: [number, number] | undefined;
280
- for (let u of uncovered) {
281
- let inter = routeIntersection(u, r);
282
- if (!inter) {
283
- remaining.push(u);
284
- continue;
285
- }
286
- claimed = claimed && [Math.min(claimed[0], inter[0]), Math.max(claimed[1], inter[1])] as [number, number] || inter;
287
- if (u[0] < inter[0]) remaining.push([u[0], inter[0]]);
288
- if (inter[1] < u[1]) remaining.push([inter[1], u[1]]);
289
- }
290
- uncovered = remaining;
291
- if (!claimed) continue;
292
- if (selfIndexSet.has(j)) {
293
- needDiskScan.add(selfStore);
294
- } else {
295
- let owners = remoteOwners.get(selfStore);
296
- if (!owners) {
297
- owners = new Map();
298
- remoteOwners.set(selfStore, owners);
299
- }
300
- let existing = owners.get(j);
301
- owners.set(j, existing && [Math.min(existing[0], claimed[0]), Math.max(existing[1], claimed[1])] as [number, number] || claimed);
302
- }
303
- }
304
- }
305
- if (!needDiskScan.size && !remoteOwners.size) return;
306
- console.log(`Boundary scan (${label}): diskScans=${needDiskScan.size}, stores with remote previous-window owners: ${remoteOwners.size}`);
307
- for (let selfStore of needDiskScan) {
308
- if (selfStore.store instanceof BlobStore) {
309
- await selfStore.store.rescanBase();
310
- }
311
- }
312
- let since = windowStart - BOUNDARY_SCAN_LOOKBACK;
313
- for (let [selfStore, owners] of remoteOwners) {
314
- if (!(selfStore.store instanceof BlobStore)) continue;
315
- for (let [sourceIndex, route] of owners) {
316
- let ownerSource = effective.sources[sourceIndex];
317
- if (typeof ownerSource === "string") continue;
318
- try {
319
- await selfStore.store.boundaryScanRemote(createApiArchives(ownerSource), { since, route });
320
- } catch (e) {
321
- console.error(`Boundary scan (${label}) of previous-window owner (source index ${sourceIndex}) failed: ${(e as Error).stack ?? e}`);
322
- }
323
- }
324
- }
325
- } finally {
326
- boundaryScansRunning.delete(bucketKey);
327
- }
134
+ /** 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. */
135
+ export function getBucketStates(): BucketState[] {
136
+ return [...loadedBuckets.values()];
328
137
  }
329
138
 
330
139
  const resolvedSourceArchives = new Map<string, IArchives>();
@@ -332,205 +141,117 @@ const resolvedSourceArchives = new Map<string, IArchives>();
332
141
  export function resolveSourceArchives(url: string): IArchives {
333
142
  let existing = resolvedSourceArchives.get(url);
334
143
  if (existing) return existing;
335
- let archives: IArchives;
144
+ let sourceConfig: SourceConfig | undefined;
336
145
  if (url.startsWith("https://")) {
337
146
  // The config is fabricated from the bare URL and can never exact-match a server's entries - which is fine, because holder reads are internal reads, and internal reads never select a store (see readBucketInternal)
338
- archives = createApiArchives(normalizeSource(url));
339
- } else {
340
- archives = new ArchivesDisk(url);
147
+ sourceConfig = normalizeSource(url);
341
148
  }
149
+ let archives = createStoreSource({ sourceConfig, folder: url });
342
150
  resolvedSourceArchives.set(url, archives);
343
151
  return archives;
344
152
  }
345
153
 
346
- function sourceIdentity(sourceConfig: SourceConfig | undefined): string {
347
- if (!sourceConfig) return "disk";
348
- return JSON.stringify({ ...sourceConfig, validWindow: undefined, route: undefined });
154
+ /** What an index entry records as the holder of its bytes (see ArchivesSource.url), so it must name the endpoint FOREVER. An intermediate is a switchover's temporary alternate port onto another source, and that port is gone for good once its window passes - so it is recorded as the source it was split out of, which holds the same bucket and outlives it. */
155
+ function sourcePersistentUrl(sourceConfig: SourceConfig | undefined, folder: string): string {
156
+ if (!sourceConfig) return folder;
157
+ return sourceConfig.intermediate || sourceConfig.url;
349
158
  }
350
159
 
351
- function buildStore(account: string, bucketName: string, planStore: StorePlanStore): IBucketStore {
352
- let folder = getBucketFolder(account, bucketName, planStore.route);
353
- if (planStore.rawDisk) {
354
- return new RawDiskStore(new ArchivesDisk(folder));
355
- }
356
- let sources: ArchivesSource[] = planStore.sourceSpecs.map(spec => ({
357
- source: spec.sourceConfig && createApiArchives(spec.sourceConfig) || new ArchivesDisk(folder),
358
- url: spec.sourceConfig?.url || folder,
359
- validWindows: spec.validWindows,
360
- route: spec.route,
361
- noFullSync: spec.noFullSync,
362
- intermediate: spec.sourceConfig?.intermediate,
363
- sourceConfig: spec.sourceConfig,
364
- identity: sourceIdentity(spec.sourceConfig),
365
- }));
366
- return new BlobStore(folder, sources, {
367
- readerDiskLimit: planStore.readerDiskLimit,
160
+ /** The fast-write policy this store writes under: its own configuration decides it (fast/writeDelay are OUR settings, not the peer's), and each source then holds writes for as long as sourceWriteDelay says. */
161
+ /**
162
+ * Every store this process runs, by folder - which IS the store's identity: account, name, bucket.
163
+ * A store is made once, configures itself from the routing config it holds, and stays; nothing here
164
+ * decides what it is, only that it exists.
165
+ */
166
+ const stores = new Map<string, BlobStore>();
167
+
168
+ export function getStore(account: string, bucketName: string, name: string): BlobStore {
169
+ let folder = getBucketFolder(name, account, bucketName);
170
+ let existing = stores.get(folder);
171
+ if (existing) return existing;
172
+ let store = new BlobStore(folder, name, {
173
+ // Which entries in ITS routing config are this server's copy of it
174
+ isSelf: source => isSelfSource(source, account, bucketName),
175
+ createSource: config => createStoreSource({ sourceConfig: config.sourceConfig, folder, writeDelay: config.writeDelay }),
176
+ applySource: (source, sourceConfig, writeDelay) => applySourceConfig(source, sourceConfig, writeDelay),
368
177
  onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
369
178
  resolveSourceUrl: resolveSourceArchives,
370
- entries: planStore.entries,
371
179
  });
372
- }
373
-
374
- function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan): BucketState {
375
- if (!plan) {
376
- plan = computeStorePlan(account, bucketName, routing);
377
- }
378
- let { selfEntries, self } = plan;
379
- if (!self) {
380
- console.log(`This server is not in the routing config for bucket ${account}/${bucketName}: no longer synchronizing, valid window treated as [0, 0] (fast writes flush immediately), disk data kept and served`);
381
- }
382
- let stores: LoadedStore[] = plan.stores.map(planStore => ({
383
- routeKey: planStore.routeKey,
384
- route: planStore.route,
385
- entries: planStore.entries,
386
- folder: getBucketFolder(account, bucketName, planStore.route),
387
- store: buildStore(account, bucketName, planStore),
180
+ stores.set(folder, store);
181
+ // Lazy init would leave a store nothing has touched sitting inert - no index loaded, no config applied, nothing synchronizing - while its folder is full of files
182
+ void store.init().catch((e: Error) => console.error(`Initializing store ${JSON.stringify(name)} of bucket ${account}/${bucketName} failed: ${e.stack ?? e}`));
183
+ return store;
184
+ }
185
+
186
+ /** The bucket as this server has it: the stores whose folders exist, plus whatever the newest of their configs says about us. Names come from the DISK, not from the config - a store we hold keeps being served whether or not the current config still mentions it. */
187
+ function buildBucket(account: string, bucketName: string, names: string[], routing: RemoteConfig | undefined): BucketState {
188
+ let plan = routing && planSelfStores(account, bucketName, routing) || undefined;
189
+ let entriesByName = new Map<string, HostedConfig[]>();
190
+ for (let selfStore of plan?.stores || []) {
191
+ entriesByName.set(selfStore.name, selfStore.entries);
192
+ }
193
+ // Each store reads its own copy of the config and decides what it is - see BlobStore.applyRoutingConfig. Nothing here configures anything.
194
+ let stores: LoadedStore[] = names.map(name => ({
195
+ name,
196
+ entries: entriesByName.get(name) || [],
197
+ folder: getBucketFolder(name, account, bucketName),
198
+ store: getStore(account, bucketName, name),
388
199
  }));
389
- let loaded: BucketState = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, stores, structureKey: plan.structureKey };
390
- scheduleWindowBoundaryRebuild(loaded);
391
- scheduleBoundaryScans(loaded);
392
- // A loaded bucket must actually be running: each store's init loads its index and starts its source synchronization, and it is lazy, so without this a bucket nothing has read from or written to sits inert - reporting no data and no syncing while its disk is full of files.
393
- for (let s of stores) {
394
- if (s.store instanceof BlobStore) {
395
- let store = s.store;
396
- void store.init().catch((e: Error) => console.error(`Initializing the store for bucket ${account}/${bucketName} (route ${s.routeKey}) failed: ${e.stack ?? e}`));
397
- }
398
- }
200
+ let loaded: BucketState = { account, bucketName, routing, selfEntries: plan?.selfEntries || [], self: plan?.self, stores };
201
+ loadedBuckets.set(`${account}/${bucketName}`, loaded);
202
+ scheduleBoundaryWork(loaded);
399
203
  return loaded;
400
204
  }
401
205
 
206
+ // A bucket exists here when it has at least one store folder - the disk IS the record. Its config is the newest copy among those stores; with none at all it has no config yet, which is a perfectly good state for a store nobody has configured.
402
207
  async function loadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
208
+ let folders = await listBucketStoreFolders(account, bucketName);
209
+ if (!folders.length) return undefined;
403
210
  let routing = await readRoutingFromDisk(account, bucketName);
404
- if (!routing) return undefined;
405
- return buildBucket(account, bucketName, routing);
211
+ return buildBucket(account, bucketName, folders.map(x => x.name), routing);
406
212
  }
407
213
 
408
214
  export function getLoadedBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
409
215
  let key = `${account}/${bucketName}`;
410
- let loaded = buckets.get(key);
411
- if (!loaded) {
412
- loaded = loadBucket(account, bucketName);
413
- buckets.set(key, loaded);
414
- void loaded.then(bucket => {
415
- if (!bucket && buckets.get(key) === loaded) {
416
- buckets.delete(key);
417
- }
418
- }, () => {
419
- if (buckets.get(key) === loaded) {
420
- buckets.delete(key);
421
- }
422
- });
423
- }
424
- return loaded;
425
- }
426
-
427
- const routingReloads = new Map<string, Promise<void>>();
428
- function scheduleRoutingReload(account: string, bucketName: string, config?: { force?: boolean; reason?: string }): Promise<void> {
429
- let key = `${account}/${bucketName}`;
430
- let next = (routingReloads.get(key) || Promise.resolve())
431
- .then(() => checkRoutingChanged(account, bucketName, config))
432
- .catch(e => console.error(`Reloading routing config for bucket ${key} failed:`, e));
433
- routingReloads.set(key, next);
434
- return next;
216
+ return buckets.get(key) || reloadBucket(account, bucketName);
435
217
  }
436
218
 
437
- async function checkRoutingChanged(account: string, bucketName: string, config?: { force?: boolean; reason?: string }): Promise<void> {
219
+ /** 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. */
220
+ export async function reloadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
438
221
  let key = `${account}/${bucketName}`;
439
- let loaded = await buckets.get(key);
440
- if (!loaded) return;
441
- let routing = await readRoutingFromDisk(account, bucketName);
442
- if (!routing) return;
443
- if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
444
- let reason = config?.force && (config.reason || "forced") || "routing config changed";
445
- let plan = computeStorePlan(account, bucketName, routing);
446
- if (plan.structureKey === loaded.structureKey) {
447
- console.log(`Applying the routing config to the running stores for bucket ${key} (${reason})`);
448
- for (let planStore of plan.stores) {
449
- let live = loaded.stores.find(x => x.routeKey === planStore.routeKey);
450
- // structureKey equality means the store set (routes, rawDisk, limits) is unchanged, so every plan store has its live twin
451
- if (!live || !(live.store instanceof BlobStore)) continue;
452
- let storeFolder = live.folder;
453
- live.store.updateSources(planStore.sourceSpecs.map(spec => {
454
- const sourceConfig = spec.sourceConfig;
455
- if (!sourceConfig) {
456
- return { identity: sourceIdentity(undefined), url: storeFolder, validWindows: spec.validWindows, create: (): IArchives => new ArchivesDisk(storeFolder) };
457
- }
458
- return {
459
- identity: sourceIdentity(sourceConfig),
460
- url: sourceConfig.url,
461
- validWindows: spec.validWindows,
462
- route: spec.route,
463
- noFullSync: spec.noFullSync,
464
- intermediate: sourceConfig.intermediate,
465
- sourceConfig,
466
- create: () => createApiArchives(sourceConfig),
467
- };
468
- }), planStore.entries);
469
- live.entries = planStore.entries;
222
+ let loaded = loadBucket(account, bucketName);
223
+ buckets.set(key, loaded);
224
+ void loaded.then(bucket => {
225
+ if (!bucket && buckets.get(key) === loaded) {
226
+ buckets.delete(key);
470
227
  }
471
- loaded.routing = routing;
472
- loaded.routingJSON = JSON.stringify(routing);
473
- loaded.selfEntries = plan.selfEntries;
474
- loaded.self = plan.self;
475
- scheduleWindowBoundaryRebuild(loaded);
476
- scheduleBoundaryScans(loaded);
477
- broadcastRoutingChanged();
478
- return;
479
- }
480
- console.log(`Rebuilding the stores for bucket ${key} (${reason}): structure changed from ${loaded.structureKey} to ${plan.structureKey}`);
481
- for (let s of loaded.stores) {
482
- if (s.store instanceof BlobStore) {
483
- await s.store.dispose();
228
+ }, () => {
229
+ if (buckets.get(key) === loaded) {
230
+ buckets.delete(key);
484
231
  }
485
- }
486
- buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing, plan)));
487
- broadcastRoutingChanged();
488
- }
489
-
490
- const routingWrites = new Map<string, Promise<void>>();
491
-
492
- /** The routing-config write path - the ONE write that cannot go through a store (it is what CREATES the bucket and its stores). Serialized per bucket: concurrent config writes would race the version check. */
493
- export async function queueRoutingConfigWrite(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
232
+ });
233
+ return await loaded;
234
+ }
235
+
236
+ /**
237
+ * Writing the routing config is a write like any other: it goes into a store, and the store applies
238
+ * it to itself and lets its peers pull it. The only thing that happens here is picking WHICH store,
239
+ * because the writer names a source and a source names a store.
240
+ *
241
+ * In-flight switchover windows are re-injected first (see intermediateManagement): an operator's
242
+ * config knows nothing about a switchover that is happening right now, and writing it as-is would
243
+ * cancel it mid-flight.
244
+ */
245
+ export async function writeRoutingConfig(account: string, bucketName: string, name: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
494
246
  assertWritesAllowed();
495
- let key = `${account}/${bucketName}`;
496
- let run = (routingWrites.get(key) || Promise.resolve()).then(() => writeRoutingConfig(account, bucketName, data, config));
497
- routingWrites.set(key, run.then(() => { }, () => { }));
498
- return await run;
499
- }
500
-
501
- async function writeRoutingConfig(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
502
- let key = `${account}/${bucketName}`;
247
+ let store = getStore(account, bucketName, name);
503
248
  let incoming = parseRoutingData(data);
504
- let loaded = await getLoadedBucket(account, bucketName);
505
- if (!loaded) {
506
- await resolveSourceArchives(getBucketFolder(account, bucketName)).set(ROUTING_FILE, data, { lastModified: config?.lastModified });
507
- buckets.set(key, Promise.resolve(buildBucket(account, bucketName, incoming)));
508
- broadcastRoutingChanged();
509
- console.log(`Created bucket ${key}`);
510
- return;
511
- }
512
- if (JSON.stringify(incoming) !== loaded.routingJSON && getConfigVersion(incoming) <= getConfigVersion(loaded.routing)) {
513
- 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)}`;
514
- console.error(message);
515
- throw new Error(message);
516
- }
517
- let stored = incoming;
518
- let reinjected = 0;
519
- for (let intermediate of getIntermediateSources(expireIntermediateSources(loaded.routing, Date.now()))) {
520
- stored = injectIntermediateSource(stored, {
521
- splitUrl: intermediate.intermediate || intermediate.url,
522
- intermediateUrl: intermediate.url,
523
- start: intermediate.validWindow[0],
524
- end: intermediate.validWindow[1],
525
- });
526
- reinjected++;
527
- }
528
- if (JSON.stringify(stored) !== JSON.stringify(incoming)) {
529
- console.log(`Re-injected ${reinjected} in-flight switchover windows into the incoming routing config for bucket ${key} (version ${getConfigVersion(incoming)})`);
530
- }
531
- // Written straight into the plain bucket folder: the routing file defines the per-route stores, so it never flows through them
532
- await resolveSourceArchives(getBucketFolder(account, bucketName)).set(ROUTING_FILE, Buffer.from(serializeRemoteConfig(stored)), { lastModified: config?.lastModified });
533
- await scheduleRoutingReload(account, bucketName);
249
+ let current = (await getLoadedBucket(account, bucketName))?.routing;
250
+ let stored = reinjectIntermediates(current, incoming);
251
+ await store.set({ path: ROUTING_FILE, data: Buffer.from(serializeRemoteConfig(stored)), lastModified: config?.lastModified });
252
+ // The write may have CREATED the store (a new name gets a new folder), and the loaded bucket is a snapshot of the disk - so it is re-read, or every data write naming the new store is rejected until a restart
253
+ await reloadBucket(account, bucketName);
254
+ broadcastRoutingChanged();
534
255
  }
535
256
 
536
257
  /** 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. */
@@ -541,141 +262,6 @@ export function getActiveBucketKeys(): { account: string; bucketName: string }[]
541
262
  });
542
263
  }
543
264
 
544
- export async function rebuildAllLoadedBuckets(): Promise<void> {
545
- for (let key of [...buckets.keys()]) {
546
- let slash = key.indexOf("/");
547
- await scheduleRoutingReload(key.slice(0, slash), key.slice(slash + 1), { force: true, reason: "all loaded buckets were asked to rebuild" });
548
- }
549
- }
550
-
551
- const OWN_CONFIG_WRITE_THROTTLE = 30 * 1000;
552
- const INTERMEDIATE_MAINTAIN_INTERVAL = 60 * 1000;
553
- // 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
554
- const lastOwnConfigWrite = new Map<string, number>();
555
-
556
- async function writeOwnRoutingConfig(loaded: BucketState, updated: RemoteConfig, reason: string): Promise<void> {
557
- let key = `${loaded.account}/${loaded.bucketName}`;
558
- let sinceLast = Date.now() - (lastOwnConfigWrite.get(key) || 0);
559
- if (sinceLast < OWN_CONFIG_WRITE_THROTTLE) {
560
- 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)}`);
561
- return;
562
- }
563
- lastOwnConfigWrite.set(key, Date.now());
564
- let version = nextIntermediateVersion(getConfigVersion(loaded.routing));
565
- let next: RemoteConfig = { ...updated, version };
566
- console.log(`Writing ${ROUTING_FILE} for bucket ${key} (${reason}), version ${getConfigVersion(loaded.routing)} -> ${version}: ${JSON.stringify(next)}`);
567
- let data = Buffer.from(serializeRemoteConfig(next));
568
- await queueRoutingConfigWrite(loaded.account, loaded.bucketName, data);
569
- await propagateRoutingConfig(loaded, next, data);
570
- }
571
-
572
- async function propagateRoutingConfig(loaded: BucketState, next: RemoteConfig, data: Buffer): Promise<void> {
573
- let targets = new Map<string, SourceConfig>();
574
- for (let source of [...loaded.routing.sources, ...next.sources]) {
575
- if (typeof source === "string" || source.intermediate) continue;
576
- if (source.type === "remote") {
577
- let parsed = parseHostedUrl(source.url);
578
- if (parsed.account !== loaded.account || parsed.bucketName !== loaded.bucketName) continue;
579
- }
580
- if (targets.has(source.url)) continue;
581
- targets.set(source.url, source);
582
- }
583
- for (let [url, source] of targets) {
584
- try {
585
- await createApiArchives(source).set(ROUTING_FILE, data);
586
- console.log(`Wrote ${ROUTING_FILE} for bucket ${loaded.account}/${loaded.bucketName} to ${url}`);
587
- } catch (e) {
588
- console.error(`Propagating our routing config update to ${url} failed: ${(e as Error).stack ?? e}`);
589
- }
590
- }
591
- }
592
-
593
- async function maintainIntermediates(): Promise<void> {
594
- let { domain, port } = getStorageServerConfig();
595
- let takeover = getTakeoverIntermediate();
596
- let written: string[] = [];
597
- let alreadyCorrect: string[] = [];
598
- let notOurs: string[] = [];
599
- // 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.
600
- let keys = [...buckets.keys()];
601
- for (let key of keys) {
602
- let loadedPromise = buckets.get(key);
603
- if (!loadedPromise) continue;
604
- let loaded = await loadedPromise.catch(() => undefined);
605
- if (!loaded) continue;
606
- let expired = expireIntermediateSources(loaded.routing, Date.now());
607
- if (JSON.stringify(expired) !== JSON.stringify(loaded.routing)) {
608
- await writeOwnRoutingConfig(loaded, expired, `switchover windows expired more than ${INTERMEDIATE_EXPIRE_GRACE / 1000}s ago`);
609
- written.push(key);
610
- continue;
611
- }
612
- if (!takeover) continue;
613
- let mainUrl = loaded.routing.sources.find(x => {
614
- if (typeof x === "string" || x.type !== "remote" || x.intermediate) return false;
615
- let parsed = parseHostedUrl(x.url);
616
- return parsed.address === domain && parsed.port === port;
617
- }) as HostedConfig | undefined;
618
- if (!mainUrl) {
619
- notOurs.push(key);
620
- continue;
621
- }
622
- let injected = injectIntermediateSource(loaded.routing, {
623
- splitUrl: mainUrl.url,
624
- intermediateUrl: replaceHostedUrlPort(mainUrl.url, takeover.altPort),
625
- start: takeover.start,
626
- end: takeover.end,
627
- });
628
- if (JSON.stringify(injected) === JSON.stringify(loaded.routing)) {
629
- alreadyCorrect.push(key);
630
- continue;
631
- }
632
- 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()}`);
633
- written.push(key);
634
- }
635
- if (!takeover) return;
636
- if (!keys.length) {
637
- console.log(`No active buckets, so no config files to write the intermediate into (a bucket is only active once something uses it)`);
638
- return;
639
- }
640
- if (!written.length) {
641
- console.log(`Wrote no config files: of ${keys.length} active buckets, ${alreadyCorrect.length} already contain the intermediate and ${notOurs.length} are not hosted by us`);
642
- }
643
- }
644
-
645
- /** Our predecessor still holds the main port and has been serving all along, so it - not us - knows which buckets are in use. We activate exactly those, which is what makes their config files get the intermediate; nothing else on disk is touched. */
646
- async function activatePredecessorBuckets(): Promise<void> {
647
- let { domain, port } = getStorageServerConfig();
648
- let url = `https://${domain}:${port}`;
649
- let keys: { account: string; bucketName: string }[];
650
- try {
651
- keys = await listServerActiveBucketKeys({ url });
652
- } catch (e) {
653
- console.error(`Could not ask our predecessor on ${url} which buckets are active, so only buckets used on this process get the intermediate: ${(e as Error).stack ?? e}`);
654
- return;
655
- }
656
- if (!keys.length) {
657
- console.log(`Our predecessor on ${url} has no active buckets, so there are no config files to write the intermediate into`);
658
- return;
659
- }
660
- 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(", ")}`);
661
- for (let { account, bucketName } of keys) {
662
- try {
663
- await getLoadedBucket(account, bucketName);
664
- } catch (e) {
665
- console.error(`Activating bucket ${account}/${bucketName} (active on our predecessor) failed: ${(e as Error).stack ?? e}`);
666
- }
667
- }
668
- }
669
-
670
- /** 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. */
671
- export const startIntermediateMaintenance = lazy(() => {
672
- void (async () => {
673
- await activatePredecessorBuckets();
674
- await maintainIntermediates();
675
- })().catch((e: Error) => console.error(`Writing the intermediate into the config files failed: ${e.stack ?? e}`));
676
- runInfinitePoll(INTERMEDIATE_MAINTAIN_INTERVAL, maintainIntermediates);
677
- });
678
-
679
265
  export type ServerBucketInfo = {
680
266
  bucketName: string;
681
267
  active: boolean;
@@ -691,8 +277,8 @@ export type ServerBucketInfo = {
691
277
 
692
278
  export type ActiveBucketInfo = {
693
279
  folder: string;
694
- /** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded */
695
- routing: RemoteConfig;
280
+ /** 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. */
281
+ routing?: RemoteConfig;
696
282
  /** Our own entries in that config, and their summarized current role (routes union + flags) */
697
283
  selfEntries: HostedConfig[];
698
284
  self?: SelfSummary;
@@ -701,7 +287,8 @@ export type ActiveBucketInfo = {
701
287
 
702
288
  function toActiveBucketInfo(loaded: BucketState): ActiveBucketInfo {
703
289
  return {
704
- folder: getBucketFolder(loaded.account, loaded.bucketName),
290
+ // One of its stores' folders - a bucket no longer has a folder of its own, it has one per store
291
+ folder: loaded.stores[0]?.folder || "",
705
292
  routing: loaded.routing,
706
293
  selfEntries: loaded.selfEntries,
707
294
  self: loaded.self,
@@ -739,13 +326,11 @@ export async function activateBucket(account: string, bucketName: string): Promi
739
326
  return `Bucket ${key} failed to load on this server: ${String((e as Error).stack ?? e).slice(0, 500)}`;
740
327
  }
741
328
  if (!loaded) {
742
- return `Bucket ${key} does not exist on this server (no routing file in ${getBucketFolder(account, bucketName)})`;
329
+ return `Bucket ${key} does not exist on this server (it has no store folders under ${getBucketFolder("<name>", account, bucketName)})`;
743
330
  }
744
331
  // Wait for the indexes to load, so the totals we return are the real ones rather than zeroes from stores that have not read their index yet. The source scans keep running in the background.
745
332
  for (let s of loaded.stores) {
746
- if (s.store instanceof BlobStore) {
747
- await s.store.init();
748
- }
333
+ await s.store.init();
749
334
  }
750
335
  if (!wasLoaded) {
751
336
  console.log(`Activated bucket ${key} on request: it is now loaded and synchronizing`);
@@ -764,19 +349,18 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
764
349
  timings.set(name, (timings.get(name) || 0) + (Date.now() - began));
765
350
  }
766
351
  }
767
- let accountFolder = path.join(getStorageServerConfig().folder, "buckets2", account);
768
- let names: string[];
769
- try {
770
- names = await timed("readdir", () => fs.promises.readdir(accountFolder));
771
- } catch {
772
- return [];
352
+ // The disk is the record: a bucket exists here when it has at least one store folder, and several names for one bucket are still one bucket
353
+ let folders = await timed("readdir", () => listAccountStoreFolders(account));
354
+ let byBucket = new Map<string, string>();
355
+ for (let store of folders) {
356
+ if (byBucket.has(store.bucketName)) continue;
357
+ byBucket.set(store.bucketName, store.folder);
773
358
  }
774
- // Per-route folders (bucketName-route-<start>-<end>) belong to the same bucket as the plain folder - collapse them so one bucket lists once
775
- names = [...new Set(names.map(x => x.replace(/-route-[\d.]+-[\d.]+$/, "")))];
359
+ let names = [...byBucket.keys()];
776
360
  try {
777
361
  return await Promise.all(names.map(async bucketName => {
778
362
  let key = `${account}/${bucketName}`;
779
- let folder = getBucketFolder(account, bucketName);
363
+ let folder = byBucket.get(bucketName) || "";
780
364
  let base: ServerBucketInfo = { bucketName, active: false, folder };
781
365
  base.disk = await timed("statfs", () => getDiskInfo(folder)).catch((e: Error) => {
782
366
  base.diskError = String(e.stack ?? e).slice(0, 500);
@@ -795,11 +379,10 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
795
379
  }
796
380
  }
797
381
  try {
798
- const data = await timed("readRoutingFile", () => readRoutingFile(folder));
799
- if (!data) {
800
- return { ...base, error: `No routing file (${ROUTING_FILE})` };
382
+ const parsed = await timed("readRoutingFile", () => readRoutingFromDisk(account, bucketName));
383
+ if (!parsed) {
384
+ return { ...base, error: `No routing file (${ROUTING_FILE}) in any of its stores` };
801
385
  }
802
- let parsed = await timed("parseRouting", async () => parseRoutingData(data));
803
386
  return { ...base, config: { remoteConfig: parsed } };
804
387
  } catch (e) {
805
388
  return { ...base, error: String((e as Error).stack ?? e).slice(0, 500) };
@@ -858,101 +441,3 @@ export function clearAccountWriteStats(account: string): number {
858
441
  console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
859
442
  return cleared;
860
443
  }
861
-
862
- const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
863
-
864
- // Wraps a locally hosted bucket in the IArchives interface, so code written against IArchives (the chain, sync sources, holder reads) talks to it directly instead of becoming a network client to ourselves.
865
- class BucketIArchivesWrapper implements IArchives {
866
- constructor(private account: string, private bucketName: string, private sourceConfig: SourceConfig) { }
867
-
868
- private findStore(): Promise<LoadedStore> {
869
- return findBucketStore(this.account, this.bucketName, this.sourceConfig);
870
- }
871
-
872
- public getDebugName() {
873
- return `localBucket account ${this.account} bucket ${this.bucketName} route ${JSON.stringify(this.sourceConfig.route || FULL_ROUTE)}`;
874
- }
875
- public async get(fileName: string, config?: GetConfig): Promise<Buffer | undefined> {
876
- let result = await this.get2(fileName, config);
877
- return result && result.data || undefined;
878
- }
879
- public async get2(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
880
- if (fileName === ROUTING_FILE) {
881
- return await getRoutingFileResult(this.account, this.bucketName);
882
- }
883
- if (config?.internal) {
884
- return await readBucketInternal(this.account, this.bucketName, { path: fileName, range: config.range, includeTombstones: config.includeTombstones });
885
- }
886
- return await (await this.findStore()).store.get2({ path: fileName, ...config });
887
- }
888
- public async set(fileName: string, data: Buffer, config?: SetConfig): Promise<string> {
889
- assertWritesAllowed();
890
- if (fileName === ROUTING_FILE) {
891
- await queueRoutingConfigWrite(this.account, this.bucketName, data, config);
892
- return fileName;
893
- }
894
- await (await this.findStore()).store.set({ ...config, path: fileName, data });
895
- return fileName;
896
- }
897
- public async del(fileName: string, config?: DelConfig): Promise<void> {
898
- assertWritesAllowed();
899
- await (await this.findStore()).store.del({ path: fileName, ...config });
900
- }
901
- public async setLargeFile(config: { path: string; lastModified?: number; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
902
- assertWritesAllowed();
903
- let store = (await this.findStore()).store;
904
- let id = await store.startLargeUpload({ path: config.path, lastModified: config.lastModified });
905
- try {
906
- while (true) {
907
- let data = await config.getNextData();
908
- if (!data) break;
909
- for (let offset = 0; offset < data.length; offset += LARGE_FILE_PART_SIZE) {
910
- await store.appendLargeUpload({ id, data: data.subarray(offset, offset + LARGE_FILE_PART_SIZE) });
911
- }
912
- }
913
- await store.finishLargeUpload({ id, path: config.path, lastModified: config.lastModified });
914
- } catch (e) {
915
- await store.cancelLargeUpload({ id });
916
- throw e;
917
- }
918
- }
919
- public async getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number } | undefined> {
920
- if (fileName === ROUTING_FILE) {
921
- let result = await getRoutingFileResult(this.account, this.bucketName);
922
- return result && { writeTime: result.writeTime, size: result.size } || undefined;
923
- }
924
- return await (await this.findStore()).store.getInfo({ path: fileName, ...config });
925
- }
926
- public async findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]> {
927
- return await (await this.findStore()).store.findInfo({ prefix, ...config });
928
- }
929
- public async find(prefix: string, config?: FindConfig): Promise<string[]> {
930
- return (await this.findInfo(prefix, config)).map(x => x.path);
931
- }
932
- public async getURL(filePath: string): Promise<string> {
933
- let { domain, port } = getStorageServerConfig();
934
- return buildFileUrl(`https://${domain}:${port}/file/${encodeURIComponent(this.account)}/${encodeURIComponent(this.bucketName)}`, filePath);
935
- }
936
- public async getConfig(): Promise<ArchivesConfig> {
937
- return getBucketConfig(await requireBucket(this.account, this.bucketName));
938
- }
939
- public async hasWriteAccess(): Promise<boolean> {
940
- return true;
941
- }
942
- public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
943
- return await (await this.findStore()).store.getChangesAfter2(config);
944
- }
945
- public async getSyncStatus(): Promise<ArchivesSyncStatus> {
946
- return await bucketSyncStatus(await requireBucket(this.account, this.bucketName));
947
- }
948
- }
949
-
950
- const localArchives = new Map<string, IArchives>();
951
- export function getLocalArchives(account: string, bucketName: string, sourceConfig: SourceConfig): IArchives {
952
- let key = `${account}/${bucketName}|${stableStringify(sourceConfig)}`;
953
- let existing = localArchives.get(key);
954
- if (existing) return existing;
955
- let archives = new BucketIArchivesWrapper(account, bucketName, sourceConfig);
956
- localArchives.set(key, archives);
957
- return archives;
958
- }