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