sliftutils 1.7.71 → 1.7.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +506 -257
- package/misc/https/dist/hostServer.ts.cache +5 -2
- package/package.json +1 -1
- package/spec.txt +10 -0
- package/storage/ArchivesDisk.d.ts +4 -13
- package/storage/ArchivesDisk.ts +6 -4
- package/storage/IArchives.d.ts +15 -10
- package/storage/IArchives.ts +19 -6
- package/storage/backblaze.d.ts +3 -9
- package/storage/backblaze.ts +22 -19
- package/storage/dist/ArchivesDisk.ts.cache +5 -2
- package/storage/dist/IArchives.ts.cache +17 -3
- package/storage/dist/backblaze.ts.cache +35 -26
- package/storage/remoteStorage/ArchivesRemote.d.ts +5 -9
- package/storage/remoteStorage/ArchivesRemote.ts +25 -20
- package/storage/remoteStorage/ArchivesUrl.d.ts +3 -9
- package/storage/remoteStorage/ArchivesUrl.ts +7 -5
- package/storage/remoteStorage/accessPage.tsx +4 -4
- package/storage/remoteStorage/accessStats.d.ts +2 -0
- package/storage/remoteStorage/accessStats.ts +32 -0
- package/storage/remoteStorage/blobStore.d.ts +193 -88
- package/storage/remoteStorage/blobStore.ts +535 -400
- package/storage/remoteStorage/bucketDisk.d.ts +18 -0
- package/storage/remoteStorage/bucketDisk.ts +66 -0
- package/storage/remoteStorage/createArchives.d.ts +4 -10
- package/storage/remoteStorage/createArchives.ts +103 -62
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +28 -19
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +7 -4
- package/storage/remoteStorage/dist/accessPage.tsx.cache +6 -6
- package/storage/remoteStorage/dist/accessStats.ts.cache +38 -3
- package/storage/remoteStorage/dist/blobStore.ts.cache +516 -374
- package/storage/remoteStorage/dist/bucketDisk.ts.cache +72 -0
- package/storage/remoteStorage/dist/createArchives.ts.cache +114 -57
- package/storage/remoteStorage/dist/intermediateSources.ts.cache +12 -12
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +18 -2
- package/storage/remoteStorage/dist/serverConfig.ts.cache +116 -0
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +6 -5
- package/storage/remoteStorage/dist/sourcesList.ts.cache +5 -2
- package/storage/remoteStorage/dist/storageController.ts.cache +223 -190
- package/storage/remoteStorage/dist/storageServer.ts.cache +9 -9
- package/storage/remoteStorage/dist/storageServerState.ts.cache +329 -566
- package/storage/remoteStorage/dist/storePlan.ts.cache +149 -0
- package/storage/remoteStorage/dist/validation.ts.cache +40 -0
- package/storage/remoteStorage/grantAccessCli.ts +1 -1
- package/storage/remoteStorage/intermediateSources.d.ts +3 -5
- package/storage/remoteStorage/intermediateSources.ts +14 -16
- package/storage/remoteStorage/remoteConfig.d.ts +2 -2
- package/storage/remoteStorage/remoteConfig.ts +19 -3
- package/storage/remoteStorage/serverConfig.d.ts +27 -0
- package/storage/remoteStorage/serverConfig.ts +109 -0
- package/storage/remoteStorage/sourceWrapper.d.ts +3 -3
- package/storage/remoteStorage/sourceWrapper.ts +7 -6
- package/storage/remoteStorage/sourcesList.ts +3 -0
- package/storage/remoteStorage/storageController.d.ts +118 -36
- package/storage/remoteStorage/storageController.ts +166 -168
- package/storage/remoteStorage/storageServer.ts +1 -1
- package/storage/remoteStorage/storageServerState.d.ts +52 -63
- package/storage/remoteStorage/storageServerState.ts +340 -584
- package/storage/remoteStorage/storePlan.d.ts +34 -0
- package/storage/remoteStorage/storePlan.ts +166 -0
- package/storage/remoteStorage/validation.d.ts +4 -0
- package/storage/remoteStorage/validation.ts +31 -0
|
@@ -3,228 +3,158 @@ import fs from "fs";
|
|
|
3
3
|
import { lazy } from "socket-function/src/caching";
|
|
4
4
|
import { sort } from "socket-function/src/misc";
|
|
5
5
|
import { runInfinitePoll } from "socket-function/src/batching";
|
|
6
|
-
import { getFileStorageNested2 } from "../FileFolderAPI";
|
|
7
|
-
import { TransactionStorage } from "../TransactionStorage";
|
|
8
|
-
import { JSONStorage } from "../JSONStorage";
|
|
9
6
|
import { ArchivesDisk } from "../ArchivesDisk";
|
|
10
|
-
import { BlobStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MARGIN } from "./blobStore";
|
|
7
|
+
import { BlobStore, RawDiskStore, IBucketStore, DEFAULT_FAST_WRITE_DELAY, WINDOW_END_FLUSH_MARGIN } from "./blobStore";
|
|
11
8
|
import {
|
|
12
|
-
RemoteConfig, HostedConfig,
|
|
13
|
-
|
|
14
|
-
STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, FULL_ROUTE,
|
|
9
|
+
RemoteConfig, HostedConfig, SourceConfig, IArchives, ArchivesSource, ArchivesConfig, ArchivesSyncStatus,
|
|
10
|
+
ArchiveFileInfo, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, SetConfig, SyncActivity, FULL_ROUTE,
|
|
15
11
|
} from "../IArchives";
|
|
16
|
-
import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort, buildFileUrl, getConfigVersion,
|
|
12
|
+
import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, parseHostedUrl, replaceHostedUrlPort, buildFileUrl, getConfigVersion, routeIntersection, normalizeSource } from "./remoteConfig";
|
|
17
13
|
import { injectIntermediateSource, expireIntermediateSources, getIntermediateSources, findSplitUrl, nextIntermediateVersion, INTERMEDIATE_EXPIRE_GRACE } from "./intermediateSources";
|
|
18
14
|
import { getTakeoverIntermediate } from "./deployTakeover";
|
|
19
15
|
import { createApiArchives, listServerActiveBucketKeys } from "./createArchives";
|
|
20
|
-
import type { IStorage } from "../IStorage";
|
|
21
16
|
import { broadcastRoutingChanged } from "./storageController";
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
17
|
+
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.
|
|
22
|
+
|
|
23
|
+
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];
|
|
27
|
+
// 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
|
+
entries: HostedConfig[];
|
|
31
29
|
folder: string;
|
|
30
|
+
store: IBucketStore;
|
|
32
31
|
};
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return config;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let writesRejectedReason: string | undefined;
|
|
49
|
-
export function setWritesRejectedReason(reason: string | undefined): void {
|
|
50
|
-
writesRejectedReason = reason;
|
|
51
|
-
}
|
|
52
|
-
export function getWritesRejectedReason(): string | undefined {
|
|
53
|
-
return writesRejectedReason;
|
|
54
|
-
}
|
|
55
|
-
export function assertWritesAllowed(): void {
|
|
56
|
-
if (writesRejectedReason) throw new Error(writesRejectedReason);
|
|
57
|
-
}
|
|
33
|
+
// A bucket loaded on this server: its routing config and its per-route stores. Pure data - the stores themselves do the work.
|
|
34
|
+
export type BucketState = {
|
|
35
|
+
account: string;
|
|
36
|
+
bucketName: string;
|
|
37
|
+
routing: RemoteConfig;
|
|
38
|
+
routingJSON: string;
|
|
39
|
+
selfEntries: HostedConfig[];
|
|
40
|
+
self: SelfSummary | undefined;
|
|
41
|
+
stores: LoadedStore[];
|
|
42
|
+
structureKey: string;
|
|
43
|
+
};
|
|
58
44
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
throw new Error(`Storage server is not initialized and there is no --folder arg, so the storage folder is unknown`);
|
|
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(",")}]`;
|
|
65
50
|
}
|
|
66
|
-
|
|
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(",")}}`;
|
|
67
54
|
}
|
|
68
55
|
|
|
69
|
-
|
|
70
|
-
function
|
|
71
|
-
let
|
|
72
|
-
if (!
|
|
73
|
-
|
|
74
|
-
let root = await getFileStorageNested2(getStorageFolder());
|
|
75
|
-
let system = await root.folder.getStorage("system2");
|
|
76
|
-
let transactionName = "storage" + name[0].toUpperCase() + name.slice(1);
|
|
77
|
-
return new JSONStorage<unknown>(new TransactionStorage(await system.folder.getStorage(name), transactionName));
|
|
78
|
-
})();
|
|
79
|
-
systemStorages.set(name, storage);
|
|
56
|
+
/** 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
|
+
export async function requireBucket(account: string, bucketName: string): Promise<BucketState> {
|
|
58
|
+
let bucket = await getLoadedBucket(account, bucketName);
|
|
59
|
+
if (!bucket) {
|
|
60
|
+
throw new Error(`Bucket ${account}/${bucketName} does not exist on this server. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
80
61
|
}
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
export function getTrust(): Promise<IStorage<TrustRecord>> {
|
|
84
|
-
return getSystemStorage<TrustRecord>("trust");
|
|
85
|
-
}
|
|
86
|
-
export function getRequests(): Promise<IStorage<AccessRequest[]>> {
|
|
87
|
-
return getSystemStorage<AccessRequest[]>("requests");
|
|
62
|
+
return bucket;
|
|
88
63
|
}
|
|
89
64
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
flushedWrites: number;
|
|
96
|
-
flushedBytes: number;
|
|
97
|
-
};
|
|
98
|
-
function emptyWriteStats(): BucketWriteStats {
|
|
99
|
-
return { originalWrites: 0, originalBytes: 0, flushedWrites: 0, flushedBytes: 0 };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// In memory only: totals since this process started (or the last clearWriteStats). Persisting them to disk was more machinery than the numbers were worth.
|
|
103
|
-
const writeStats = new Map<string, BucketWriteStats>();
|
|
104
|
-
|
|
105
|
-
function countBucketWrite(key: string, kind: "original" | "flushed", bytes: number): void {
|
|
106
|
-
let stats = writeStats.get(key);
|
|
107
|
-
if (!stats) {
|
|
108
|
-
stats = emptyWriteStats();
|
|
109
|
-
writeStats.set(key, stats);
|
|
65
|
+
/** The store serving a request: the exact config entry the CLIENT selected, matched by equality (key order ignored) against the bucket's own entries. A match is honored even when its window has passed - the selection never validates, the store's own validation throws instead. Throws when nothing matches, listing what is available. */
|
|
66
|
+
export async function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): Promise<LoadedStore> {
|
|
67
|
+
let bucket = await requireBucket(account, bucketName);
|
|
68
|
+
if (!sourceConfig) {
|
|
69
|
+
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)}`);
|
|
110
70
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
} else {
|
|
115
|
-
stats.flushedWrites++;
|
|
116
|
-
stats.flushedBytes += bytes;
|
|
71
|
+
let wanted = stableStringify(sourceConfig);
|
|
72
|
+
for (let s of bucket.stores) {
|
|
73
|
+
if (s.entries.some(e => stableStringify(e) === wanted)) return s;
|
|
117
74
|
}
|
|
75
|
+
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)}`);
|
|
118
76
|
}
|
|
119
77
|
|
|
120
|
-
|
|
121
|
-
|
|
78
|
+
/** 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. */
|
|
79
|
+
export async function readBucketInternal(account: string, bucketName: string, config: { path: string; range?: { start: number; end: number }; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
80
|
+
let bucket = await requireBucket(account, bucketName);
|
|
81
|
+
let results = await Promise.all(bucket.stores.map(s => s.store.get2({ path: config.path, range: config.range, includeTombstones: config.includeTombstones, internal: true })));
|
|
82
|
+
let best: { data: Buffer; writeTime: number; size: number } | undefined;
|
|
83
|
+
for (let result of results) {
|
|
84
|
+
if (result && (!best || result.writeTime > best.writeTime)) {
|
|
85
|
+
best = result;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return best;
|
|
122
89
|
}
|
|
123
90
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
let
|
|
127
|
-
let
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
91
|
+
export function getBucketConfig(bucket: BucketState): ArchivesConfig {
|
|
92
|
+
let index = { fileCount: 0, byteCount: 0 };
|
|
93
|
+
let indexSources: { debugName: string; fileCount: number; byteCount: number }[] = [];
|
|
94
|
+
let syncing: SyncActivity[] = [];
|
|
95
|
+
let readerDiskLimit: number | undefined;
|
|
96
|
+
for (let s of bucket.stores) {
|
|
97
|
+
let progress = s.store.getSyncProgress?.();
|
|
98
|
+
if (!progress) continue;
|
|
99
|
+
index.fileCount += progress.index.fileCount;
|
|
100
|
+
index.byteCount += progress.index.byteCount;
|
|
101
|
+
indexSources.push(...progress.sources);
|
|
102
|
+
syncing.push(...progress.syncing);
|
|
103
|
+
readerDiskLimit = readerDiskLimit || progress.readerDiskLimit;
|
|
132
104
|
}
|
|
133
|
-
|
|
134
|
-
|
|
105
|
+
return {
|
|
106
|
+
// 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
|
|
107
|
+
supportsChangesAfter: bucket.stores.some(s => s.store instanceof BlobStore),
|
|
108
|
+
remoteConfig: bucket.routing,
|
|
109
|
+
index,
|
|
110
|
+
indexSources,
|
|
111
|
+
readerDiskLimit,
|
|
112
|
+
syncing,
|
|
113
|
+
};
|
|
135
114
|
}
|
|
136
115
|
|
|
137
|
-
export async function
|
|
138
|
-
let
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
for (let key of await trust.getKeys()) {
|
|
142
|
-
if (!key.startsWith(prefix)) continue;
|
|
143
|
-
let machineId = key.slice(prefix.length);
|
|
144
|
-
if (desired.has(machineId)) {
|
|
145
|
-
desired.delete(machineId);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
console.log(`Removing trust for machine ${machineId} on account ${config.account}`);
|
|
149
|
-
await trust.remove(key);
|
|
150
|
-
}
|
|
151
|
-
for (let machineId of desired) {
|
|
152
|
-
console.log(`Adding trust for machine ${machineId} on account ${config.account}`);
|
|
153
|
-
await trust.set(`${prefix}${machineId}`, { account: config.account, machineId, ip: "", time: Date.now() });
|
|
116
|
+
export async function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyncStatus> {
|
|
117
|
+
let supported = bucket.stores.filter(s => s.store.getSyncStatus);
|
|
118
|
+
if (!supported.length) {
|
|
119
|
+
throw new Error(`Bucket ${bucket.account}/${bucket.bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
|
|
154
120
|
}
|
|
121
|
+
let statuses = await Promise.all(supported.map(async s => {
|
|
122
|
+
let getSyncStatus = s.store.getSyncStatus;
|
|
123
|
+
if (!getSyncStatus) {
|
|
124
|
+
throw new Error(`getSyncStatus disappeared from a store for ${bucket.account}/${bucket.bucketName}`);
|
|
125
|
+
}
|
|
126
|
+
return await getSyncStatus.call(s.store);
|
|
127
|
+
}));
|
|
128
|
+
return {
|
|
129
|
+
allScansComplete: statuses.every(x => x.allScansComplete),
|
|
130
|
+
indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
|
|
131
|
+
sources: statuses.flatMap(x => x.sources),
|
|
132
|
+
};
|
|
155
133
|
}
|
|
156
134
|
|
|
157
|
-
export
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
};
|
|
135
|
+
export async function bucketIndexTotals(bucket: BucketState): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } | undefined> {
|
|
136
|
+
let supported = bucket.stores.filter(s => s.store.computeIndexTotals);
|
|
137
|
+
if (!supported.length) return undefined;
|
|
138
|
+
let totals = await Promise.all(supported.map(async s => {
|
|
139
|
+
let computeIndexTotals = s.store.computeIndexTotals;
|
|
140
|
+
if (!computeIndexTotals) {
|
|
141
|
+
throw new Error(`computeIndexTotals disappeared from a store for ${bucket.account}/${bucket.bucketName}`);
|
|
142
|
+
}
|
|
143
|
+
return await computeIndexTotals.call(s.store);
|
|
144
|
+
}));
|
|
145
|
+
return {
|
|
146
|
+
fileCount: totals.reduce((sum, x) => sum + x.fileCount, 0),
|
|
147
|
+
byteCount: totals.reduce((sum, x) => sum + x.byteCount, 0),
|
|
148
|
+
sources: totals.flatMap(x => x.sources),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
167
151
|
|
|
168
|
-
const buckets = new Map<string, Promise<
|
|
152
|
+
const buckets = new Map<string, Promise<BucketState | undefined>>();
|
|
169
153
|
|
|
170
154
|
const MAX_REBUILD_TIMER_DELAY = 2 ** 31 - 1;
|
|
171
155
|
const REBUILD_BOUNDARY_BUFFER = 1000;
|
|
172
156
|
|
|
173
|
-
function
|
|
174
|
-
return path.join(getStorageServerConfig().folder, "buckets2", account, bucketName);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const extraListenPorts = new Set<number>();
|
|
178
|
-
export function addExtraListenPort(port: number): void {
|
|
179
|
-
extraListenPorts.add(port);
|
|
180
|
-
}
|
|
181
|
-
export function removeExtraListenPort(port: number): void {
|
|
182
|
-
extraListenPorts.delete(port);
|
|
183
|
-
}
|
|
184
|
-
/** Whether address:port is this server process. The ONE self test - findSelfIndexes, createApiArchives, and SourceWrapper all consult it, so "is this me" cannot disagree between the routing plan and connection building: a URL that is us on an extra listen port must never become a network client to ourselves, which is how infinite self-request loops form. */
|
|
185
|
-
export function isOwnAddress(address: string, port: number): boolean {
|
|
186
|
-
let config = getStorageServerConfigOptional();
|
|
187
|
-
if (!config) return false;
|
|
188
|
-
if (address !== config.domain) return false;
|
|
189
|
-
return port === config.port || extraListenPorts.has(port);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function findSelfIndexes(routing: RemoteConfig, account: string, bucketName: string): number[] {
|
|
193
|
-
let indexes: number[] = [];
|
|
194
|
-
for (let i = 0; i < routing.sources.length; i++) {
|
|
195
|
-
let source = routing.sources[i];
|
|
196
|
-
if (typeof source === "string" || source.type !== "remote") continue;
|
|
197
|
-
let parsed = parseHostedUrl(source.url);
|
|
198
|
-
if (parsed.account !== account || parsed.bucketName !== bucketName) continue;
|
|
199
|
-
if (isOwnAddress(parsed.address, parsed.port)) {
|
|
200
|
-
indexes.push(i);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return indexes;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function selectEntryAt(entries: HostedConfig[], time: number, route?: number): HostedConfig | undefined {
|
|
207
|
-
if (route !== undefined) {
|
|
208
|
-
let covering = entries.filter(x => routeContains(x.route, route));
|
|
209
|
-
if (covering.length) {
|
|
210
|
-
entries = covering;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
let containing = entries.find(x => x.validWindow[0] <= time && time < x.validWindow[1]);
|
|
214
|
-
if (containing) return containing;
|
|
215
|
-
let best: HostedConfig | undefined;
|
|
216
|
-
let bestDistance = Infinity;
|
|
217
|
-
for (let entry of entries) {
|
|
218
|
-
let distance = Math.min(Math.abs(time - entry.validWindow[0]), Math.abs(time - entry.validWindow[1]));
|
|
219
|
-
if (distance < bestDistance) {
|
|
220
|
-
bestDistance = distance;
|
|
221
|
-
best = entry;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return best;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function scheduleWindowBoundaryRebuild(loaded: LoadedBucket): void {
|
|
157
|
+
function scheduleWindowBoundaryRebuild(loaded: BucketState): void {
|
|
228
158
|
let now = Date.now();
|
|
229
159
|
let boundaries = loaded.selfEntries.flatMap(x => x.validWindow).filter(t => t > now);
|
|
230
160
|
if (!boundaries.length) return;
|
|
@@ -251,7 +181,7 @@ const STARTUP_BOUNDARY_SCAN_DELAY = 30 * 1000;
|
|
|
251
181
|
const scheduledBoundaryScans = new Set<string>();
|
|
252
182
|
const boundaryScansRunning = new Set<string>();
|
|
253
183
|
|
|
254
|
-
function scheduleBoundaryScans(loaded:
|
|
184
|
+
function scheduleBoundaryScans(loaded: BucketState): void {
|
|
255
185
|
let key = `${loaded.account}/${loaded.bucketName}`;
|
|
256
186
|
let now = Date.now();
|
|
257
187
|
let starts = new Set<number>();
|
|
@@ -305,18 +235,19 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
305
235
|
try {
|
|
306
236
|
let loaded = await buckets.get(bucketKey);
|
|
307
237
|
if (!loaded) return;
|
|
308
|
-
let store = loaded.store;
|
|
309
|
-
if (!(store instanceof BlobStore)) return;
|
|
310
238
|
let effective = loaded.routing;
|
|
311
239
|
let selfIndexes = findSelfIndexes(effective, loaded.account, loaded.bucketName);
|
|
312
240
|
let selfIndexSet = new Set(selfIndexes);
|
|
313
241
|
let selves = selfIndexes.map(i => effective.sources[i] as HostedConfig).filter(x => x.validWindow[0] === windowStart);
|
|
314
242
|
if (!selves.length) return;
|
|
315
243
|
let prevTime = windowStart - 1;
|
|
316
|
-
|
|
317
|
-
let
|
|
244
|
+
// 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
|
|
245
|
+
let needDiskScan = new Set<LoadedStore>();
|
|
246
|
+
let remoteOwners = new Map<LoadedStore, Map<number, [number, number]>>();
|
|
318
247
|
for (let self of selves) {
|
|
319
248
|
let selfRoute = self.route || FULL_ROUTE;
|
|
249
|
+
let selfStore = loaded.stores.find(x => x.routeKey === JSON.stringify(selfRoute));
|
|
250
|
+
if (!selfStore || !(selfStore.store instanceof BlobStore)) continue;
|
|
320
251
|
let idx = effective.sources.indexOf(self);
|
|
321
252
|
let shadowed = false;
|
|
322
253
|
for (let j = 0; j < idx; j++) {
|
|
@@ -353,26 +284,36 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
353
284
|
uncovered = remaining;
|
|
354
285
|
if (!claimed) continue;
|
|
355
286
|
if (selfIndexSet.has(j)) {
|
|
356
|
-
needDiskScan
|
|
287
|
+
needDiskScan.add(selfStore);
|
|
357
288
|
} else {
|
|
358
|
-
let
|
|
359
|
-
|
|
289
|
+
let owners = remoteOwners.get(selfStore);
|
|
290
|
+
if (!owners) {
|
|
291
|
+
owners = new Map();
|
|
292
|
+
remoteOwners.set(selfStore, owners);
|
|
293
|
+
}
|
|
294
|
+
let existing = owners.get(j);
|
|
295
|
+
owners.set(j, existing && [Math.min(existing[0], claimed[0]), Math.max(existing[1], claimed[1])] as [number, number] || claimed);
|
|
360
296
|
}
|
|
361
297
|
}
|
|
362
298
|
}
|
|
363
|
-
if (!needDiskScan && !remoteOwners.size) return;
|
|
364
|
-
console.log(`Boundary scan (${label}):
|
|
365
|
-
|
|
366
|
-
|
|
299
|
+
if (!needDiskScan.size && !remoteOwners.size) return;
|
|
300
|
+
console.log(`Boundary scan (${label}): diskScans=${needDiskScan.size}, stores with remote previous-window owners: ${remoteOwners.size}`);
|
|
301
|
+
for (let selfStore of needDiskScan) {
|
|
302
|
+
if (selfStore.store instanceof BlobStore) {
|
|
303
|
+
await selfStore.store.rescanBase();
|
|
304
|
+
}
|
|
367
305
|
}
|
|
368
306
|
let since = windowStart - BOUNDARY_SCAN_LOOKBACK;
|
|
369
|
-
for (let [
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
307
|
+
for (let [selfStore, owners] of remoteOwners) {
|
|
308
|
+
if (!(selfStore.store instanceof BlobStore)) continue;
|
|
309
|
+
for (let [sourceIndex, route] of owners) {
|
|
310
|
+
let ownerSource = effective.sources[sourceIndex];
|
|
311
|
+
if (typeof ownerSource === "string") continue;
|
|
312
|
+
try {
|
|
313
|
+
await selfStore.store.boundaryScanRemote(createApiArchives(ownerSource), { since, route });
|
|
314
|
+
} catch (e) {
|
|
315
|
+
console.error(`Boundary scan (${label}) of previous-window owner (source index ${sourceIndex}) failed: ${(e as Error).stack ?? e}`);
|
|
316
|
+
}
|
|
376
317
|
}
|
|
377
318
|
}
|
|
378
319
|
} finally {
|
|
@@ -380,15 +321,6 @@ async function runBoundaryScan(bucketKey: string, windowStart: number, offset: n
|
|
|
380
321
|
}
|
|
381
322
|
}
|
|
382
323
|
|
|
383
|
-
type StorePlan = {
|
|
384
|
-
selfEntries: HostedConfig[];
|
|
385
|
-
self: SelfSummary | undefined;
|
|
386
|
-
rawDisk: boolean;
|
|
387
|
-
sourceSpecs: { sourceConfig?: HostedConfig | BackblazeConfig; validWindow: [number, number]; route?: [number, number]; noFullSync?: boolean }[];
|
|
388
|
-
readerDiskLimit?: number;
|
|
389
|
-
structureKey: string;
|
|
390
|
-
};
|
|
391
|
-
|
|
392
324
|
const resolvedSourceArchives = new Map<string, IArchives>();
|
|
393
325
|
/** 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. */
|
|
394
326
|
export function resolveSourceArchives(url: string): IArchives {
|
|
@@ -396,6 +328,7 @@ export function resolveSourceArchives(url: string): IArchives {
|
|
|
396
328
|
if (existing) return existing;
|
|
397
329
|
let archives: IArchives;
|
|
398
330
|
if (url.startsWith("https://")) {
|
|
331
|
+
// 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)
|
|
399
332
|
archives = createApiArchives(normalizeSource(url));
|
|
400
333
|
} else {
|
|
401
334
|
archives = new ArchivesDisk(url);
|
|
@@ -404,176 +337,68 @@ export function resolveSourceArchives(url: string): IArchives {
|
|
|
404
337
|
return archives;
|
|
405
338
|
}
|
|
406
339
|
|
|
407
|
-
function sourceIdentity(sourceConfig:
|
|
340
|
+
function sourceIdentity(sourceConfig: SourceConfig | undefined): string {
|
|
408
341
|
if (!sourceConfig) return "disk";
|
|
409
342
|
return JSON.stringify({ ...sourceConfig, validWindow: undefined, route: undefined });
|
|
410
343
|
}
|
|
411
344
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
continue;
|
|
432
|
-
}
|
|
433
|
-
merged.push(route);
|
|
434
|
-
}
|
|
435
|
-
return merged;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** anchor is the first currently-valid entry (nearest-window when none contains now, matching selectEntryAt) - the deterministic representative used only for positional things: the sync-topology cut (selfIndex) and the diskWindow seed. Everything behavioral comes from the summary, which spans every entry the anchor's group represents. */
|
|
439
|
-
function summarizeSelf(selfEntries: HostedConfig[], now: number): { summary: SelfSummary | undefined; anchor: HostedConfig | undefined } {
|
|
440
|
-
let current = selfEntries.filter(x => x.validWindow[0] <= now && now < x.validWindow[1]);
|
|
441
|
-
if (!current.length) {
|
|
442
|
-
let nearest = selectEntryAt(selfEntries, now);
|
|
443
|
-
current = nearest && [nearest] || [];
|
|
444
|
-
}
|
|
445
|
-
if (!current.length) {
|
|
446
|
-
return { summary: undefined, anchor: undefined };
|
|
447
|
-
}
|
|
448
|
-
let summary: SelfSummary = {
|
|
449
|
-
routes: mergeRoutes(current.map(x => x.route)),
|
|
450
|
-
public: current.some(x => x.public),
|
|
451
|
-
immutable: current.some(x => x.immutable),
|
|
452
|
-
noFullSync: current.some(x => x.noFullSync),
|
|
453
|
-
rawDisk: current.some(x => x.rawDisk),
|
|
454
|
-
readerDiskLimit: current.find(x => x.readerDiskLimit !== undefined)?.readerDiskLimit,
|
|
455
|
-
};
|
|
456
|
-
return { summary, anchor: current[0] };
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function computeStorePlan(account: string, bucketName: string, routing: RemoteConfig): StorePlan {
|
|
460
|
-
let selfIndexes = findSelfIndexes(routing, account, bucketName);
|
|
461
|
-
let selfEntries = selfIndexes.map(i => routing.sources[i] as HostedConfig);
|
|
462
|
-
let { summary: self, anchor } = summarizeSelf(selfEntries, Date.now());
|
|
463
|
-
let selfIndex = -1;
|
|
464
|
-
if (anchor) {
|
|
465
|
-
selfIndex = routing.sources.indexOf(anchor);
|
|
466
|
-
}
|
|
467
|
-
let diskWindow: [number, number] = [0, 0];
|
|
468
|
-
if (anchor) {
|
|
469
|
-
let [start, end] = anchor.validWindow;
|
|
470
|
-
let merged = true;
|
|
471
|
-
while (merged) {
|
|
472
|
-
merged = false;
|
|
473
|
-
for (let entry of selfEntries) {
|
|
474
|
-
let [entryStart, entryEnd] = entry.validWindow;
|
|
475
|
-
if (entryStart > end || entryEnd < start) continue;
|
|
476
|
-
if (entryStart < start || entryEnd > end) {
|
|
477
|
-
start = Math.min(start, entryStart);
|
|
478
|
-
end = Math.max(end, entryEnd);
|
|
479
|
-
merged = true;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
diskWindow = [start, end];
|
|
484
|
-
}
|
|
485
|
-
let ownIndexes = new Set(selfIndexes);
|
|
486
|
-
let sourceSpecs: StorePlan["sourceSpecs"] = [{
|
|
487
|
-
validWindow: diskWindow,
|
|
488
|
-
}];
|
|
489
|
-
if (self && selfIndex !== -1) {
|
|
490
|
-
for (let i = selfIndex + 1; i < routing.sources.length; i++) {
|
|
491
|
-
let source = routing.sources[i];
|
|
492
|
-
if (typeof source === "string" || ownIndexes.has(i)) continue;
|
|
493
|
-
// One spec per intersection segment: a peer's route can overlap several of our (disjoint) route slices, and each slice becomes its own sync-source slot (same-URL multi-slot is already the supported shape)
|
|
494
|
-
for (let selfRoute of self.routes) {
|
|
495
|
-
let sharedRoute = routeIntersection(selfRoute, source.route);
|
|
496
|
-
if (!sharedRoute) continue;
|
|
497
|
-
sourceSpecs.push({ sourceConfig: source, validWindow: source.validWindow, route: sharedRoute, noFullSync: source.noFullSync || self.noFullSync });
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
let rawDisk = !!self?.rawDisk;
|
|
502
|
-
let structureKey = JSON.stringify({
|
|
503
|
-
rawDisk,
|
|
504
|
-
readerDiskLimit: self?.readerDiskLimit,
|
|
345
|
+
function buildStore(account: string, bucketName: string, planStore: StorePlanStore): IBucketStore {
|
|
346
|
+
let folder = getBucketFolder(account, bucketName, planStore.route);
|
|
347
|
+
if (planStore.rawDisk) {
|
|
348
|
+
return new RawDiskStore(new ArchivesDisk(folder));
|
|
349
|
+
}
|
|
350
|
+
let sources: ArchivesSource[] = planStore.sourceSpecs.map(spec => ({
|
|
351
|
+
source: spec.sourceConfig && createApiArchives(spec.sourceConfig) || new ArchivesDisk(folder),
|
|
352
|
+
url: spec.sourceConfig?.url || folder,
|
|
353
|
+
validWindow: spec.validWindow,
|
|
354
|
+
route: spec.route,
|
|
355
|
+
noFullSync: spec.noFullSync,
|
|
356
|
+
intermediate: spec.sourceConfig?.intermediate,
|
|
357
|
+
identity: sourceIdentity(spec.sourceConfig),
|
|
358
|
+
}));
|
|
359
|
+
return new BlobStore(folder, sources, {
|
|
360
|
+
readerDiskLimit: planStore.readerDiskLimit,
|
|
361
|
+
onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
|
|
362
|
+
resolveSourceUrl: resolveSourceArchives,
|
|
363
|
+
entries: planStore.entries,
|
|
505
364
|
});
|
|
506
|
-
return { selfEntries, self, rawDisk, sourceSpecs, readerDiskLimit: self?.readerDiskLimit, structureKey };
|
|
507
365
|
}
|
|
508
366
|
|
|
509
|
-
function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan):
|
|
510
|
-
let folder = getBucketFolder(account, bucketName);
|
|
367
|
+
function buildBucket(account: string, bucketName: string, routing: RemoteConfig, plan?: StorePlan): BucketState {
|
|
511
368
|
if (!plan) {
|
|
512
369
|
plan = computeStorePlan(account, bucketName, routing);
|
|
513
370
|
}
|
|
514
371
|
let { selfEntries, self } = plan;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
route: spec.route,
|
|
527
|
-
noFullSync: spec.noFullSync,
|
|
528
|
-
intermediate: spec.sourceConfig?.intermediate,
|
|
529
|
-
identity: sourceIdentity(spec.sourceConfig),
|
|
530
|
-
}));
|
|
531
|
-
store = new BlobStore(folder, sources, {
|
|
532
|
-
onIndexChanged: key => {
|
|
533
|
-
if (key !== ROUTING_FILE) return;
|
|
534
|
-
void scheduleRoutingReload(account, bucketName);
|
|
535
|
-
},
|
|
536
|
-
readerDiskLimit: plan.readerDiskLimit,
|
|
537
|
-
onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
|
|
538
|
-
resolveSourceUrl: resolveSourceArchives,
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
let loaded: LoadedBucket = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, store, structureKey: plan.structureKey };
|
|
372
|
+
if (!self) {
|
|
373
|
+
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`);
|
|
374
|
+
}
|
|
375
|
+
let stores: LoadedStore[] = plan.stores.map(planStore => ({
|
|
376
|
+
routeKey: planStore.routeKey,
|
|
377
|
+
route: planStore.route,
|
|
378
|
+
entries: planStore.entries,
|
|
379
|
+
folder: getBucketFolder(account, bucketName, planStore.route),
|
|
380
|
+
store: buildStore(account, bucketName, planStore),
|
|
381
|
+
}));
|
|
382
|
+
let loaded: BucketState = { account, bucketName, routing, routingJSON: JSON.stringify(routing), selfEntries, self, stores, structureKey: plan.structureKey };
|
|
542
383
|
scheduleWindowBoundaryRebuild(loaded);
|
|
543
384
|
scheduleBoundaryScans(loaded);
|
|
544
|
-
// A loaded bucket must actually be running:
|
|
545
|
-
|
|
546
|
-
|
|
385
|
+
// 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.
|
|
386
|
+
for (let s of stores) {
|
|
387
|
+
if (s.store instanceof BlobStore) {
|
|
388
|
+
let store = s.store;
|
|
389
|
+
void store.init().catch((e: Error) => console.error(`Initializing the store for bucket ${account}/${bucketName} (route ${s.routeKey}) failed: ${e.stack ?? e}`));
|
|
390
|
+
}
|
|
547
391
|
}
|
|
548
392
|
return loaded;
|
|
549
393
|
}
|
|
550
394
|
|
|
551
|
-
|
|
552
|
-
function getRoutingFilePath(folder: string): string {
|
|
553
|
-
return path.join(folder, "files", ROUTING_FILE);
|
|
554
|
-
}
|
|
555
|
-
async function readRoutingFile(folder: string): Promise<Buffer | undefined> {
|
|
556
|
-
try {
|
|
557
|
-
return await fs.promises.readFile(getRoutingFilePath(folder));
|
|
558
|
-
} catch (e) {
|
|
559
|
-
if ((e as { code?: string }).code === "ENOENT") return undefined;
|
|
560
|
-
throw e;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
async function readRoutingFromDisk(account: string, bucketName: string): Promise<RemoteConfig | undefined> {
|
|
565
|
-
let data = await readRoutingFile(getBucketFolder(account, bucketName));
|
|
566
|
-
if (!data) return undefined;
|
|
567
|
-
return parseRoutingData(data);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
async function loadBucket(account: string, bucketName: string): Promise<LoadedBucket | undefined> {
|
|
395
|
+
async function loadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
|
|
571
396
|
let routing = await readRoutingFromDisk(account, bucketName);
|
|
572
397
|
if (!routing) return undefined;
|
|
573
398
|
return buildBucket(account, bucketName, routing);
|
|
574
399
|
}
|
|
575
400
|
|
|
576
|
-
export function getLoadedBucket(account: string, bucketName: string): Promise<
|
|
401
|
+
export function getLoadedBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
|
|
577
402
|
let key = `${account}/${bucketName}`;
|
|
578
403
|
let loaded = buckets.get(key);
|
|
579
404
|
if (!loaded) {
|
|
@@ -592,7 +417,6 @@ export function getLoadedBucket(account: string, bucketName: string): Promise<Lo
|
|
|
592
417
|
return loaded;
|
|
593
418
|
}
|
|
594
419
|
|
|
595
|
-
|
|
596
420
|
const routingReloads = new Map<string, Promise<void>>();
|
|
597
421
|
function scheduleRoutingReload(account: string, bucketName: string, config?: { force?: boolean; reason?: string }): Promise<void> {
|
|
598
422
|
let key = `${account}/${bucketName}`;
|
|
@@ -612,67 +436,66 @@ async function checkRoutingChanged(account: string, bucketName: string, config?:
|
|
|
612
436
|
if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
|
|
613
437
|
let reason = config?.force && (config.reason || "forced") || "routing config changed";
|
|
614
438
|
let plan = computeStorePlan(account, bucketName, routing);
|
|
615
|
-
if (plan.structureKey === loaded.structureKey
|
|
616
|
-
console.log(`Applying the routing config to the running
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
439
|
+
if (plan.structureKey === loaded.structureKey) {
|
|
440
|
+
console.log(`Applying the routing config to the running stores for bucket ${key} (${reason})`);
|
|
441
|
+
for (let planStore of plan.stores) {
|
|
442
|
+
let live = loaded.stores.find(x => x.routeKey === planStore.routeKey);
|
|
443
|
+
// structureKey equality means the store set (routes, rawDisk, limits) is unchanged, so every plan store has its live twin
|
|
444
|
+
if (!live || !(live.store instanceof BlobStore)) continue;
|
|
445
|
+
let storeFolder = live.folder;
|
|
446
|
+
live.store.updateSources(planStore.sourceSpecs.map(spec => {
|
|
447
|
+
const sourceConfig = spec.sourceConfig;
|
|
448
|
+
if (!sourceConfig) {
|
|
449
|
+
return { identity: sourceIdentity(undefined), url: storeFolder, validWindow: spec.validWindow, create: (): IArchives => new ArchivesDisk(storeFolder) };
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
identity: sourceIdentity(sourceConfig),
|
|
453
|
+
url: sourceConfig.url,
|
|
454
|
+
validWindow: spec.validWindow,
|
|
455
|
+
route: spec.route,
|
|
456
|
+
noFullSync: spec.noFullSync,
|
|
457
|
+
intermediate: sourceConfig.intermediate,
|
|
458
|
+
create: () => createApiArchives(sourceConfig),
|
|
459
|
+
};
|
|
460
|
+
}), planStore.entries);
|
|
461
|
+
live.entries = planStore.entries;
|
|
462
|
+
}
|
|
463
|
+
loaded.routing = routing;
|
|
464
|
+
loaded.routingJSON = JSON.stringify(routing);
|
|
465
|
+
loaded.selfEntries = plan.selfEntries;
|
|
466
|
+
loaded.self = plan.self;
|
|
467
|
+
scheduleWindowBoundaryRebuild(loaded);
|
|
468
|
+
scheduleBoundaryScans(loaded);
|
|
636
469
|
broadcastRoutingChanged();
|
|
637
470
|
return;
|
|
638
471
|
}
|
|
639
|
-
console.log(`Rebuilding the
|
|
640
|
-
|
|
641
|
-
|
|
472
|
+
console.log(`Rebuilding the stores for bucket ${key} (${reason}): structure changed from ${loaded.structureKey} to ${plan.structureKey}`);
|
|
473
|
+
for (let s of loaded.stores) {
|
|
474
|
+
if (s.store instanceof BlobStore) {
|
|
475
|
+
await s.store.dispose();
|
|
476
|
+
}
|
|
642
477
|
}
|
|
643
478
|
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, routing, plan)));
|
|
644
479
|
broadcastRoutingChanged();
|
|
645
480
|
}
|
|
646
481
|
|
|
647
|
-
|
|
648
|
-
let self = selectEntryAt(bucket.selfEntries, writeTime, route);
|
|
649
|
-
return { fast: self?.fast, writeDelay: self?.writeDelay };
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
export async function assertMutable(bucket: LoadedBucket, filePath: string, writeTime: number): Promise<void> {
|
|
653
|
-
let self = selectEntryAt(bucket.selfEntries, writeTime, getRoute(filePath));
|
|
654
|
-
if (!self?.immutable) return;
|
|
655
|
-
if (await bucket.store.getInfo(filePath)) {
|
|
656
|
-
throw new Error(`Bucket ${bucket.account}/${bucket.bucketName} is immutable (at write time ${writeTime}) and ${JSON.stringify(filePath)} already exists, so it cannot be written to`);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
482
|
+
const routingWrites = new Map<string, Promise<void>>();
|
|
659
483
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
484
|
+
/** 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. */
|
|
485
|
+
export async function queueRoutingConfigWrite(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
486
|
+
assertWritesAllowed();
|
|
487
|
+
let key = `${account}/${bucketName}`;
|
|
488
|
+
let run = (routingWrites.get(key) || Promise.resolve()).then(() => writeRoutingConfig(account, bucketName, data, config));
|
|
489
|
+
routingWrites.set(key, run.then(() => { }, () => { }));
|
|
490
|
+
return await run;
|
|
666
491
|
}
|
|
667
492
|
|
|
668
|
-
const routingWrites = new Map<string, Promise<void>>();
|
|
669
|
-
|
|
670
493
|
async function writeRoutingConfig(account: string, bucketName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
671
494
|
let key = `${account}/${bucketName}`;
|
|
672
495
|
let incoming = parseRoutingData(data);
|
|
673
496
|
let loaded = await getLoadedBucket(account, bucketName);
|
|
674
497
|
if (!loaded) {
|
|
675
|
-
await
|
|
498
|
+
await resolveSourceArchives(getBucketFolder(account, bucketName)).set(ROUTING_FILE, data, { lastModified: config?.lastModified });
|
|
676
499
|
buckets.set(key, Promise.resolve(buildBucket(account, bucketName, incoming)));
|
|
677
500
|
broadcastRoutingChanged();
|
|
678
501
|
console.log(`Created bucket ${key}`);
|
|
@@ -697,78 +520,11 @@ async function writeRoutingConfig(account: string, bucketName: string, data: Buf
|
|
|
697
520
|
if (JSON.stringify(stored) !== JSON.stringify(incoming)) {
|
|
698
521
|
console.log(`Re-injected ${reinjected} in-flight switchover windows into the incoming routing config for bucket ${key} (version ${getConfigVersion(incoming)})`);
|
|
699
522
|
}
|
|
700
|
-
|
|
523
|
+
// Written straight into the plain bucket folder: the routing file defines the per-route stores, so it never flows through them
|
|
524
|
+
await resolveSourceArchives(getBucketFolder(account, bucketName)).set(ROUTING_FILE, Buffer.from(serializeRemoteConfig(stored)), { lastModified: config?.lastModified });
|
|
701
525
|
await scheduleRoutingReload(account, bucketName);
|
|
702
526
|
}
|
|
703
527
|
|
|
704
|
-
export async function writeBucketFile(account: string, bucketName: string, filePath: string, data: Buffer, config?: { lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
|
|
705
|
-
assertWritesAllowed();
|
|
706
|
-
if (filePath === ROUTING_FILE) {
|
|
707
|
-
let key = `${account}/${bucketName}`;
|
|
708
|
-
let run = (routingWrites.get(key) || Promise.resolve()).then(() => writeRoutingConfig(account, bucketName, data, config));
|
|
709
|
-
routingWrites.set(key, run.then(() => { }, () => { }));
|
|
710
|
-
return await run;
|
|
711
|
-
}
|
|
712
|
-
let loaded = await getLoadedBucket(account, bucketName);
|
|
713
|
-
if (!loaded) {
|
|
714
|
-
throw new Error(`Bucket ${account}/${bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
715
|
-
}
|
|
716
|
-
let writeTime = config?.lastModified || Date.now();
|
|
717
|
-
let route = getRoute(filePath);
|
|
718
|
-
if (!config?.lastModified && loaded.selfEntries.length) {
|
|
719
|
-
let timeValid = loaded.selfEntries.filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
|
|
720
|
-
if (!timeValid.length) {
|
|
721
|
-
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) is outside all our valid windows ${JSON.stringify(loaded.selfEntries.map(x => x.validWindow))} (a switchover moved the write target)`);
|
|
722
|
-
throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This server is not a valid write target at ${writeTime} for bucket ${account}/${bucketName} (our valid windows: ${JSON.stringify(loaded.selfEntries.map(x => x.validWindow))}). Re-resolve the currently valid source and retry.`);
|
|
723
|
-
}
|
|
724
|
-
if (!timeValid.some(x => routeContains(x.route, route))) {
|
|
725
|
-
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}: route ${route} is outside our routes ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))} at writeTime ${writeTime} (the client's shard config is stale)`);
|
|
726
|
-
throw new Error(`${STORAGE_WRONG_ROUTE} This server does not handle route ${route} (key ${JSON.stringify(filePath)}) for bucket ${account}/${bucketName} (our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}). Re-resolve the source for this key and retry.`);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
if (config?.forceSetImmutable) {
|
|
730
|
-
if (!config.lastModified) {
|
|
731
|
-
throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}`);
|
|
732
|
-
}
|
|
733
|
-
// Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable)
|
|
734
|
-
let self = selectEntryAt(loaded.selfEntries, writeTime, route);
|
|
735
|
-
if (self?.immutable && await loaded.store.getInfo(filePath)) return;
|
|
736
|
-
} else {
|
|
737
|
-
await assertMutable(loaded, filePath, writeTime);
|
|
738
|
-
}
|
|
739
|
-
if (config?.internal) {
|
|
740
|
-
if (!config.lastModified) {
|
|
741
|
-
throw new Error(`Internal writes must carry lastModified (they are synchronization pushes, ordered by their write time), writing ${JSON.stringify(filePath)} to bucket ${account}/${bucketName}`);
|
|
742
|
-
}
|
|
743
|
-
// See SetConfig.internal: the stamp must land inside SOME window+route this server is configured for (any window, including past ones - synchronization moves old data), so a confused peer can't stuff data onto a node that was never meant to hold it
|
|
744
|
-
let covered = loaded.selfEntries.some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
|
|
745
|
-
if (!covered) {
|
|
746
|
-
throw new Error(`Internal write of ${JSON.stringify(filePath)} to bucket ${account}/${bucketName} rejected: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) at route ${route} is outside every window/route this server is configured for: ${JSON.stringify(loaded.selfEntries.map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))}`);
|
|
747
|
-
}
|
|
748
|
-
if (loaded.store.setInternal) {
|
|
749
|
-
await loaded.store.setInternal(filePath, data, { lastModified: writeTime });
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
// rawDisk stores: the normal set already IS the disk
|
|
753
|
-
await loaded.store.set(filePath, data, { lastModified: writeTime });
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
await loaded.store.set(filePath, data, { ...getWriteConfig(loaded, writeTime, route), lastModified: writeTime });
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
export function getBucketConfig(bucket: LoadedBucket): ArchivesConfig {
|
|
760
|
-
let progress = bucket.store.getSyncProgress?.();
|
|
761
|
-
return {
|
|
762
|
-
// Native change-feed support = the store keeps an index (rawDisk buckets emulate getChangesAfter2 with a full listing) - clients use this to pick their scan cadence
|
|
763
|
-
supportsChangesAfter: bucket.store instanceof BlobStore,
|
|
764
|
-
remoteConfig: bucket.routing,
|
|
765
|
-
index: progress?.index,
|
|
766
|
-
indexSources: progress?.sources,
|
|
767
|
-
readerDiskLimit: progress?.readerDiskLimit,
|
|
768
|
-
syncing: progress?.syncing,
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
|
|
772
528
|
/** 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. */
|
|
773
529
|
export function getActiveBucketKeys(): { account: string; bucketName: string }[] {
|
|
774
530
|
return [...buckets.keys()].map(key => {
|
|
@@ -789,7 +545,7 @@ const INTERMEDIATE_MAINTAIN_INTERVAL = 60 * 1000;
|
|
|
789
545
|
// 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
|
|
790
546
|
const lastOwnConfigWrite = new Map<string, number>();
|
|
791
547
|
|
|
792
|
-
async function writeOwnRoutingConfig(loaded:
|
|
548
|
+
async function writeOwnRoutingConfig(loaded: BucketState, updated: RemoteConfig, reason: string): Promise<void> {
|
|
793
549
|
let key = `${loaded.account}/${loaded.bucketName}`;
|
|
794
550
|
let sinceLast = Date.now() - (lastOwnConfigWrite.get(key) || 0);
|
|
795
551
|
if (sinceLast < OWN_CONFIG_WRITE_THROTTLE) {
|
|
@@ -801,12 +557,12 @@ async function writeOwnRoutingConfig(loaded: LoadedBucket, updated: RemoteConfig
|
|
|
801
557
|
let next: RemoteConfig = { ...updated, version };
|
|
802
558
|
console.log(`Writing ${ROUTING_FILE} for bucket ${key} (${reason}), version ${getConfigVersion(loaded.routing)} -> ${version}: ${JSON.stringify(next)}`);
|
|
803
559
|
let data = Buffer.from(serializeRemoteConfig(next));
|
|
804
|
-
await
|
|
560
|
+
await queueRoutingConfigWrite(loaded.account, loaded.bucketName, data);
|
|
805
561
|
await propagateRoutingConfig(loaded, next, data);
|
|
806
562
|
}
|
|
807
563
|
|
|
808
|
-
async function propagateRoutingConfig(loaded:
|
|
809
|
-
let targets = new Map<string,
|
|
564
|
+
async function propagateRoutingConfig(loaded: BucketState, next: RemoteConfig, data: Buffer): Promise<void> {
|
|
565
|
+
let targets = new Map<string, SourceConfig>();
|
|
810
566
|
for (let source of [...loaded.routing.sources, ...next.sources]) {
|
|
811
567
|
if (typeof source === "string" || source.intermediate) continue;
|
|
812
568
|
if (source.type === "remote") {
|
|
@@ -912,11 +668,6 @@ export const startIntermediateMaintenance = lazy(() => {
|
|
|
912
668
|
runInfinitePoll(INTERMEDIATE_MAINTAIN_INTERVAL, maintainIntermediates);
|
|
913
669
|
});
|
|
914
670
|
|
|
915
|
-
export type BucketDiskInfo = {
|
|
916
|
-
totalBytes: number;
|
|
917
|
-
freeBytes: number;
|
|
918
|
-
usedBytes: number;
|
|
919
|
-
};
|
|
920
671
|
export type ServerBucketInfo = {
|
|
921
672
|
bucketName: string;
|
|
922
673
|
active: boolean;
|
|
@@ -930,18 +681,6 @@ export type ServerBucketInfo = {
|
|
|
930
681
|
error?: string;
|
|
931
682
|
};
|
|
932
683
|
|
|
933
|
-
async function getDiskInfo(folder: string): Promise<BucketDiskInfo> {
|
|
934
|
-
let stats = await fs.promises.statfs(folder);
|
|
935
|
-
let blockSize = Number(stats.bsize);
|
|
936
|
-
let totalBytes = Number(stats.blocks) * blockSize;
|
|
937
|
-
return {
|
|
938
|
-
totalBytes,
|
|
939
|
-
// Matches the server's own low-space check: what an unprivileged process can actually use
|
|
940
|
-
freeBytes: Number(stats.bavail) * blockSize,
|
|
941
|
-
usedBytes: totalBytes - Number(stats.bfree) * blockSize,
|
|
942
|
-
};
|
|
943
|
-
}
|
|
944
|
-
|
|
945
684
|
export type ActiveBucketInfo = {
|
|
946
685
|
folder: string;
|
|
947
686
|
/** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded */
|
|
@@ -952,6 +691,16 @@ export type ActiveBucketInfo = {
|
|
|
952
691
|
config: ArchivesConfig;
|
|
953
692
|
};
|
|
954
693
|
|
|
694
|
+
function toActiveBucketInfo(loaded: BucketState): ActiveBucketInfo {
|
|
695
|
+
return {
|
|
696
|
+
folder: getBucketFolder(loaded.account, loaded.bucketName),
|
|
697
|
+
routing: loaded.routing,
|
|
698
|
+
selfEntries: loaded.selfEntries,
|
|
699
|
+
self: loaded.self,
|
|
700
|
+
config: getBucketConfig(loaded),
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
955
704
|
/** 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. */
|
|
956
705
|
export async function getActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
957
706
|
let key = `${account}/${bucketName}`;
|
|
@@ -959,7 +708,7 @@ export async function getActiveBucket(account: string, bucketName: string): Prom
|
|
|
959
708
|
if (!loadedPromise) {
|
|
960
709
|
return `Bucket ${key} is not loaded on this server, so it has no live state (it loads on first access)`;
|
|
961
710
|
}
|
|
962
|
-
let loaded:
|
|
711
|
+
let loaded: BucketState | undefined;
|
|
963
712
|
try {
|
|
964
713
|
loaded = await loadedPromise;
|
|
965
714
|
} catch (e) {
|
|
@@ -971,21 +720,11 @@ export async function getActiveBucket(account: string, bucketName: string): Prom
|
|
|
971
720
|
return toActiveBucketInfo(loaded);
|
|
972
721
|
}
|
|
973
722
|
|
|
974
|
-
function toActiveBucketInfo(loaded: LoadedBucket): ActiveBucketInfo {
|
|
975
|
-
return {
|
|
976
|
-
folder: getBucketFolder(loaded.account, loaded.bucketName),
|
|
977
|
-
routing: loaded.routing,
|
|
978
|
-
selfEntries: loaded.selfEntries,
|
|
979
|
-
self: loaded.self,
|
|
980
|
-
config: getBucketConfig(loaded),
|
|
981
|
-
};
|
|
982
|
-
}
|
|
983
|
-
|
|
984
723
|
/** 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. */
|
|
985
724
|
export async function activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
986
725
|
let key = `${account}/${bucketName}`;
|
|
987
726
|
let wasLoaded = buckets.has(key);
|
|
988
|
-
let loaded:
|
|
727
|
+
let loaded: BucketState | undefined;
|
|
989
728
|
try {
|
|
990
729
|
loaded = await getLoadedBucket(account, bucketName);
|
|
991
730
|
} catch (e) {
|
|
@@ -994,9 +733,11 @@ export async function activateBucket(account: string, bucketName: string): Promi
|
|
|
994
733
|
if (!loaded) {
|
|
995
734
|
return `Bucket ${key} does not exist on this server (no routing file in ${getBucketFolder(account, bucketName)})`;
|
|
996
735
|
}
|
|
997
|
-
// Wait for the
|
|
998
|
-
|
|
999
|
-
|
|
736
|
+
// 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.
|
|
737
|
+
for (let s of loaded.stores) {
|
|
738
|
+
if (s.store instanceof BlobStore) {
|
|
739
|
+
await s.store.init();
|
|
740
|
+
}
|
|
1000
741
|
}
|
|
1001
742
|
if (!wasLoaded) {
|
|
1002
743
|
console.log(`Activated bucket ${key} on request: it is now loaded and synchronizing`);
|
|
@@ -1022,6 +763,8 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
|
|
|
1022
763
|
} catch {
|
|
1023
764
|
return [];
|
|
1024
765
|
}
|
|
766
|
+
// Per-route folders (bucketName-route-<start>-<end>) belong to the same bucket as the plain folder - collapse them so one bucket lists once
|
|
767
|
+
names = [...new Set(names.map(x => x.replace(/-route-[\d.]+-[\d.]+$/, "")))];
|
|
1025
768
|
try {
|
|
1026
769
|
return await Promise.all(names.map(async bucketName => {
|
|
1027
770
|
let key = `${account}/${bucketName}`;
|
|
@@ -1061,99 +804,121 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
|
|
|
1061
804
|
}
|
|
1062
805
|
}
|
|
1063
806
|
|
|
1064
|
-
export
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
807
|
+
export type BucketWriteStats = {
|
|
808
|
+
/** Every set call the bucket accepted */
|
|
809
|
+
originalWrites: number;
|
|
810
|
+
originalBytes: number;
|
|
811
|
+
/** 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). */
|
|
812
|
+
flushedWrites: number;
|
|
813
|
+
flushedBytes: number;
|
|
814
|
+
};
|
|
815
|
+
function emptyWriteStats(): BucketWriteStats {
|
|
816
|
+
return { originalWrites: 0, originalBytes: 0, flushedWrites: 0, flushedBytes: 0 };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// In memory only: totals since this process started (or the last clearWriteStats). Persisting them to disk was more machinery than the numbers were worth.
|
|
820
|
+
const writeStats = new Map<string, BucketWriteStats>();
|
|
821
|
+
|
|
822
|
+
function countBucketWrite(key: string, kind: "original" | "flushed", bytes: number): void {
|
|
823
|
+
let stats = writeStats.get(key);
|
|
824
|
+
if (!stats) {
|
|
825
|
+
stats = emptyWriteStats();
|
|
826
|
+
writeStats.set(key, stats);
|
|
1068
827
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
throw new Error(`Internal deletions must carry lastModified (they are synchronization pushes, ordered by their write time), deleting ${JSON.stringify(filePath)} in bucket ${account}/${bucketName}`);
|
|
1076
|
-
}
|
|
1077
|
-
// Same acceptance rule as internal writes (see SetConfig.internal): the stamp must land inside SOME window+route this server is configured for
|
|
1078
|
-
let covered = loaded.selfEntries.some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
|
|
1079
|
-
if (!covered) {
|
|
1080
|
-
throw new Error(`Internal deletion of ${JSON.stringify(filePath)} in bucket ${account}/${bucketName} rejected: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) at route ${route} is outside every window/route this server is configured for: ${JSON.stringify(loaded.selfEntries.map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))}`);
|
|
1081
|
-
}
|
|
1082
|
-
if (loaded.store.setInternal) {
|
|
1083
|
-
// setInternal treats an empty buffer as exactly a deletion: disk removal plus a tombstone index entry, no fan-out
|
|
1084
|
-
await loaded.store.setInternal(filePath, Buffer.alloc(0), { lastModified: writeTime });
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
await loaded.store.del(filePath);
|
|
1088
|
-
return;
|
|
828
|
+
if (kind === "original") {
|
|
829
|
+
stats.originalWrites++;
|
|
830
|
+
stats.originalBytes += bytes;
|
|
831
|
+
} else {
|
|
832
|
+
stats.flushedWrites++;
|
|
833
|
+
stats.flushedBytes += bytes;
|
|
1089
834
|
}
|
|
1090
|
-
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function getBucketWriteStats(key: string): BucketWriteStats {
|
|
838
|
+
return writeStats.get(key) || emptyWriteStats();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Zeroes the write statistics of every bucket in the account. */
|
|
842
|
+
export function clearAccountWriteStats(account: string): number {
|
|
843
|
+
let prefix = `${account}/`;
|
|
844
|
+
let cleared = 0;
|
|
845
|
+
for (let key of [...writeStats.keys()]) {
|
|
846
|
+
if (!key.startsWith(prefix)) continue;
|
|
847
|
+
writeStats.delete(key);
|
|
848
|
+
cleared++;
|
|
849
|
+
}
|
|
850
|
+
console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
|
|
851
|
+
return cleared;
|
|
1091
852
|
}
|
|
1092
853
|
|
|
1093
854
|
const LARGE_FILE_PART_SIZE = 8 * 1024 * 1024;
|
|
1094
855
|
|
|
1095
|
-
|
|
1096
|
-
|
|
856
|
+
// 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.
|
|
857
|
+
class BucketIArchivesWrapper implements IArchives {
|
|
858
|
+
constructor(private account: string, private bucketName: string, private sourceConfig: SourceConfig) { }
|
|
1097
859
|
|
|
1098
|
-
private
|
|
1099
|
-
return
|
|
860
|
+
private findStore(): Promise<LoadedStore> {
|
|
861
|
+
return findBucketStore(this.account, this.bucketName, this.sourceConfig);
|
|
1100
862
|
}
|
|
1101
863
|
|
|
1102
864
|
public getDebugName() {
|
|
1103
|
-
return `localBucket account ${this.account} bucket ${this.bucketName}`;
|
|
865
|
+
return `localBucket account ${this.account} bucket ${this.bucketName} route ${JSON.stringify(this.sourceConfig.route || FULL_ROUTE)}`;
|
|
1104
866
|
}
|
|
1105
867
|
public async get(fileName: string, config?: GetConfig): Promise<Buffer | undefined> {
|
|
1106
868
|
let result = await this.get2(fileName, config);
|
|
1107
869
|
return result && result.data || undefined;
|
|
1108
870
|
}
|
|
1109
871
|
public async get2(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
if (config?.internal && bucket.store.getInternal2) {
|
|
1113
|
-
return await bucket.store.getInternal2(fileName, config);
|
|
872
|
+
if (fileName === ROUTING_FILE) {
|
|
873
|
+
return await getRoutingFileResult(this.account, this.bucketName);
|
|
1114
874
|
}
|
|
1115
|
-
|
|
875
|
+
if (config?.internal) {
|
|
876
|
+
return await readBucketInternal(this.account, this.bucketName, { path: fileName, range: config.range, includeTombstones: config.includeTombstones });
|
|
877
|
+
}
|
|
878
|
+
return await (await this.findStore()).store.get2({ path: fileName, ...config });
|
|
1116
879
|
}
|
|
1117
880
|
public async set(fileName: string, data: Buffer, config?: SetConfig): Promise<string> {
|
|
1118
|
-
|
|
881
|
+
assertWritesAllowed();
|
|
882
|
+
if (fileName === ROUTING_FILE) {
|
|
883
|
+
await queueRoutingConfigWrite(this.account, this.bucketName, data, config);
|
|
884
|
+
return fileName;
|
|
885
|
+
}
|
|
886
|
+
await (await this.findStore()).store.set({ ...config, path: fileName, data });
|
|
1119
887
|
return fileName;
|
|
1120
888
|
}
|
|
1121
889
|
public async del(fileName: string, config?: DelConfig): Promise<void> {
|
|
1122
|
-
|
|
890
|
+
assertWritesAllowed();
|
|
891
|
+
await (await this.findStore()).store.del({ path: fileName, ...config });
|
|
1123
892
|
}
|
|
1124
893
|
public async setLargeFile(config: { path: string; lastModified?: number; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
|
|
1125
894
|
assertWritesAllowed();
|
|
1126
|
-
let
|
|
1127
|
-
|
|
1128
|
-
throw new Error(`Bucket ${this.account}/${this.bucketName} does not exist. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
1129
|
-
}
|
|
1130
|
-
await assertMutable(bucket, config.path, config.lastModified || Date.now());
|
|
1131
|
-
let id = await bucket.store.startLargeUpload();
|
|
895
|
+
let store = (await this.findStore()).store;
|
|
896
|
+
let id = await store.startLargeUpload({ path: config.path, lastModified: config.lastModified });
|
|
1132
897
|
try {
|
|
1133
898
|
while (true) {
|
|
1134
899
|
let data = await config.getNextData();
|
|
1135
900
|
if (!data) break;
|
|
1136
901
|
for (let offset = 0; offset < data.length; offset += LARGE_FILE_PART_SIZE) {
|
|
1137
|
-
await
|
|
902
|
+
await store.appendLargeUpload({ id, data: data.subarray(offset, offset + LARGE_FILE_PART_SIZE) });
|
|
1138
903
|
}
|
|
1139
904
|
}
|
|
1140
|
-
await
|
|
905
|
+
await store.finishLargeUpload({ id, path: config.path, lastModified: config.lastModified });
|
|
1141
906
|
} catch (e) {
|
|
1142
|
-
await
|
|
907
|
+
await store.cancelLargeUpload({ id });
|
|
1143
908
|
throw e;
|
|
1144
909
|
}
|
|
1145
910
|
}
|
|
1146
911
|
public async getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number } | undefined> {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
912
|
+
if (fileName === ROUTING_FILE) {
|
|
913
|
+
let result = await getRoutingFileResult(this.account, this.bucketName);
|
|
914
|
+
return result && { writeTime: result.writeTime, size: result.size } || undefined;
|
|
915
|
+
}
|
|
916
|
+
return await (await this.findStore()).store.getInfo({ path: fileName, ...config });
|
|
1150
917
|
}
|
|
1151
|
-
public async findInfo(prefix: string, config?:
|
|
1152
|
-
|
|
1153
|
-
if (!bucket) return [];
|
|
1154
|
-
return await bucket.store.findInfo(prefix, config);
|
|
918
|
+
public async findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]> {
|
|
919
|
+
return await (await this.findStore()).store.findInfo({ prefix, ...config });
|
|
1155
920
|
}
|
|
1156
|
-
public async find(prefix: string, config?:
|
|
921
|
+
public async find(prefix: string, config?: FindConfig): Promise<string[]> {
|
|
1157
922
|
return (await this.findInfo(prefix, config)).map(x => x.path);
|
|
1158
923
|
}
|
|
1159
924
|
public async getURL(filePath: string): Promise<string> {
|
|
@@ -1161,34 +926,25 @@ class ArchivesLocalBucket implements IArchives {
|
|
|
1161
926
|
return buildFileUrl(`https://${domain}:${port}/file/${encodeURIComponent(this.account)}/${encodeURIComponent(this.bucketName)}`, filePath);
|
|
1162
927
|
}
|
|
1163
928
|
public async getConfig(): Promise<ArchivesConfig> {
|
|
1164
|
-
|
|
1165
|
-
if (!bucket) return { supportsChangesAfter: true };
|
|
1166
|
-
return getBucketConfig(bucket);
|
|
929
|
+
return getBucketConfig(await requireBucket(this.account, this.bucketName));
|
|
1167
930
|
}
|
|
1168
931
|
public async hasWriteAccess(): Promise<boolean> {
|
|
1169
932
|
return true;
|
|
1170
933
|
}
|
|
1171
934
|
public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
1172
|
-
|
|
1173
|
-
if (!bucket) return [];
|
|
1174
|
-
return await bucket.store.getChangesAfter2(config);
|
|
935
|
+
return await (await this.findStore()).store.getChangesAfter2(config);
|
|
1175
936
|
}
|
|
1176
937
|
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
1177
|
-
|
|
1178
|
-
if (!bucket) return { allScansComplete: true, indexSize: 0, sources: [] };
|
|
1179
|
-
if (!bucket.store.getSyncStatus) {
|
|
1180
|
-
throw new Error(`Bucket ${this.account}/${this.bucketName} does not support getSyncStatus (rawDisk buckets have no synchronization)`);
|
|
1181
|
-
}
|
|
1182
|
-
return await bucket.store.getSyncStatus();
|
|
938
|
+
return await bucketSyncStatus(await requireBucket(this.account, this.bucketName));
|
|
1183
939
|
}
|
|
1184
940
|
}
|
|
1185
941
|
|
|
1186
942
|
const localArchives = new Map<string, IArchives>();
|
|
1187
|
-
export function getLocalArchives(account: string, bucketName: string): IArchives {
|
|
1188
|
-
let key = `${account}/${bucketName}`;
|
|
943
|
+
export function getLocalArchives(account: string, bucketName: string, sourceConfig: SourceConfig): IArchives {
|
|
944
|
+
let key = `${account}/${bucketName}|${stableStringify(sourceConfig)}`;
|
|
1189
945
|
let existing = localArchives.get(key);
|
|
1190
946
|
if (existing) return existing;
|
|
1191
|
-
let archives = new
|
|
947
|
+
let archives = new BucketIArchivesWrapper(account, bucketName, sourceConfig);
|
|
1192
948
|
localArchives.set(key, archives);
|
|
1193
949
|
return archives;
|
|
1194
950
|
}
|