sliftutils 1.7.89 → 1.7.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +36 -81
- package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
- package/misc/https/dist/dns.ts.cache +2 -2
- package/package.json +1 -1
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
- package/storage/dist/ArchivesDisk.ts.cache +2 -2
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/archiveHelpers.ts.cache +87 -0
- package/storage/dist/backblaze.ts.cache +2 -2
- package/storage/remoteStorage/accessStats.d.ts +12 -0
- package/storage/remoteStorage/accessStats.ts +47 -0
- package/storage/remoteStorage/blobStore.d.ts +3 -1
- package/storage/remoteStorage/blobStore.ts +5 -0
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +2 -2
- package/storage/remoteStorage/dist/accessStats.ts.cache +42 -3
- package/storage/remoteStorage/dist/blobStore.ts.cache +7 -4
- package/storage/remoteStorage/dist/createArchives.ts.cache +2 -2
- package/storage/remoteStorage/dist/intermediateManagement.ts.cache +56 -50
- package/storage/remoteStorage/dist/storageController.ts.cache +47 -58
- package/storage/remoteStorage/dist/storageServerState.ts.cache +119 -248
- package/storage/remoteStorage/dist/storeConfig.ts.cache +2 -2
- package/storage/remoteStorage/dist/storePlan.ts.cache +4 -56
- package/storage/remoteStorage/dist/storeSync.ts.cache +6 -5
- package/storage/remoteStorage/intermediateManagement.d.ts +2 -3
- package/storage/remoteStorage/intermediateManagement.ts +53 -46
- package/storage/remoteStorage/storageController.ts +35 -44
- package/storage/remoteStorage/storageServerState.d.ts +17 -57
- package/storage/remoteStorage/storageServerState.ts +112 -279
- package/storage/remoteStorage/storeConfig.d.ts +2 -0
- package/storage/remoteStorage/storeConfig.ts +2 -0
- package/storage/remoteStorage/storePlan.d.ts +0 -20
- package/storage/remoteStorage/storePlan.ts +1 -69
|
@@ -1,81 +1,89 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import fs from "fs";
|
|
3
1
|
import { BlobStore } from "./blobStore";
|
|
4
2
|
import {
|
|
5
|
-
RemoteConfig,
|
|
3
|
+
RemoteConfig, SourceConfig, IArchives, ArchivesConfig, ArchivesSyncStatus, SyncActivity,
|
|
6
4
|
} from "../IArchives";
|
|
7
5
|
import { ROUTING_FILE, parseRoutingData, serializeRemoteConfig, normalizeSource } from "./remoteConfig";
|
|
8
6
|
import { createStoreSource, applySourceConfig } from "./storeSources";
|
|
9
7
|
import { broadcastRoutingChanged } from "./storageController";
|
|
10
|
-
import {
|
|
8
|
+
import { assertWritesAllowed } from "./serverConfig";
|
|
9
|
+
import { assertValidSourceName } from "./validation";
|
|
11
10
|
import { getBucketFolder, listAccountStoreFolders, listBucketStoreFolders, readRoutingFromDisk, getDiskInfo, BucketDiskInfo } from "./bucketDisk";
|
|
12
11
|
import { scheduleBoundaryWork, reinjectIntermediates } from "./intermediateManagement";
|
|
13
|
-
import {
|
|
12
|
+
import { isSelfSource } from "./storePlan";
|
|
13
|
+
import { countBucketWrite, getBucketWriteStats, BucketWriteStats } from "./accessStats";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* What this server HAS
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
16
|
+
* What this server HAS: the stores, by name (getStore) - made once, self-configuring from there -
|
|
17
|
+
* and the three entry points a request resolves through (findBucketStore, readBucketInternal,
|
|
18
|
+
* writeRoutingConfig). There is no cached bucket state: a bucket is nothing but the stores whose
|
|
19
|
+
* folders share its name, listed off the disk when something genuinely needs all of them.
|
|
25
20
|
*/
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
37
|
-
export type BucketState = {
|
|
38
|
-
account: string;
|
|
39
|
-
bucketName: string;
|
|
40
|
-
// The newest routing config among its stores, or absent when none of them has one yet
|
|
41
|
-
routing?: RemoteConfig;
|
|
42
|
-
selfEntries: HostedConfig[];
|
|
43
|
-
self: SelfSummary | undefined;
|
|
44
|
-
stores: LoadedStore[];
|
|
45
|
-
};
|
|
22
|
+
/**
|
|
23
|
+
* Every store this process runs, by folder - which IS the store's identity: account, name, bucket.
|
|
24
|
+
* A store is made once, configures itself from the routing config it holds, and stays; nothing here
|
|
25
|
+
* decides what it is, only that it exists.
|
|
26
|
+
*/
|
|
27
|
+
const stores = new Map<string, BlobStore>();
|
|
28
|
+
// The opened stores of each `${account}/${bucketName}`, by name - which buckets are ACTIVE (something touched them), for deploy handoffs and the debug pages. Purely a registry of what getStore already made.
|
|
29
|
+
const activeStores = new Map<string, Map<string, BlobStore>>();
|
|
46
30
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
31
|
+
export function getStore(account: string, bucketName: string, name: string): BlobStore {
|
|
32
|
+
// The one place a name becomes a folder, so the one place it is validated (it can come straight off the wire)
|
|
33
|
+
assertValidSourceName(name);
|
|
34
|
+
let folder = getBucketFolder(name, account, bucketName);
|
|
35
|
+
let existing = stores.get(folder);
|
|
36
|
+
if (existing) return existing;
|
|
37
|
+
console.log(`Opening store ${JSON.stringify(name)} of bucket ${account}/${bucketName} (folder ${folder}) - created on this first use if it never existed`);
|
|
38
|
+
let store = new BlobStore(folder, name, {
|
|
39
|
+
// Which entries in ITS routing config are this server's copy of it
|
|
40
|
+
isSelf: source => isSelfSource(source, account, bucketName),
|
|
41
|
+
createSource: config => createStoreSource({ sourceConfig: config.sourceConfig, folder, writeDelay: config.writeDelay }),
|
|
42
|
+
applySource: (source, sourceConfig, writeDelay) => applySourceConfig(source, sourceConfig, writeDelay),
|
|
43
|
+
onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
|
|
44
|
+
resolveSourceUrl: resolveSourceArchives,
|
|
45
|
+
// The store is the one that knows when a config landed, and a config with upcoming windows is what boundary scans are armed from
|
|
46
|
+
onRoutingApplied: routing => scheduleBoundaryWork(account, bucketName, routing),
|
|
47
|
+
});
|
|
48
|
+
stores.set(folder, store);
|
|
49
|
+
let key = `${account}/${bucketName}`;
|
|
50
|
+
let byName = activeStores.get(key);
|
|
51
|
+
if (!byName) {
|
|
52
|
+
byName = new Map();
|
|
53
|
+
activeStores.set(key, byName);
|
|
52
54
|
}
|
|
53
|
-
|
|
55
|
+
byName.set(name, store);
|
|
56
|
+
// 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
|
|
57
|
+
void store.init().catch((e: Error) => console.error(`Initializing store ${JSON.stringify(name)} of bucket ${account}/${bucketName} failed: ${e.stack ?? e}`));
|
|
58
|
+
return store;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
/** The store serving a request: the one the client's selected entry NAMES.
|
|
57
|
-
export
|
|
58
|
-
let bucket = await requireBucket(account, bucketName);
|
|
61
|
+
/** The store serving a request: the one the client's selected entry NAMES. Account, name, and bucket ARE the folder, so this is a direct lookup, never a search of what exists - and a name this server has never seen is CREATED, never rejected, because asking for a name is the instruction to have that store (one name, one folder, one index; it configures itself once the routing config lands in it). Nothing else about the request is compared - not the window, not the route, not the flags - which is the whole point of naming it: a client a config version behind on some flag still reaches the right store. */
|
|
62
|
+
export function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): BlobStore {
|
|
59
63
|
if (!sourceConfig) {
|
|
60
|
-
throw new Error(`No remote source configuration was provided for bucket ${account}/${bucketName}: every request must say which configured source it selected
|
|
64
|
+
throw new Error(`No remote source configuration was provided for bucket ${account}/${bucketName}: every request must say which configured source it selected`);
|
|
61
65
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
return getStore(account, bucketName, sourceConfig.name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The stores of a bucket as the DISK records them (a bucket is nothing more than the store folders sharing its name), opened - so the ones that weren't running yet start synchronizing. Empty when the bucket does not exist here. */
|
|
70
|
+
export async function getBucketStores(account: string, bucketName: string): Promise<{ name: string; store: BlobStore }[]> {
|
|
71
|
+
let folders = await listBucketStoreFolders(account, bucketName);
|
|
72
|
+
return folders.map(x => ({ name: x.name, store: getStore(account, bucketName, x.name) }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function requireBucketStores(account: string, bucketName: string): Promise<{ name: string; store: BlobStore }[]> {
|
|
76
|
+
let list = await getBucketStores(account, bucketName);
|
|
77
|
+
if (!list.length) {
|
|
78
|
+
throw new Error(`Bucket ${account}/${bucketName} does not exist on this server. Write its routing config to ${JSON.stringify(ROUTING_FILE)} to create it.`);
|
|
71
79
|
}
|
|
72
|
-
|
|
80
|
+
return list;
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
/** 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. */
|
|
76
84
|
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> {
|
|
77
|
-
let
|
|
78
|
-
let results = await Promise.all(
|
|
85
|
+
let bucketStores = await requireBucketStores(account, bucketName);
|
|
86
|
+
let results = await Promise.all(bucketStores.map(s => s.store.get2({ path: config.path, range: config.range, includeTombstones: config.includeTombstones, internal: true })));
|
|
79
87
|
let best: { data: Buffer; writeTime: number; size: number } | undefined;
|
|
80
88
|
for (let result of results) {
|
|
81
89
|
if (result && (!best || result.writeTime > best.writeTime)) {
|
|
@@ -85,13 +93,13 @@ export async function readBucketInternal(account: string, bucketName: string, co
|
|
|
85
93
|
return best;
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
function aggregateArchivesConfig(bucketStores: BlobStore[], routing: RemoteConfig | undefined): ArchivesConfig {
|
|
89
97
|
let index = { fileCount: 0, byteCount: 0 };
|
|
90
98
|
let indexSources: { debugName: string; fileCount: number; byteCount: number }[] = [];
|
|
91
99
|
let syncing: SyncActivity[] = [];
|
|
92
100
|
let readerDiskLimit: number | undefined;
|
|
93
|
-
for (let
|
|
94
|
-
let progress =
|
|
101
|
+
for (let store of bucketStores) {
|
|
102
|
+
let progress = store.getSyncProgress();
|
|
95
103
|
index.fileCount += progress.index.fileCount;
|
|
96
104
|
index.byteCount += progress.index.byteCount;
|
|
97
105
|
indexSources.push(...progress.sources);
|
|
@@ -101,7 +109,7 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
|
|
|
101
109
|
return {
|
|
102
110
|
// Every store is index-backed, so the change feed is always native
|
|
103
111
|
supportsChangesAfter: true,
|
|
104
|
-
remoteConfig:
|
|
112
|
+
remoteConfig: routing,
|
|
105
113
|
index,
|
|
106
114
|
indexSources,
|
|
107
115
|
readerDiskLimit,
|
|
@@ -109,8 +117,15 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
|
|
|
109
117
|
};
|
|
110
118
|
}
|
|
111
119
|
|
|
112
|
-
export async function
|
|
113
|
-
let
|
|
120
|
+
export async function getBucketArchivesConfig(account: string, bucketName: string): Promise<ArchivesConfig> {
|
|
121
|
+
let bucketStores = await requireBucketStores(account, bucketName);
|
|
122
|
+
let routing = await readRoutingFromDisk(account, bucketName);
|
|
123
|
+
return aggregateArchivesConfig(bucketStores.map(x => x.store), routing);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function bucketSyncStatus(account: string, bucketName: string): Promise<ArchivesSyncStatus> {
|
|
127
|
+
let bucketStores = await requireBucketStores(account, bucketName);
|
|
128
|
+
let statuses = await Promise.all(bucketStores.map(s => s.store.getSyncStatus()));
|
|
114
129
|
return {
|
|
115
130
|
allScansComplete: statuses.every(x => x.allScansComplete),
|
|
116
131
|
indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
|
|
@@ -118,8 +133,9 @@ export async function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyn
|
|
|
118
133
|
};
|
|
119
134
|
}
|
|
120
135
|
|
|
121
|
-
export async function
|
|
122
|
-
let
|
|
136
|
+
export async function debugBucketIndexTotals(account: string, bucketName: string): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }> {
|
|
137
|
+
let bucketStores = await requireBucketStores(account, bucketName);
|
|
138
|
+
let totals = await Promise.all(bucketStores.map(s => s.store.computeIndexTotals()));
|
|
123
139
|
return {
|
|
124
140
|
fileCount: totals.reduce((sum, x) => sum + x.fileCount, 0),
|
|
125
141
|
byteCount: totals.reduce((sum, x) => sum + x.byteCount, 0),
|
|
@@ -127,15 +143,6 @@ export async function bucketIndexTotals(bucket: BucketState): Promise<{ fileCoun
|
|
|
127
143
|
};
|
|
128
144
|
}
|
|
129
145
|
|
|
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>();
|
|
133
|
-
|
|
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()];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
146
|
const resolvedSourceArchives = new Map<string, IArchives>();
|
|
140
147
|
/** 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. */
|
|
141
148
|
export function resolveSourceArchives(url: string): IArchives {
|
|
@@ -151,88 +158,6 @@ export function resolveSourceArchives(url: string): IArchives {
|
|
|
151
158
|
return archives;
|
|
152
159
|
}
|
|
153
160
|
|
|
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;
|
|
158
|
-
}
|
|
159
|
-
|
|
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),
|
|
177
|
-
onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
|
|
178
|
-
resolveSourceUrl: resolveSourceArchives,
|
|
179
|
-
});
|
|
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),
|
|
199
|
-
}));
|
|
200
|
-
let loaded: BucketState = { account, bucketName, routing, selfEntries: plan?.selfEntries || [], self: plan?.self, stores };
|
|
201
|
-
loadedBuckets.set(`${account}/${bucketName}`, loaded);
|
|
202
|
-
scheduleBoundaryWork(loaded);
|
|
203
|
-
return loaded;
|
|
204
|
-
}
|
|
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.
|
|
207
|
-
async function loadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
|
|
208
|
-
let folders = await listBucketStoreFolders(account, bucketName);
|
|
209
|
-
if (!folders.length) return undefined;
|
|
210
|
-
let routing = await readRoutingFromDisk(account, bucketName);
|
|
211
|
-
return buildBucket(account, bucketName, folders.map(x => x.name), routing);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function getLoadedBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
|
|
215
|
-
let key = `${account}/${bucketName}`;
|
|
216
|
-
return buckets.get(key) || reloadBucket(account, bucketName);
|
|
217
|
-
}
|
|
218
|
-
|
|
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> {
|
|
221
|
-
let key = `${account}/${bucketName}`;
|
|
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);
|
|
227
|
-
}
|
|
228
|
-
}, () => {
|
|
229
|
-
if (buckets.get(key) === loaded) {
|
|
230
|
-
buckets.delete(key);
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
return await loaded;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
161
|
/**
|
|
237
162
|
* Writing the routing config is a write like any other: it goes into a store, and the store applies
|
|
238
163
|
* it to itself and lets its peers pull it. The only thing that happens here is picking WHICH store,
|
|
@@ -246,17 +171,15 @@ export async function writeRoutingConfig(account: string, bucketName: string, na
|
|
|
246
171
|
assertWritesAllowed();
|
|
247
172
|
let store = getStore(account, bucketName, name);
|
|
248
173
|
let incoming = parseRoutingData(data);
|
|
249
|
-
let current =
|
|
174
|
+
let current = await readRoutingFromDisk(account, bucketName);
|
|
250
175
|
let stored = reinjectIntermediates(current, incoming);
|
|
251
176
|
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
177
|
broadcastRoutingChanged();
|
|
255
178
|
}
|
|
256
179
|
|
|
257
|
-
/** Which buckets this process currently has
|
|
180
|
+
/** Which buckets this process currently has active (some store of theirs was opened) - what a deploy successor asks its predecessor for, so it activates exactly the buckets that are actually in use. */
|
|
258
181
|
export function getActiveBucketKeys(): { account: string; bucketName: string }[] {
|
|
259
|
-
return [...
|
|
182
|
+
return [...activeStores.keys()].map(key => {
|
|
260
183
|
let slash = key.indexOf("/");
|
|
261
184
|
return { account: key.slice(0, slash), bucketName: key.slice(slash + 1) };
|
|
262
185
|
});
|
|
@@ -277,80 +200,46 @@ export type ServerBucketInfo = {
|
|
|
277
200
|
|
|
278
201
|
export type ActiveBucketInfo = {
|
|
279
202
|
folder: string;
|
|
280
|
-
/** The routing config the
|
|
203
|
+
/** The bucket's routing config, the newest copy among its stores. Absent when none of them has one yet. */
|
|
281
204
|
routing?: RemoteConfig;
|
|
282
|
-
/** Our own entries in that config, and their summarized current role (routes union + flags) */
|
|
283
|
-
selfEntries: HostedConfig[];
|
|
284
|
-
self?: SelfSummary;
|
|
285
205
|
config: ArchivesConfig;
|
|
286
206
|
};
|
|
287
207
|
|
|
288
|
-
|
|
289
|
-
|
|
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 || "",
|
|
292
|
-
routing: loaded.routing,
|
|
293
|
-
selfEntries: loaded.selfEntries,
|
|
294
|
-
self: loaded.self,
|
|
295
|
-
config: getBucketConfig(loaded),
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** 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. */
|
|
300
|
-
export async function getActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
208
|
+
/** The state of ONE active bucket. Returns an error string when the bucket is not active here, which is the normal state for a bucket nothing has accessed since startup. */
|
|
209
|
+
export async function debugGetActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
301
210
|
let key = `${account}/${bucketName}`;
|
|
302
|
-
let
|
|
303
|
-
if (!
|
|
304
|
-
return `Bucket ${key} is not
|
|
211
|
+
let opened = activeStores.get(key);
|
|
212
|
+
if (!opened || !opened.size) {
|
|
213
|
+
return `Bucket ${key} is not active on this server (a bucket activates on first access)`;
|
|
305
214
|
}
|
|
306
|
-
let
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
} catch (e) {
|
|
310
|
-
return `Bucket ${key} failed to load on this server: ${String((e as Error).stack ?? e).slice(0, 500)}`;
|
|
311
|
-
}
|
|
312
|
-
if (!loaded) {
|
|
313
|
-
return `Bucket ${key} does not exist on this server`;
|
|
314
|
-
}
|
|
315
|
-
return toActiveBucketInfo(loaded);
|
|
215
|
+
let bucketStores = [...opened.values()];
|
|
216
|
+
let routing = await readRoutingFromDisk(account, bucketName);
|
|
217
|
+
return { folder: bucketStores[0].folder, routing, config: aggregateArchivesConfig(bucketStores, routing) };
|
|
316
218
|
}
|
|
317
219
|
|
|
318
|
-
/** Loads a bucket that exists on this server's disk into memory, which starts
|
|
220
|
+
/** Loads every store of a bucket that exists on this server's disk into memory, which starts their synchronization and window timers, and returns the bucket's state. Nothing is written and no other server is contacted - unlike building an ArchivesChain for it, which would probe every source and could write the routing config. Already-active buckets just return their state. */
|
|
319
221
|
export async function activateBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
320
222
|
let key = `${account}/${bucketName}`;
|
|
321
|
-
let
|
|
322
|
-
let
|
|
323
|
-
|
|
324
|
-
loaded = await getLoadedBucket(account, bucketName);
|
|
325
|
-
} catch (e) {
|
|
326
|
-
return `Bucket ${key} failed to load on this server: ${String((e as Error).stack ?? e).slice(0, 500)}`;
|
|
327
|
-
}
|
|
328
|
-
if (!loaded) {
|
|
223
|
+
let wasActive = activeStores.has(key);
|
|
224
|
+
let bucketStores = await getBucketStores(account, bucketName);
|
|
225
|
+
if (!bucketStores.length) {
|
|
329
226
|
return `Bucket ${key} does not exist on this server (it has no store folders under ${getBucketFolder("<name>", account, bucketName)})`;
|
|
330
227
|
}
|
|
331
228
|
// 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.
|
|
332
|
-
for (let s of
|
|
229
|
+
for (let s of bucketStores) {
|
|
333
230
|
await s.store.init();
|
|
334
231
|
}
|
|
335
|
-
if (!
|
|
336
|
-
console.log(`Activated bucket ${key} on request:
|
|
232
|
+
if (!wasActive) {
|
|
233
|
+
console.log(`Activated bucket ${key} on request: its ${bucketStores.length} store(s) are now loaded and synchronizing`);
|
|
337
234
|
}
|
|
338
|
-
|
|
235
|
+
let routing = await readRoutingFromDisk(account, bucketName);
|
|
236
|
+
return { folder: bucketStores[0].store.folder, routing, config: aggregateArchivesConfig(bucketStores.map(x => x.store), routing) };
|
|
339
237
|
}
|
|
340
238
|
|
|
341
|
-
export async function
|
|
239
|
+
export async function debugListAccountBuckets(account: string): Promise<ServerBucketInfo[]> {
|
|
342
240
|
let start = Date.now();
|
|
343
|
-
let timings = new Map<string, number>();
|
|
344
|
-
async function timed<T>(name: string, run: () => Promise<T>): Promise<T> {
|
|
345
|
-
let began = Date.now();
|
|
346
|
-
try {
|
|
347
|
-
return await run();
|
|
348
|
-
} finally {
|
|
349
|
-
timings.set(name, (timings.get(name) || 0) + (Date.now() - began));
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
241
|
// 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
|
|
242
|
+
let folders = await listAccountStoreFolders(account);
|
|
354
243
|
let byBucket = new Map<string, string>();
|
|
355
244
|
for (let store of folders) {
|
|
356
245
|
if (byBucket.has(store.bucketName)) continue;
|
|
@@ -361,83 +250,27 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
|
|
|
361
250
|
return await Promise.all(names.map(async bucketName => {
|
|
362
251
|
let key = `${account}/${bucketName}`;
|
|
363
252
|
let folder = byBucket.get(bucketName) || "";
|
|
364
|
-
let base: ServerBucketInfo = { bucketName, active:
|
|
365
|
-
base.disk = await
|
|
253
|
+
let base: ServerBucketInfo = { bucketName, active: activeStores.has(key), folder };
|
|
254
|
+
base.disk = await getDiskInfo(folder).catch((e: Error) => {
|
|
366
255
|
base.diskError = String(e.stack ?? e).slice(0, 500);
|
|
367
256
|
return undefined;
|
|
368
257
|
});
|
|
369
258
|
base.writeStats = getBucketWriteStats(key);
|
|
370
|
-
const loadedPromise = buckets.get(key);
|
|
371
|
-
if (loadedPromise) {
|
|
372
|
-
try {
|
|
373
|
-
let loaded = await timed("awaitLoaded", () => loadedPromise);
|
|
374
|
-
if (loaded) {
|
|
375
|
-
return { ...base, active: true, config: getBucketConfig(loaded) };
|
|
376
|
-
}
|
|
377
|
-
} catch (e) {
|
|
378
|
-
return { ...base, active: true, error: String((e as Error).stack ?? e).slice(0, 500) };
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
259
|
try {
|
|
382
|
-
|
|
383
|
-
if (
|
|
260
|
+
let routing = await readRoutingFromDisk(account, bucketName);
|
|
261
|
+
if (base.active) {
|
|
262
|
+
let opened = [...(activeStores.get(key)?.values() || [])];
|
|
263
|
+
return { ...base, config: aggregateArchivesConfig(opened, routing) };
|
|
264
|
+
}
|
|
265
|
+
if (!routing) {
|
|
384
266
|
return { ...base, error: `No routing file (${ROUTING_FILE}) in any of its stores` };
|
|
385
267
|
}
|
|
386
|
-
return { ...base, config: { remoteConfig:
|
|
268
|
+
return { ...base, config: { remoteConfig: routing } };
|
|
387
269
|
} catch (e) {
|
|
388
270
|
return { ...base, error: String((e as Error).stack ?? e).slice(0, 500) };
|
|
389
271
|
}
|
|
390
272
|
}));
|
|
391
273
|
} finally {
|
|
392
|
-
|
|
393
|
-
let parts = [...timings].map(([name, ms]) => `${name} ${ms}ms`).join(", ");
|
|
394
|
-
console.log(`listAccountBuckets(${account}) took ${Date.now() - start}ms for ${names.length} buckets: ${parts}`);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
export type BucketWriteStats = {
|
|
399
|
-
/** Every set call the bucket accepted */
|
|
400
|
-
originalWrites: number;
|
|
401
|
-
originalBytes: number;
|
|
402
|
-
/** 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). */
|
|
403
|
-
flushedWrites: number;
|
|
404
|
-
flushedBytes: number;
|
|
405
|
-
};
|
|
406
|
-
function emptyWriteStats(): BucketWriteStats {
|
|
407
|
-
return { originalWrites: 0, originalBytes: 0, flushedWrites: 0, flushedBytes: 0 };
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// In memory only: totals since this process started (or the last clearWriteStats). Persisting them to disk was more machinery than the numbers were worth.
|
|
411
|
-
const writeStats = new Map<string, BucketWriteStats>();
|
|
412
|
-
|
|
413
|
-
function countBucketWrite(key: string, kind: "original" | "flushed", bytes: number): void {
|
|
414
|
-
let stats = writeStats.get(key);
|
|
415
|
-
if (!stats) {
|
|
416
|
-
stats = emptyWriteStats();
|
|
417
|
-
writeStats.set(key, stats);
|
|
418
|
-
}
|
|
419
|
-
if (kind === "original") {
|
|
420
|
-
stats.originalWrites++;
|
|
421
|
-
stats.originalBytes += bytes;
|
|
422
|
-
} else {
|
|
423
|
-
stats.flushedWrites++;
|
|
424
|
-
stats.flushedBytes += bytes;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function getBucketWriteStats(key: string): BucketWriteStats {
|
|
429
|
-
return writeStats.get(key) || emptyWriteStats();
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** Zeroes the write statistics of every bucket in the account. */
|
|
433
|
-
export function clearAccountWriteStats(account: string): number {
|
|
434
|
-
let prefix = `${account}/`;
|
|
435
|
-
let cleared = 0;
|
|
436
|
-
for (let key of [...writeStats.keys()]) {
|
|
437
|
-
if (!key.startsWith(prefix)) continue;
|
|
438
|
-
writeStats.delete(key);
|
|
439
|
-
cleared++;
|
|
274
|
+
console.log(`debugListAccountBuckets(${account}) took ${Date.now() - start}ms for ${names.length} buckets`);
|
|
440
275
|
}
|
|
441
|
-
console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
|
|
442
|
-
return cleared;
|
|
443
276
|
}
|
|
@@ -24,23 +24,3 @@ export type BoundaryHandover = {
|
|
|
24
24
|
* Pure: config in, plan out. Nothing here reads a store, a clock, or the network.
|
|
25
25
|
*/
|
|
26
26
|
export declare function previousWindowOwners(config: RemoteConfig, windowStart: number, selfIndexes: number[]): BoundaryHandover[];
|
|
27
|
-
/** Our role in a bucket's routing config, summarized across ALL currently-valid self entries. Stored instead of a single representative HostedConfig, so nothing can accidentally use one entry's route or flags where the union is required - the standard config has the same URL twice: a routed write-shard entry plus an unrouted read-everything entry. */
|
|
28
|
-
export type SelfSummary = {
|
|
29
|
-
/** The union of the current entries' routes, with overlapping/adjacent ranges combined - which commonly collapses to a single full range, making matching trivial. */
|
|
30
|
-
routes: [number, number][];
|
|
31
|
-
public: boolean;
|
|
32
|
-
immutable: boolean;
|
|
33
|
-
noFullSync: boolean;
|
|
34
|
-
readerDiskLimit?: number;
|
|
35
|
-
};
|
|
36
|
-
/** One store this server runs for a bucket: the name that IS the store, and the entries that name it. What it synchronizes with is NOT decided here - the store reads its own copy of the config and works that out itself (see BlobStore.applyRoutingConfig). */
|
|
37
|
-
export type SelfStore = {
|
|
38
|
-
name: string;
|
|
39
|
-
entries: HostedConfig[];
|
|
40
|
-
};
|
|
41
|
-
/** Which stores this server runs for a bucket, by name. Nothing more: a store is found by name, and configures itself. */
|
|
42
|
-
export declare function planSelfStores(account: string, bucketName: string, routing: RemoteConfig): {
|
|
43
|
-
selfEntries: HostedConfig[];
|
|
44
|
-
self: SelfSummary | undefined;
|
|
45
|
-
stores: SelfStore[];
|
|
46
|
-
};
|