sliftutils 1.7.88 → 1.7.90
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 +679 -300
- 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 +29 -3
- package/storage/dist/FileFolderAPI.tsx.cache +2 -2
- package/storage/dist/IArchives.ts.cache +17 -53
- 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/archiveHelpers.ts.cache +87 -0
- package/storage/dist/backblaze.ts.cache +59 -45
- 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/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 +26 -6
- 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 +2 -2
- package/storage/remoteStorage/dist/blobStore.ts.cache +484 -966
- 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 +64 -27
- 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 +33 -36
- 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 +138 -707
- 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 +651 -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 +37 -41
- 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 +39 -26
- package/storage/remoteStorage/storageServerState.ts +149 -669
- 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,50 @@
|
|
|
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 {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
import { assertValidSourceName } from "./validation";
|
|
12
|
+
import { getBucketFolder, listAccountStoreFolders, listBucketStoreFolders, readRoutingFromDisk, getDiskInfo, BucketDiskInfo } from "./bucketDisk";
|
|
13
|
+
import { scheduleBoundaryWork, reinjectIntermediates } from "./intermediateManagement";
|
|
14
|
+
import { planSelfStores, isSelfSource, SelfSummary } from "./storePlan";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What this server HAS, as opposed to what any of it does. Four things, and nothing else:
|
|
18
|
+
*
|
|
19
|
+
* - the stores, by folder (getStore) - made once, and self-configuring from there
|
|
20
|
+
* - which of them a request means (findBucketStore), which is only ever a name
|
|
21
|
+
* - what exists on disk, for listings and for the pages that report it
|
|
22
|
+
*
|
|
23
|
+
* It decides nothing about routes, windows or peers - a store reads its own config and works that
|
|
24
|
+
* out itself (BlobStore.applyRoutingConfig) - and it schedules nothing: switchovers and the handovers
|
|
25
|
+
* they cause live in intermediateManagement.
|
|
26
|
+
*/
|
|
22
27
|
|
|
23
28
|
export type LoadedStore = {
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
route?: [number, number];
|
|
29
|
+
// The name its entries gave it (see CommonConfig.name) - its identity here, its folder on disk, and how a request finds it
|
|
30
|
+
name: string;
|
|
27
31
|
// 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
32
|
entries: HostedConfig[];
|
|
29
33
|
folder: string;
|
|
30
|
-
store:
|
|
34
|
+
store: BlobStore;
|
|
31
35
|
};
|
|
32
36
|
|
|
33
|
-
// A bucket loaded on this server:
|
|
37
|
+
// 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
38
|
export type BucketState = {
|
|
35
39
|
account: string;
|
|
36
40
|
bucketName: string;
|
|
37
|
-
routing
|
|
38
|
-
|
|
41
|
+
// The newest routing config among its stores, or absent when none of them has one yet
|
|
42
|
+
routing?: RemoteConfig;
|
|
39
43
|
selfEntries: HostedConfig[];
|
|
40
44
|
self: SelfSummary | undefined;
|
|
41
45
|
stores: LoadedStore[];
|
|
42
|
-
structureKey: string;
|
|
43
46
|
};
|
|
44
47
|
|
|
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
48
|
/** 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
49
|
export async function requireBucket(account: string, bucketName: string): Promise<BucketState> {
|
|
58
50
|
let bucket = await getLoadedBucket(account, bucketName);
|
|
@@ -62,23 +54,14 @@ export async function requireBucket(account: string, bucketName: string): Promis
|
|
|
62
54
|
return bucket;
|
|
63
55
|
}
|
|
64
56
|
|
|
65
|
-
/** The
|
|
66
|
-
function
|
|
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. */
|
|
72
|
-
export async function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): Promise<LoadedStore> {
|
|
73
|
-
let bucket = await requireBucket(account, bucketName);
|
|
57
|
+
/** 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. */
|
|
58
|
+
export function findBucketStore(account: string, bucketName: string, sourceConfig: SourceConfig | undefined): BlobStore {
|
|
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`);
|
|
76
61
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
throw new Error(`No source on this server matches the requested remote configuration for bucket ${account}/${bucketName}. Requested: ${JSON.stringify(sourceConfig)}. Available: ${JSON.stringify(bucket.selfEntries)}`);
|
|
62
|
+
// Not part of resolving the request (the name already says everything): loading the bucket is what schedules its switchover work and marks it active for deploy handoffs, and a bucket serving requests must be loaded
|
|
63
|
+
void getLoadedBucket(account, bucketName).catch((e: Error) => console.error(`Loading bucket ${account}/${bucketName} failed: ${e.stack ?? e}`));
|
|
64
|
+
return getStore(account, bucketName, sourceConfig.name);
|
|
82
65
|
}
|
|
83
66
|
|
|
84
67
|
/** 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 +83,7 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
|
|
|
100
83
|
let syncing: SyncActivity[] = [];
|
|
101
84
|
let readerDiskLimit: number | undefined;
|
|
102
85
|
for (let s of bucket.stores) {
|
|
103
|
-
let progress = s.store.getSyncProgress
|
|
104
|
-
if (!progress) continue;
|
|
86
|
+
let progress = s.store.getSyncProgress();
|
|
105
87
|
index.fileCount += progress.index.fileCount;
|
|
106
88
|
index.byteCount += progress.index.byteCount;
|
|
107
89
|
indexSources.push(...progress.sources);
|
|
@@ -109,8 +91,8 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
|
|
|
109
91
|
readerDiskLimit = readerDiskLimit || progress.readerDiskLimit;
|
|
110
92
|
}
|
|
111
93
|
return {
|
|
112
|
-
//
|
|
113
|
-
supportsChangesAfter:
|
|
94
|
+
// Every store is index-backed, so the change feed is always native
|
|
95
|
+
supportsChangesAfter: true,
|
|
114
96
|
remoteConfig: bucket.routing,
|
|
115
97
|
index,
|
|
116
98
|
indexSources,
|
|
@@ -120,17 +102,7 @@ export function getBucketConfig(bucket: BucketState): ArchivesConfig {
|
|
|
120
102
|
}
|
|
121
103
|
|
|
122
104
|
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
|
-
}));
|
|
105
|
+
let statuses = await Promise.all(bucket.stores.map(s => s.store.getSyncStatus()));
|
|
134
106
|
return {
|
|
135
107
|
allScansComplete: statuses.every(x => x.allScansComplete),
|
|
136
108
|
indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
|
|
@@ -138,16 +110,8 @@ export async function bucketSyncStatus(bucket: BucketState): Promise<ArchivesSyn
|
|
|
138
110
|
};
|
|
139
111
|
}
|
|
140
112
|
|
|
141
|
-
export async function
|
|
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
|
-
}));
|
|
113
|
+
export async function debugBucketIndexTotals(bucket: BucketState): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }> {
|
|
114
|
+
let totals = await Promise.all(bucket.stores.map(s => s.store.computeIndexTotals()));
|
|
151
115
|
return {
|
|
152
116
|
fileCount: totals.reduce((sum, x) => sum + x.fileCount, 0),
|
|
153
117
|
byteCount: totals.reduce((sum, x) => sum + x.byteCount, 0),
|
|
@@ -156,175 +120,12 @@ export async function bucketIndexTotals(bucket: BucketState): Promise<{ fileCoun
|
|
|
156
120
|
}
|
|
157
121
|
|
|
158
122
|
const buckets = new Map<string, Promise<BucketState | undefined>>();
|
|
123
|
+
// The resolved ones, for the code that walks buckets rather than asking for one (see getBucketStates)
|
|
124
|
+
const loadedBuckets = new Map<string, BucketState>();
|
|
159
125
|
|
|
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
|
-
}
|
|
126
|
+
/** 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. */
|
|
127
|
+
export function getBucketStates(): BucketState[] {
|
|
128
|
+
return [...loadedBuckets.values()];
|
|
328
129
|
}
|
|
329
130
|
|
|
330
131
|
const resolvedSourceArchives = new Map<string, IArchives>();
|
|
@@ -332,205 +133,120 @@ const resolvedSourceArchives = new Map<string, IArchives>();
|
|
|
332
133
|
export function resolveSourceArchives(url: string): IArchives {
|
|
333
134
|
let existing = resolvedSourceArchives.get(url);
|
|
334
135
|
if (existing) return existing;
|
|
335
|
-
let
|
|
136
|
+
let sourceConfig: SourceConfig | undefined;
|
|
336
137
|
if (url.startsWith("https://")) {
|
|
337
138
|
// 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);
|
|
139
|
+
sourceConfig = normalizeSource(url);
|
|
341
140
|
}
|
|
141
|
+
let archives = createStoreSource({ sourceConfig, folder: url });
|
|
342
142
|
resolvedSourceArchives.set(url, archives);
|
|
343
143
|
return archives;
|
|
344
144
|
}
|
|
345
145
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
146
|
+
/** 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. */
|
|
147
|
+
function sourcePersistentUrl(sourceConfig: SourceConfig | undefined, folder: string): string {
|
|
148
|
+
if (!sourceConfig) return folder;
|
|
149
|
+
return sourceConfig.intermediate || sourceConfig.url;
|
|
349
150
|
}
|
|
350
151
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}));
|
|
366
|
-
|
|
367
|
-
|
|
152
|
+
/** 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. */
|
|
153
|
+
/**
|
|
154
|
+
* Every store this process runs, by folder - which IS the store's identity: account, name, bucket.
|
|
155
|
+
* A store is made once, configures itself from the routing config it holds, and stays; nothing here
|
|
156
|
+
* decides what it is, only that it exists.
|
|
157
|
+
*/
|
|
158
|
+
const stores = new Map<string, BlobStore>();
|
|
159
|
+
|
|
160
|
+
export function getStore(account: string, bucketName: string, name: string): BlobStore {
|
|
161
|
+
// The one place a name becomes a folder, so the one place it is validated (it can come straight off the wire)
|
|
162
|
+
assertValidSourceName(name);
|
|
163
|
+
let folder = getBucketFolder(name, account, bucketName);
|
|
164
|
+
let existing = stores.get(folder);
|
|
165
|
+
if (existing) return existing;
|
|
166
|
+
console.log(`Opening store ${JSON.stringify(name)} of bucket ${account}/${bucketName} (folder ${folder}) - created on this first use if it never existed`);
|
|
167
|
+
let store = new BlobStore(folder, name, {
|
|
168
|
+
// Which entries in ITS routing config are this server's copy of it
|
|
169
|
+
isSelf: source => isSelfSource(source, account, bucketName),
|
|
170
|
+
createSource: config => createStoreSource({ sourceConfig: config.sourceConfig, folder, writeDelay: config.writeDelay }),
|
|
171
|
+
applySource: (source, sourceConfig, writeDelay) => applySourceConfig(source, sourceConfig, writeDelay),
|
|
368
172
|
onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
|
|
369
173
|
resolveSourceUrl: resolveSourceArchives,
|
|
370
|
-
entries: planStore.entries,
|
|
371
174
|
});
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
let
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
175
|
+
stores.set(folder, store);
|
|
176
|
+
// 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
|
|
177
|
+
void store.init().catch((e: Error) => console.error(`Initializing store ${JSON.stringify(name)} of bucket ${account}/${bucketName} failed: ${e.stack ?? e}`));
|
|
178
|
+
return store;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 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. */
|
|
182
|
+
function buildBucket(account: string, bucketName: string, names: string[], routing: RemoteConfig | undefined): BucketState {
|
|
183
|
+
let plan = routing && planSelfStores(account, bucketName, routing) || undefined;
|
|
184
|
+
let entriesByName = new Map<string, HostedConfig[]>();
|
|
185
|
+
for (let selfStore of plan?.stores || []) {
|
|
186
|
+
entriesByName.set(selfStore.name, selfStore.entries);
|
|
187
|
+
}
|
|
188
|
+
// Each store reads its own copy of the config and decides what it is - see BlobStore.applyRoutingConfig. Nothing here configures anything.
|
|
189
|
+
let stores: LoadedStore[] = names.map(name => ({
|
|
190
|
+
name,
|
|
191
|
+
entries: entriesByName.get(name) || [],
|
|
192
|
+
folder: getBucketFolder(name, account, bucketName),
|
|
193
|
+
store: getStore(account, bucketName, name),
|
|
388
194
|
}));
|
|
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
|
-
}
|
|
195
|
+
let loaded: BucketState = { account, bucketName, routing, selfEntries: plan?.selfEntries || [], self: plan?.self, stores };
|
|
196
|
+
loadedBuckets.set(`${account}/${bucketName}`, loaded);
|
|
197
|
+
scheduleBoundaryWork(loaded);
|
|
399
198
|
return loaded;
|
|
400
199
|
}
|
|
401
200
|
|
|
201
|
+
// 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
202
|
async function loadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
|
|
203
|
+
let folders = await listBucketStoreFolders(account, bucketName);
|
|
204
|
+
if (!folders.length) return undefined;
|
|
403
205
|
let routing = await readRoutingFromDisk(account, bucketName);
|
|
404
|
-
|
|
405
|
-
return buildBucket(account, bucketName, routing);
|
|
206
|
+
return buildBucket(account, bucketName, folders.map(x => x.name), routing);
|
|
406
207
|
}
|
|
407
208
|
|
|
408
209
|
export function getLoadedBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
|
|
409
210
|
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;
|
|
211
|
+
return buckets.get(key) || reloadBucket(account, bucketName);
|
|
425
212
|
}
|
|
426
213
|
|
|
427
|
-
|
|
428
|
-
function
|
|
214
|
+
/** 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. */
|
|
215
|
+
export async function reloadBucket(account: string, bucketName: string): Promise<BucketState | undefined> {
|
|
429
216
|
let key = `${account}/${bucketName}`;
|
|
430
|
-
let
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
async function checkRoutingChanged(account: string, bucketName: string, config?: { force?: boolean; reason?: string }): Promise<void> {
|
|
438
|
-
let key = `${account}/${bucketName}`;
|
|
439
|
-
let loaded = await buckets.get(key);
|
|
440
|
-
if (!loaded) return;
|
|
441
|
-
let routing = await readRoutingFromDisk(account, bucketName);
|
|
442
|
-
if (!routing) return;
|
|
443
|
-
if (!config?.force && JSON.stringify(routing) === loaded.routingJSON) return;
|
|
444
|
-
let reason = config?.force && (config.reason || "forced") || "routing config changed";
|
|
445
|
-
let plan = computeStorePlan(account, bucketName, routing);
|
|
446
|
-
if (plan.structureKey === loaded.structureKey) {
|
|
447
|
-
console.log(`Applying the routing config to the running stores for bucket ${key} (${reason})`);
|
|
448
|
-
for (let planStore of plan.stores) {
|
|
449
|
-
let live = loaded.stores.find(x => x.routeKey === planStore.routeKey);
|
|
450
|
-
// structureKey equality means the store set (routes, rawDisk, limits) is unchanged, so every plan store has its live twin
|
|
451
|
-
if (!live || !(live.store instanceof BlobStore)) continue;
|
|
452
|
-
let storeFolder = live.folder;
|
|
453
|
-
live.store.updateSources(planStore.sourceSpecs.map(spec => {
|
|
454
|
-
const sourceConfig = spec.sourceConfig;
|
|
455
|
-
if (!sourceConfig) {
|
|
456
|
-
return { identity: sourceIdentity(undefined), url: storeFolder, validWindows: spec.validWindows, create: (): IArchives => new ArchivesDisk(storeFolder) };
|
|
457
|
-
}
|
|
458
|
-
return {
|
|
459
|
-
identity: sourceIdentity(sourceConfig),
|
|
460
|
-
url: sourceConfig.url,
|
|
461
|
-
validWindows: spec.validWindows,
|
|
462
|
-
route: spec.route,
|
|
463
|
-
noFullSync: spec.noFullSync,
|
|
464
|
-
intermediate: sourceConfig.intermediate,
|
|
465
|
-
sourceConfig,
|
|
466
|
-
create: () => createApiArchives(sourceConfig),
|
|
467
|
-
};
|
|
468
|
-
}), planStore.entries);
|
|
469
|
-
live.entries = planStore.entries;
|
|
217
|
+
let loaded = loadBucket(account, bucketName);
|
|
218
|
+
buckets.set(key, loaded);
|
|
219
|
+
void loaded.then(bucket => {
|
|
220
|
+
if (!bucket && buckets.get(key) === loaded) {
|
|
221
|
+
buckets.delete(key);
|
|
470
222
|
}
|
|
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();
|
|
223
|
+
}, () => {
|
|
224
|
+
if (buckets.get(key) === loaded) {
|
|
225
|
+
buckets.delete(key);
|
|
484
226
|
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
227
|
+
});
|
|
228
|
+
return await loaded;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Writing the routing config is a write like any other: it goes into a store, and the store applies
|
|
233
|
+
* it to itself and lets its peers pull it. The only thing that happens here is picking WHICH store,
|
|
234
|
+
* because the writer names a source and a source names a store.
|
|
235
|
+
*
|
|
236
|
+
* In-flight switchover windows are re-injected first (see intermediateManagement): an operator's
|
|
237
|
+
* config knows nothing about a switchover that is happening right now, and writing it as-is would
|
|
238
|
+
* cancel it mid-flight.
|
|
239
|
+
*/
|
|
240
|
+
export async function writeRoutingConfig(account: string, bucketName: string, name: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
494
241
|
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}`;
|
|
242
|
+
let store = getStore(account, bucketName, name);
|
|
503
243
|
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);
|
|
244
|
+
let current = (await getLoadedBucket(account, bucketName))?.routing;
|
|
245
|
+
let stored = reinjectIntermediates(current, incoming);
|
|
246
|
+
await store.set({ path: ROUTING_FILE, data: Buffer.from(serializeRemoteConfig(stored)), lastModified: config?.lastModified });
|
|
247
|
+
// 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
|
|
248
|
+
await reloadBucket(account, bucketName);
|
|
249
|
+
broadcastRoutingChanged();
|
|
534
250
|
}
|
|
535
251
|
|
|
536
252
|
/** 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 +257,6 @@ export function getActiveBucketKeys(): { account: string; bucketName: string }[]
|
|
|
541
257
|
});
|
|
542
258
|
}
|
|
543
259
|
|
|
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
260
|
export type ServerBucketInfo = {
|
|
680
261
|
bucketName: string;
|
|
681
262
|
active: boolean;
|
|
@@ -691,8 +272,8 @@ export type ServerBucketInfo = {
|
|
|
691
272
|
|
|
692
273
|
export type ActiveBucketInfo = {
|
|
693
274
|
folder: string;
|
|
694
|
-
/** The routing config the bucket is RUNNING on, straight from memory - including switchover windows written since it loaded */
|
|
695
|
-
routing
|
|
275
|
+
/** 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. */
|
|
276
|
+
routing?: RemoteConfig;
|
|
696
277
|
/** Our own entries in that config, and their summarized current role (routes union + flags) */
|
|
697
278
|
selfEntries: HostedConfig[];
|
|
698
279
|
self?: SelfSummary;
|
|
@@ -701,7 +282,8 @@ export type ActiveBucketInfo = {
|
|
|
701
282
|
|
|
702
283
|
function toActiveBucketInfo(loaded: BucketState): ActiveBucketInfo {
|
|
703
284
|
return {
|
|
704
|
-
folder
|
|
285
|
+
// One of its stores' folders - a bucket no longer has a folder of its own, it has one per store
|
|
286
|
+
folder: loaded.stores[0]?.folder || "",
|
|
705
287
|
routing: loaded.routing,
|
|
706
288
|
selfEntries: loaded.selfEntries,
|
|
707
289
|
self: loaded.self,
|
|
@@ -710,7 +292,7 @@ function toActiveBucketInfo(loaded: BucketState): ActiveBucketInfo {
|
|
|
710
292
|
}
|
|
711
293
|
|
|
712
294
|
/** 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. */
|
|
713
|
-
export async function
|
|
295
|
+
export async function debugGetActiveBucket(account: string, bucketName: string): Promise<ActiveBucketInfo | string> {
|
|
714
296
|
let key = `${account}/${bucketName}`;
|
|
715
297
|
let loadedPromise = buckets.get(key);
|
|
716
298
|
if (!loadedPromise) {
|
|
@@ -739,13 +321,11 @@ export async function activateBucket(account: string, bucketName: string): Promi
|
|
|
739
321
|
return `Bucket ${key} failed to load on this server: ${String((e as Error).stack ?? e).slice(0, 500)}`;
|
|
740
322
|
}
|
|
741
323
|
if (!loaded) {
|
|
742
|
-
return `Bucket ${key} does not exist on this server (no
|
|
324
|
+
return `Bucket ${key} does not exist on this server (it has no store folders under ${getBucketFolder("<name>", account, bucketName)})`;
|
|
743
325
|
}
|
|
744
326
|
// 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
327
|
for (let s of loaded.stores) {
|
|
746
|
-
|
|
747
|
-
await s.store.init();
|
|
748
|
-
}
|
|
328
|
+
await s.store.init();
|
|
749
329
|
}
|
|
750
330
|
if (!wasLoaded) {
|
|
751
331
|
console.log(`Activated bucket ${key} on request: it is now loaded and synchronizing`);
|
|
@@ -753,7 +333,7 @@ export async function activateBucket(account: string, bucketName: string): Promi
|
|
|
753
333
|
return toActiveBucketInfo(loaded);
|
|
754
334
|
}
|
|
755
335
|
|
|
756
|
-
export async function
|
|
336
|
+
export async function debugListAccountBuckets(account: string): Promise<ServerBucketInfo[]> {
|
|
757
337
|
let start = Date.now();
|
|
758
338
|
let timings = new Map<string, number>();
|
|
759
339
|
async function timed<T>(name: string, run: () => Promise<T>): Promise<T> {
|
|
@@ -764,19 +344,18 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
|
|
|
764
344
|
timings.set(name, (timings.get(name) || 0) + (Date.now() - began));
|
|
765
345
|
}
|
|
766
346
|
}
|
|
767
|
-
|
|
768
|
-
let
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
347
|
+
// 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
|
|
348
|
+
let folders = await timed("readdir", () => listAccountStoreFolders(account));
|
|
349
|
+
let byBucket = new Map<string, string>();
|
|
350
|
+
for (let store of folders) {
|
|
351
|
+
if (byBucket.has(store.bucketName)) continue;
|
|
352
|
+
byBucket.set(store.bucketName, store.folder);
|
|
773
353
|
}
|
|
774
|
-
|
|
775
|
-
names = [...new Set(names.map(x => x.replace(/-route-[\d.]+-[\d.]+$/, "")))];
|
|
354
|
+
let names = [...byBucket.keys()];
|
|
776
355
|
try {
|
|
777
356
|
return await Promise.all(names.map(async bucketName => {
|
|
778
357
|
let key = `${account}/${bucketName}`;
|
|
779
|
-
let folder =
|
|
358
|
+
let folder = byBucket.get(bucketName) || "";
|
|
780
359
|
let base: ServerBucketInfo = { bucketName, active: false, folder };
|
|
781
360
|
base.disk = await timed("statfs", () => getDiskInfo(folder)).catch((e: Error) => {
|
|
782
361
|
base.diskError = String(e.stack ?? e).slice(0, 500);
|
|
@@ -795,11 +374,10 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
|
|
|
795
374
|
}
|
|
796
375
|
}
|
|
797
376
|
try {
|
|
798
|
-
const
|
|
799
|
-
if (!
|
|
800
|
-
return { ...base, error: `No routing file (${ROUTING_FILE})` };
|
|
377
|
+
const parsed = await timed("readRoutingFile", () => readRoutingFromDisk(account, bucketName));
|
|
378
|
+
if (!parsed) {
|
|
379
|
+
return { ...base, error: `No routing file (${ROUTING_FILE}) in any of its stores` };
|
|
801
380
|
}
|
|
802
|
-
let parsed = await timed("parseRouting", async () => parseRoutingData(data));
|
|
803
381
|
return { ...base, config: { remoteConfig: parsed } };
|
|
804
382
|
} catch (e) {
|
|
805
383
|
return { ...base, error: String((e as Error).stack ?? e).slice(0, 500) };
|
|
@@ -808,7 +386,7 @@ export async function listAccountBuckets(account: string): Promise<ServerBucketI
|
|
|
808
386
|
} finally {
|
|
809
387
|
// The parts run concurrently, so these are summed wall times per part, not a breakdown of the total
|
|
810
388
|
let parts = [...timings].map(([name, ms]) => `${name} ${ms}ms`).join(", ");
|
|
811
|
-
console.log(`
|
|
389
|
+
console.log(`debugListAccountBuckets(${account}) took ${Date.now() - start}ms for ${names.length} buckets: ${parts}`);
|
|
812
390
|
}
|
|
813
391
|
}
|
|
814
392
|
|
|
@@ -847,7 +425,7 @@ function getBucketWriteStats(key: string): BucketWriteStats {
|
|
|
847
425
|
}
|
|
848
426
|
|
|
849
427
|
/** Zeroes the write statistics of every bucket in the account. */
|
|
850
|
-
export function
|
|
428
|
+
export function debugClearAccountWriteStats(account: string): number {
|
|
851
429
|
let prefix = `${account}/`;
|
|
852
430
|
let cleared = 0;
|
|
853
431
|
for (let key of [...writeStats.keys()]) {
|
|
@@ -858,101 +436,3 @@ export function clearAccountWriteStats(account: string): number {
|
|
|
858
436
|
console.log(`Cleared the write statistics of ${cleared} buckets in account ${account}`);
|
|
859
437
|
return cleared;
|
|
860
438
|
}
|
|
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
|
-
}
|