sliftutils 1.7.9 → 1.7.10
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/bin/storageserver.js +4 -0
- package/{teststorage → examplestorage}/browser.tsx +18 -22
- package/{teststorage/server.ts → examplestorage/exampleserver.ts} +5 -5
- package/index.d.ts +528 -78
- package/misc/https/dns.d.ts +7 -3
- package/misc/https/dns.ts +4 -9
- package/misc/https/hostServer.d.ts +6 -3
- package/misc/https/hostServer.ts +6 -6
- package/package.json +2 -1
- package/spec.txt +77 -42
- package/storage/ArchivesDisk.d.ts +65 -0
- package/storage/ArchivesDisk.ts +365 -0
- package/storage/IArchives.d.ts +95 -1
- package/storage/IArchives.ts +146 -1
- package/storage/backblaze.d.ts +14 -2
- package/storage/backblaze.ts +18 -2
- package/storage/remoteStorage/ArchivesRemote.d.ts +29 -17
- package/storage/remoteStorage/ArchivesRemote.ts +63 -63
- package/storage/remoteStorage/ArchivesUrl.d.ts +46 -0
- package/storage/remoteStorage/ArchivesUrl.ts +74 -0
- package/storage/remoteStorage/accessPage.tsx +111 -28
- package/storage/remoteStorage/blobStore.d.ts +79 -25
- package/storage/remoteStorage/blobStore.ts +441 -287
- package/storage/remoteStorage/cliArgs.d.ts +1 -0
- package/storage/remoteStorage/cliArgs.ts +9 -0
- package/storage/remoteStorage/createArchives.d.ts +64 -0
- package/storage/remoteStorage/createArchives.ts +395 -0
- package/storage/remoteStorage/grantAccess.js +4 -0
- package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
- package/storage/remoteStorage/grantAccessCli.ts +27 -0
- package/storage/remoteStorage/remoteConfig.d.ts +21 -0
- package/storage/remoteStorage/remoteConfig.ts +94 -0
- package/storage/remoteStorage/storageController.d.ts +19 -27
- package/storage/remoteStorage/storageController.ts +205 -136
- package/storage/remoteStorage/storageServer.d.ts +11 -0
- package/storage/remoteStorage/storageServer.ts +80 -93
- package/storage/remoteStorage/storageServerCli.d.ts +1 -0
- package/storage/remoteStorage/storageServerCli.ts +41 -0
- package/storage/remoteStorage/storageServerState.d.ts +37 -0
- package/storage/remoteStorage/storageServerState.ts +358 -0
- package/testsite/server.ts +1 -1
|
@@ -1,47 +1,61 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
1
|
import path from "path";
|
|
3
2
|
import { lazy } from "socket-function/src/caching";
|
|
4
|
-
import {
|
|
5
|
-
import { timeInMinute, sort } from "socket-function/src/misc";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
// Whole-file memory cache (only files up to MEMORY_CACHE_MAX_FILE are cached)
|
|
21
|
-
const MEMORY_CACHE_BYTES = 256 * 1024 * 1024;
|
|
22
|
-
const MEMORY_CACHE_MAX_FILE = 16 * 1024 * 1024;
|
|
3
|
+
import { runInfinitePoll, delay } from "socket-function/src/batching";
|
|
4
|
+
import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
|
|
5
|
+
import {
|
|
6
|
+
IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, assertValidLastModified,
|
|
7
|
+
} from "../IArchives";
|
|
8
|
+
import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
|
|
9
|
+
import { BulkDatabaseBase, noopReactiveDeps } from "../BulkDatabase2/BulkDatabaseBase";
|
|
10
|
+
import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../FileFolderAPI";
|
|
11
|
+
|
|
12
|
+
// The storage engine of the remote storage server. Data lives in synchronization sources (at
|
|
13
|
+
// minimum an ArchivesDisk, the local disk); BlobStore keeps an index of every file (path, last
|
|
14
|
+
// modified time, size, and which source currently holds the data) in a BulkDatabase2, and
|
|
15
|
+
// synchronizes the index from all sources (see ArchivesSource / SyncOptions in IArchives.ts).
|
|
16
|
+
// Every startup fully rescans each source's metadata, so the index self-heals; the file with the
|
|
17
|
+
// highest write time wins across all sources, so multiple sources need no stacking order.
|
|
18
|
+
|
|
23
19
|
export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
|
|
24
20
|
const FAST_FLUSH_POLL = 1000 * 15;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
t: number;
|
|
38
|
-
};
|
|
21
|
+
// Index changes are buffered in memory and written to the BulkDatabase2 in batches
|
|
22
|
+
const INDEX_FLUSH_INTERVAL = 1000 * 30;
|
|
23
|
+
// Sources that support getChangesAfter are polled this often
|
|
24
|
+
const CHANGES_POLL_INTERVAL = 1000 * 60;
|
|
25
|
+
// Sources that don't support getChangesAfter get a full metadata rescan this often
|
|
26
|
+
const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
|
|
27
|
+
// On a request for a file the index doesn't know, changes-after sources are re-polled, at most
|
|
28
|
+
// this often
|
|
29
|
+
const MISS_CHECK_INTERVAL = 1000 * 5;
|
|
30
|
+
// Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
|
|
31
|
+
const CHANGES_POLL_OVERLAP = timeInMinute;
|
|
32
|
+
const SCAN_RETRY_DELAY = 1000 * 30;
|
|
39
33
|
|
|
40
34
|
export type WriteConfig = {
|
|
41
|
-
// Resolve once the write is in memory; flush to
|
|
42
|
-
// the same key (only the latest is written). Data is lost if the process crashes first.
|
|
35
|
+
// Resolve once the write is in memory; flush to the sources after writeDelay, coalescing
|
|
36
|
+
// writes to the same key (only the latest is written). Data is lost if the process crashes first.
|
|
43
37
|
fast?: boolean;
|
|
44
38
|
writeDelay?: number;
|
|
39
|
+
// Stamps the write with this last-write time instead of now. Older than the current file's
|
|
40
|
+
// time no-ops; more than 15 minutes in the future throws. See IArchives.set.
|
|
41
|
+
lastModified?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// What the storage server needs from a bucket's store. BlobStore implements it fully; ArchivesDisk
|
|
45
|
+
// also satisfies it (used directly for rawDisk buckets), minus the optional index-backed methods.
|
|
46
|
+
export type IBucketStore = {
|
|
47
|
+
get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined>;
|
|
48
|
+
get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined>;
|
|
49
|
+
set(fileName: string, data: Buffer, config?: WriteConfig): Promise<void>;
|
|
50
|
+
del(fileName: string, config?: WriteConfig): Promise<void>;
|
|
51
|
+
getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined>;
|
|
52
|
+
findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
|
|
53
|
+
getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
|
|
54
|
+
getSyncStatus?(): Promise<ArchivesSyncStatus>;
|
|
55
|
+
startLargeUpload(): Promise<string>;
|
|
56
|
+
appendLargeUpload(id: string, data: Buffer): Promise<void>;
|
|
57
|
+
finishLargeUpload(id: string, key: string): Promise<void>;
|
|
58
|
+
cancelLargeUpload(id: string): Promise<void>;
|
|
45
59
|
};
|
|
46
60
|
|
|
47
61
|
type OverlayEntry = {
|
|
@@ -51,211 +65,379 @@ type OverlayEntry = {
|
|
|
51
65
|
flushAt: number;
|
|
52
66
|
};
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
// One row of the BulkDatabase2 index (key is the file path)
|
|
69
|
+
type BlobIndexEntry = {
|
|
70
|
+
key: string;
|
|
71
|
+
writeTime: number;
|
|
72
|
+
size: number;
|
|
73
|
+
// Which synchronization source currently holds the data (an index into the sources array)
|
|
74
|
+
source: number;
|
|
75
|
+
};
|
|
76
|
+
type IndexEntry = {
|
|
77
|
+
writeTime: number;
|
|
78
|
+
size: number;
|
|
79
|
+
source: number;
|
|
80
|
+
// When WE last changed this entry (not the file's write time) — what getChangesAfter filters
|
|
81
|
+
// on, so late-arriving files with old write times are still reported as changes
|
|
82
|
+
changedAt: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type SourceState = {
|
|
86
|
+
supportsChangesAfter: boolean;
|
|
87
|
+
initialScan: ReturnType<typeof promiseObj>;
|
|
88
|
+
scanComplete: boolean;
|
|
89
|
+
// Files seen in this source's scans / change polls so far
|
|
90
|
+
scannedCount: number;
|
|
91
|
+
// Watermark for getChangesAfter polls
|
|
92
|
+
changesAfterTime: number;
|
|
93
|
+
lastMissCheck: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export class BlobStore implements IBucketStore {
|
|
97
|
+
constructor(
|
|
98
|
+
private folder: string,
|
|
99
|
+
private sources: ArchivesSource[],
|
|
100
|
+
private config?: {
|
|
101
|
+
// Called whenever a key's index entry changes (our own writes AND files pulled in via
|
|
102
|
+
// synchronization) — how the storage server notices routing config updates.
|
|
103
|
+
onIndexChanged?: (key: string) => void;
|
|
75
104
|
}
|
|
76
|
-
}
|
|
77
|
-
public delete(key: string) {
|
|
78
|
-
let existing = this.map.get(key);
|
|
79
|
-
if (!existing) return;
|
|
80
|
-
this.bytes -= existing.length;
|
|
81
|
-
this.map.delete(key);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
105
|
+
) { }
|
|
84
106
|
|
|
85
|
-
|
|
86
|
-
constructor(private folder: string) { }
|
|
107
|
+
private stopped = { stop: false };
|
|
87
108
|
|
|
88
|
-
|
|
109
|
+
// The index's BulkDatabase2 files live under <folder>/index
|
|
110
|
+
private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex", noopReactiveDeps, async (p: string) => {
|
|
111
|
+
let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(path.join(this.folder, "index"));
|
|
112
|
+
for (let part of p.split("/")) {
|
|
113
|
+
if (part) base = await base.getDirectoryHandle(part, { create: true });
|
|
114
|
+
}
|
|
115
|
+
return wrapHandle(base);
|
|
116
|
+
});
|
|
117
|
+
// The in-memory copy of the index. All reads are served from it; changes are buffered in
|
|
118
|
+
// dirty and flushed to the BulkDatabase2 in batches (undefined = delete).
|
|
119
|
+
private mem = new Map<string, IndexEntry>();
|
|
120
|
+
private dirty = new Map<string, IndexEntry | undefined>();
|
|
89
121
|
private overlay = new Map<string, OverlayEntry>();
|
|
90
|
-
private
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
122
|
+
private sourceStates = this.sources.map((): SourceState => ({
|
|
123
|
+
supportsChangesAfter: false,
|
|
124
|
+
initialScan: promiseObj(),
|
|
125
|
+
scanComplete: false,
|
|
126
|
+
scannedCount: 0,
|
|
127
|
+
changesAfterTime: 0,
|
|
128
|
+
lastMissCheck: 0,
|
|
129
|
+
}));
|
|
94
130
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
131
|
+
public init = lazy(async () => {
|
|
132
|
+
await this.loadIndex();
|
|
133
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
134
|
+
void this.runSourceSync(i);
|
|
135
|
+
}
|
|
136
|
+
runInfinitePoll(FAST_FLUSH_POLL, () => this.flushOverlay(), this.stopped);
|
|
137
|
+
runInfinitePoll(INDEX_FLUSH_INTERVAL, () => this.flushIndex(), this.stopped);
|
|
138
|
+
});
|
|
98
139
|
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
140
|
+
// Stops all synchronization scans/polls and flushes pending writes. Used when a bucket's
|
|
141
|
+
// routing config changes and the store is rebuilt with new sources.
|
|
142
|
+
public async dispose(): Promise<void> {
|
|
143
|
+
this.stopped.stop = true;
|
|
144
|
+
await this.flushOverlay(true);
|
|
145
|
+
await this.flushIndex();
|
|
146
|
+
}
|
|
102
147
|
|
|
103
|
-
private
|
|
148
|
+
private async loadIndex(): Promise<void> {
|
|
149
|
+
let [writeTimes, sizes, sources] = await Promise.all([
|
|
150
|
+
this.index.getColumn("writeTime"),
|
|
151
|
+
this.index.getColumn("size"),
|
|
152
|
+
this.index.getColumn("source"),
|
|
153
|
+
]);
|
|
154
|
+
let sizeMap = new Map(sizes.map(x => [x.key, x.value]));
|
|
155
|
+
let sourceMap = new Map(sources.map(x => [x.key, x.value]));
|
|
156
|
+
for (let entry of writeTimes) {
|
|
157
|
+
let size = sizeMap.get(entry.key);
|
|
158
|
+
let source = sourceMap.get(entry.key);
|
|
159
|
+
// Explicit checks, as 0 is a valid size and a valid source number
|
|
160
|
+
if (size === undefined || source === undefined) continue;
|
|
161
|
+
this.mem.set(entry.key, { writeTime: entry.value, size, source, changedAt: entry.time });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
104
164
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
|
|
111
|
-
|
|
165
|
+
private setIndexEntry(key: string, entry: { writeTime: number; size: number; source: number }): void {
|
|
166
|
+
let full: IndexEntry = { ...entry, changedAt: Date.now() };
|
|
167
|
+
this.mem.set(key, full);
|
|
168
|
+
this.dirty.set(key, full);
|
|
169
|
+
this.config?.onIndexChanged?.(key);
|
|
170
|
+
}
|
|
171
|
+
private deleteIndexEntry(key: string): void {
|
|
172
|
+
if (!this.mem.has(key)) return;
|
|
173
|
+
this.mem.delete(key);
|
|
174
|
+
this.dirty.set(key, undefined);
|
|
175
|
+
}
|
|
112
176
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
177
|
+
private async flushIndex(): Promise<void> {
|
|
178
|
+
if (!this.dirty.size) return;
|
|
179
|
+
let dirty = this.dirty;
|
|
180
|
+
this.dirty = new Map();
|
|
181
|
+
let writes: BlobIndexEntry[] = [];
|
|
182
|
+
let deletes: string[] = [];
|
|
183
|
+
for (let [key, entry] of dirty) {
|
|
184
|
+
if (entry) {
|
|
185
|
+
writes.push({ key, writeTime: entry.writeTime, size: entry.size, source: entry.source });
|
|
186
|
+
} else {
|
|
187
|
+
deletes.push(key);
|
|
188
|
+
}
|
|
120
189
|
}
|
|
190
|
+
if (writes.length) await this.index.writeBatch(writes);
|
|
191
|
+
if (deletes.length) await this.index.deleteBatch(deletes);
|
|
192
|
+
}
|
|
121
193
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
194
|
+
// ── synchronization ──
|
|
195
|
+
|
|
196
|
+
private async runSourceSync(sourceIndex: number): Promise<void> {
|
|
197
|
+
let { source, options } = this.sources[sourceIndex];
|
|
198
|
+
let state = this.sourceStates[sourceIndex];
|
|
199
|
+
while (!this.stopped.stop) {
|
|
200
|
+
try {
|
|
201
|
+
let config = await source.getConfig();
|
|
202
|
+
state.supportsChangesAfter = !!(config.supportsChangesAfter && source.getChangesAfter);
|
|
203
|
+
await this.scanSource(sourceIndex);
|
|
204
|
+
break;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
|
|
207
|
+
await delay(SCAN_RETRY_DELAY);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
state.scanComplete = true;
|
|
211
|
+
state.initialScan.resolve(undefined);
|
|
212
|
+
if (this.stopped.stop) return;
|
|
213
|
+
if (options.copyFiles) {
|
|
214
|
+
try {
|
|
215
|
+
await this.copySourceFiles(sourceIndex);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
console.error(`Copying files from sync source ${source.getDebugName()} failed:`, e);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (state.supportsChangesAfter) {
|
|
221
|
+
runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
|
|
222
|
+
await this.pollChanges(sourceIndex);
|
|
223
|
+
if (options.copyFiles) await this.copySourceFiles(sourceIndex);
|
|
224
|
+
}, this.stopped);
|
|
225
|
+
} else {
|
|
226
|
+
runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
|
|
227
|
+
await this.scanSource(sourceIndex);
|
|
228
|
+
if (options.copyFiles) await this.copySourceFiles(sourceIndex);
|
|
229
|
+
}, this.stopped);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
125
232
|
|
|
126
|
-
|
|
127
|
-
|
|
233
|
+
// Full metadata scan (size, writeTime, path) of one source, applied to the index
|
|
234
|
+
private async scanSource(sourceIndex: number): Promise<void> {
|
|
235
|
+
let { source } = this.sources[sourceIndex];
|
|
236
|
+
let state = this.sourceStates[sourceIndex];
|
|
237
|
+
let scanStart = Date.now();
|
|
238
|
+
let files = await source.findInfo("");
|
|
239
|
+
let seen = new Set<string>();
|
|
240
|
+
for (let file of files) {
|
|
241
|
+
seen.add(file.path);
|
|
242
|
+
this.applyScanned(sourceIndex, file);
|
|
243
|
+
}
|
|
244
|
+
state.scannedCount = files.length;
|
|
245
|
+
// Index entries this source was the holder of, but that vanished from it (e.g. deleted
|
|
246
|
+
// while we were offline), come out of the index. Entries changed after the scan started
|
|
247
|
+
// are kept — the scan listing may simply predate them.
|
|
248
|
+
for (let [key, entry] of this.mem) {
|
|
249
|
+
if (entry.source !== sourceIndex) continue;
|
|
250
|
+
if (seen.has(key)) continue;
|
|
251
|
+
if (entry.changedAt >= scanStart) continue;
|
|
252
|
+
this.deleteIndexEntry(key);
|
|
253
|
+
}
|
|
254
|
+
state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
|
|
128
255
|
}
|
|
129
|
-
|
|
130
|
-
|
|
256
|
+
|
|
257
|
+
private applyScanned(sourceIndex: number, file: ArchiveFileInfo): void {
|
|
258
|
+
let [windowStart, windowEnd] = this.sources[sourceIndex].options.validWindow;
|
|
259
|
+
if (file.createTime < windowStart || file.createTime > windowEnd) return;
|
|
260
|
+
let existing = this.mem.get(file.path);
|
|
261
|
+
// The highest write time wins across all sources (ties keep the existing entry)
|
|
262
|
+
if (existing && file.createTime <= existing.writeTime) return;
|
|
263
|
+
this.setIndexEntry(file.path, { writeTime: file.createTime, size: file.size, source: sourceIndex });
|
|
131
264
|
}
|
|
132
265
|
|
|
133
|
-
private async
|
|
134
|
-
let
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let oldest = this.openBlobs.keys().next().value;
|
|
145
|
-
if (oldest === undefined) break;
|
|
146
|
-
let oldHandle = this.openBlobs.get(oldest);
|
|
147
|
-
this.openBlobs.delete(oldest);
|
|
148
|
-
void oldHandle?.then(h => h.close()).catch(() => { });
|
|
149
|
-
}
|
|
150
|
-
return handle;
|
|
266
|
+
private async pollChanges(sourceIndex: number): Promise<void> {
|
|
267
|
+
let { source } = this.sources[sourceIndex];
|
|
268
|
+
if (!source.getChangesAfter) return;
|
|
269
|
+
let state = this.sourceStates[sourceIndex];
|
|
270
|
+
let pollStart = Date.now();
|
|
271
|
+
let changes = await source.getChangesAfter(state.changesAfterTime);
|
|
272
|
+
for (let file of changes) {
|
|
273
|
+
this.applyScanned(sourceIndex, file);
|
|
274
|
+
}
|
|
275
|
+
state.scannedCount += changes.length;
|
|
276
|
+
state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
|
|
151
277
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
278
|
+
|
|
279
|
+
// Downloads the files a copyFiles source currently holds into the cacheReads sources (the
|
|
280
|
+
// local cache), preserving their modified times — so a newer local write always wins
|
|
281
|
+
private async copySourceFiles(sourceIndex: number): Promise<void> {
|
|
282
|
+
let { source } = this.sources[sourceIndex];
|
|
283
|
+
let targets: number[] = [];
|
|
284
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
285
|
+
if (i !== sourceIndex && this.sources[i].options.cacheReads) targets.push(i);
|
|
286
|
+
}
|
|
287
|
+
if (!targets.length) return;
|
|
288
|
+
for (let [key, entry] of this.mem) {
|
|
289
|
+
if (entry.source !== sourceIndex) continue;
|
|
290
|
+
let result = await source.get2(key);
|
|
291
|
+
if (!result) continue;
|
|
292
|
+
for (let target of targets) {
|
|
293
|
+
await this.sources[target].source.set(key, result.data, { lastModified: result.writeTime });
|
|
294
|
+
}
|
|
295
|
+
// Only move the entry's source if it wasn't changed while we copied
|
|
296
|
+
if (this.mem.get(key) === entry) {
|
|
297
|
+
this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: targets[0] });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
157
300
|
}
|
|
158
301
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
302
|
+
// findInfo and getChangesAfter list from the index, so they must wait for the required
|
|
303
|
+
// sources' initial scans (which might lag minutes) before the listing is trustworthy
|
|
304
|
+
private async waitForRequiredScans(): Promise<void> {
|
|
305
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
306
|
+
if (!this.sources[i].options.required) continue;
|
|
307
|
+
await this.sourceStates[i].initialScan.promise;
|
|
165
308
|
}
|
|
166
|
-
let dead = await this.deadBytes.get(entry.f) || 0;
|
|
167
|
-
await this.deadBytes.set(entry.f, dead + entry.l);
|
|
168
309
|
}
|
|
169
310
|
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
311
|
+
// A requested file isn't in the index: required sources that haven't finished their initial
|
|
312
|
+
// scan are checked directly, and changes-after sources are re-polled (at most every 5 seconds)
|
|
313
|
+
private async checkMissingKey(key: string): Promise<void> {
|
|
314
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
315
|
+
let { source, options } = this.sources[i];
|
|
316
|
+
let state = this.sourceStates[i];
|
|
317
|
+
if (options.required && !state.scanComplete) {
|
|
318
|
+
let info = await source.getInfo(key);
|
|
319
|
+
if (info) {
|
|
320
|
+
this.applyScanned(i, { path: key, createTime: info.writeTime, size: info.size });
|
|
178
321
|
}
|
|
179
|
-
|
|
180
|
-
this.currentBlobOffset = 0;
|
|
322
|
+
continue;
|
|
181
323
|
}
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
324
|
+
if (state.supportsChangesAfter && Date.now() - state.lastMissCheck > MISS_CHECK_INTERVAL) {
|
|
325
|
+
state.lastMissCheck = Date.now();
|
|
326
|
+
await this.pollChanges(i);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async getIndexEntry(key: string): Promise<IndexEntry | undefined> {
|
|
332
|
+
let entry = this.mem.get(key);
|
|
333
|
+
if (entry && this.sources[entry.source]) return entry;
|
|
334
|
+
if (entry) {
|
|
335
|
+
// The source list changed and this entry's source no longer exists; treat as missing
|
|
336
|
+
this.deleteIndexEntry(key);
|
|
337
|
+
}
|
|
338
|
+
await this.checkMissingKey(key);
|
|
339
|
+
return this.mem.get(key);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── data operations ──
|
|
343
|
+
|
|
344
|
+
public async get(key: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
|
|
345
|
+
let result = await this.get2(key, config);
|
|
346
|
+
return result && result.data || undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
public async get2(key: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined> {
|
|
350
|
+
await this.init();
|
|
351
|
+
let range = config?.range;
|
|
352
|
+
let overlayEntry = this.overlay.get(key);
|
|
353
|
+
if (overlayEntry) {
|
|
354
|
+
if (!overlayEntry.data) return undefined;
|
|
355
|
+
let data = overlayEntry.data;
|
|
356
|
+
if (range) {
|
|
357
|
+
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
185
358
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
359
|
+
return { data, writeTime: overlayEntry.t };
|
|
360
|
+
}
|
|
361
|
+
let entry = await this.getIndexEntry(key);
|
|
362
|
+
if (!entry) return undefined;
|
|
363
|
+
let { source, options } = this.sources[entry.source];
|
|
364
|
+
let result = await source.get2(key, { range });
|
|
365
|
+
if (!result) {
|
|
366
|
+
// The source no longer has it, so our index entry was stale
|
|
367
|
+
this.deleteIndexEntry(key);
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
// Ranged reads can't populate a cache (they're partial)
|
|
371
|
+
if (!options.cacheReads && !range) {
|
|
372
|
+
await this.cacheRead(key, result);
|
|
373
|
+
}
|
|
192
374
|
return result;
|
|
193
375
|
}
|
|
194
376
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
377
|
+
// The read didn't come from a cacheReads source, so write it into all of them (using them as
|
|
378
|
+
// caches), and the first one becomes the entry's new source
|
|
379
|
+
private async cacheRead(key: string, result: { data: Buffer; writeTime: number }): Promise<void> {
|
|
380
|
+
let first: number | undefined;
|
|
381
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
382
|
+
if (!this.sources[i].options.cacheReads) continue;
|
|
383
|
+
await this.sources[i].source.set(key, result.data, { lastModified: result.writeTime });
|
|
384
|
+
if (first === undefined) first = i;
|
|
200
385
|
}
|
|
386
|
+
if (first === undefined) return;
|
|
387
|
+
this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: first });
|
|
201
388
|
}
|
|
202
389
|
|
|
203
390
|
public async set(key: string, data: Buffer, config?: WriteConfig): Promise<void> {
|
|
204
391
|
await this.init();
|
|
205
|
-
|
|
392
|
+
let lastModified = config?.lastModified;
|
|
393
|
+
if (lastModified) {
|
|
394
|
+
assertValidLastModified(lastModified);
|
|
395
|
+
let overlayEntry = this.overlay.get(key);
|
|
396
|
+
let entry = this.mem.get(key);
|
|
397
|
+
let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
|
|
398
|
+
// An older write never overwrites a newer one (see IArchives.set)
|
|
399
|
+
if (lastModified < currentTime) return;
|
|
400
|
+
}
|
|
401
|
+
let writeTime = lastModified || Date.now();
|
|
206
402
|
if (config?.fast) {
|
|
207
403
|
let writeDelay = config.writeDelay || DEFAULT_FAST_WRITE_DELAY;
|
|
208
|
-
this.overlay.set(key, { data, t:
|
|
404
|
+
this.overlay.set(key, { data, t: writeTime, flushAt: Date.now() + writeDelay });
|
|
209
405
|
return;
|
|
210
406
|
}
|
|
211
407
|
this.overlay.delete(key);
|
|
212
|
-
|
|
213
|
-
|
|
408
|
+
await this.writeToSources(key, data, writeTime);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
|
|
412
|
+
let first: number | undefined;
|
|
413
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
414
|
+
if (this.sources[i].options.noWriteBack) continue;
|
|
415
|
+
await this.sources[i].source.set(key, data, { lastModified: writeTime });
|
|
416
|
+
if (first === undefined) first = i;
|
|
417
|
+
}
|
|
418
|
+
if (first === undefined) {
|
|
419
|
+
throw new Error(`Every source is noWriteBack, so writes cannot be stored (store ${this.folder})`);
|
|
420
|
+
}
|
|
421
|
+
this.setIndexEntry(key, { writeTime, size: data.length, source: first });
|
|
214
422
|
}
|
|
215
423
|
|
|
216
424
|
public async del(key: string, config?: WriteConfig): Promise<void> {
|
|
217
425
|
await this.init();
|
|
218
|
-
this.memCache.delete(key);
|
|
219
426
|
if (config?.fast) {
|
|
220
427
|
let writeDelay = config.writeDelay || DEFAULT_FAST_WRITE_DELAY;
|
|
221
428
|
this.overlay.set(key, { data: undefined, t: Date.now(), flushAt: Date.now() + writeDelay });
|
|
222
429
|
return;
|
|
223
430
|
}
|
|
224
431
|
this.overlay.delete(key);
|
|
225
|
-
|
|
226
|
-
if (!prev) return;
|
|
227
|
-
await this.index.remove(key);
|
|
228
|
-
await this.addDeadBytes(prev);
|
|
432
|
+
await this.deleteFromSources(key);
|
|
229
433
|
}
|
|
230
434
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (!overlayEntry.data) return undefined;
|
|
236
|
-
if (!range) return overlayEntry.data;
|
|
237
|
-
return overlayEntry.data.subarray(Math.min(range.start, overlayEntry.data.length), Math.min(range.end, overlayEntry.data.length));
|
|
435
|
+
private async deleteFromSources(key: string): Promise<void> {
|
|
436
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
437
|
+
if (this.sources[i].options.noWriteBack) continue;
|
|
438
|
+
await this.sources[i].source.del(key);
|
|
238
439
|
}
|
|
239
|
-
|
|
240
|
-
if (cached) {
|
|
241
|
-
if (!range) return cached;
|
|
242
|
-
return cached.subarray(Math.min(range.start, cached.length), Math.min(range.end, cached.length));
|
|
243
|
-
}
|
|
244
|
-
let entry = await this.index.get(key);
|
|
245
|
-
if (!entry) return undefined;
|
|
246
|
-
let start = range && Math.min(range.start, entry.l) || 0;
|
|
247
|
-
let end = range && Math.min(range.end, entry.l) || entry.l;
|
|
248
|
-
if (end <= start) return Buffer.alloc(0);
|
|
249
|
-
let handle = await this.getBlobHandle(entry.f);
|
|
250
|
-
let buffer = Buffer.alloc(end - start);
|
|
251
|
-
let { bytesRead } = await handle.read(buffer, 0, buffer.length, entry.o + start);
|
|
252
|
-
if (bytesRead !== buffer.length) {
|
|
253
|
-
throw new Error(`Expected ${buffer.length} bytes at ${entry.f}:${entry.o + start} for ${key}, read ${bytesRead}`);
|
|
254
|
-
}
|
|
255
|
-
if (!range && buffer.length <= MEMORY_CACHE_MAX_FILE) {
|
|
256
|
-
this.memCache.set(key, buffer);
|
|
257
|
-
}
|
|
258
|
-
return buffer;
|
|
440
|
+
this.deleteIndexEntry(key);
|
|
259
441
|
}
|
|
260
442
|
|
|
261
443
|
public async getInfo(key: string): Promise<{ writeTime: number; size: number } | undefined> {
|
|
@@ -265,97 +447,106 @@ export class BlobStore {
|
|
|
265
447
|
if (!overlayEntry.data) return undefined;
|
|
266
448
|
return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
|
|
267
449
|
}
|
|
268
|
-
let entry = await this.
|
|
450
|
+
let entry = await this.getIndexEntry(key);
|
|
269
451
|
if (!entry) return undefined;
|
|
270
|
-
return { writeTime: entry.
|
|
452
|
+
return { writeTime: entry.writeTime, size: entry.size };
|
|
271
453
|
}
|
|
272
454
|
|
|
273
455
|
public async findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
|
|
274
456
|
await this.init();
|
|
457
|
+
await this.waitForRequiredScans();
|
|
275
458
|
let infos = new Map<string, ArchiveFileInfo>();
|
|
276
|
-
for (let key of
|
|
459
|
+
for (let [key, entry] of this.mem) {
|
|
277
460
|
if (!key.startsWith(prefix)) continue;
|
|
278
|
-
|
|
279
|
-
let entry = await this.index.get(key);
|
|
280
|
-
if (!entry) continue;
|
|
281
|
-
infos.set(key, { path: key, createTime: entry.t, size: entry.l });
|
|
461
|
+
infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
|
|
282
462
|
}
|
|
283
463
|
for (let [key, overlayEntry] of this.overlay) {
|
|
284
464
|
if (!key.startsWith(prefix)) continue;
|
|
285
|
-
if (!overlayEntry.data)
|
|
465
|
+
if (!overlayEntry.data) {
|
|
466
|
+
infos.delete(key);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
286
469
|
infos.set(key, { path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
|
|
287
470
|
}
|
|
288
|
-
let files = Array.from(infos.values());
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
files
|
|
304
|
-
}
|
|
305
|
-
|
|
471
|
+
let files = applyFindInfoShape(Array.from(infos.values()), prefix, config);
|
|
472
|
+
sort(files, x => x.path);
|
|
473
|
+
return files;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// All files changed after the given time — fast, straight from the in-memory index. Filters on
|
|
477
|
+
// when WE learned of the change (changedAt), so files synchronized late (with old write times)
|
|
478
|
+
// are still reported. Deletions are not reported.
|
|
479
|
+
public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
|
|
480
|
+
await this.init();
|
|
481
|
+
await this.waitForRequiredScans();
|
|
482
|
+
let files: ArchiveFileInfo[] = [];
|
|
483
|
+
for (let [key, entry] of this.mem) {
|
|
484
|
+
if (entry.changedAt <= time) continue;
|
|
485
|
+
if (this.overlay.has(key)) continue;
|
|
486
|
+
files.push({ path: key, createTime: entry.writeTime, size: entry.size });
|
|
487
|
+
}
|
|
488
|
+
for (let [key, overlayEntry] of this.overlay) {
|
|
489
|
+
if (!overlayEntry.data) continue;
|
|
490
|
+
if (overlayEntry.t <= time) continue;
|
|
491
|
+
files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
|
|
306
492
|
}
|
|
307
493
|
sort(files, x => x.path);
|
|
308
494
|
return files;
|
|
309
495
|
}
|
|
310
496
|
|
|
311
|
-
|
|
312
|
-
|
|
497
|
+
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
498
|
+
await this.init();
|
|
499
|
+
return {
|
|
500
|
+
allScansComplete: this.sourceStates.every(x => x.scanComplete),
|
|
501
|
+
indexSize: this.mem.size,
|
|
502
|
+
sources: this.sources.map((x, i) => ({
|
|
503
|
+
debugName: x.source.getDebugName(),
|
|
504
|
+
options: x.options,
|
|
505
|
+
supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
|
|
506
|
+
initialScanComplete: this.sourceStates[i].scanComplete,
|
|
507
|
+
scannedCount: this.sourceStates[i].scannedCount,
|
|
508
|
+
})),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── large uploads ──
|
|
513
|
+
// Large uploads stream onto the local disk source directly (they may not fit in memory)
|
|
514
|
+
|
|
515
|
+
private getDiskSource(): { disk: ArchivesDisk; sourceIndex: number } {
|
|
516
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
517
|
+
let source = this.sources[i].source;
|
|
518
|
+
if (source instanceof ArchivesDisk) return { disk: source, sourceIndex: i };
|
|
519
|
+
}
|
|
520
|
+
throw new Error(`Large uploads require an ArchivesDisk source, and this store has none (store ${this.folder})`);
|
|
521
|
+
}
|
|
313
522
|
public async startLargeUpload(): Promise<string> {
|
|
314
523
|
await this.init();
|
|
315
|
-
|
|
316
|
-
let tmpPath = path.join(this.blobsDir, `upload_${id}.tmp`);
|
|
317
|
-
let fd = await fs.promises.open(tmpPath, "w");
|
|
318
|
-
this.largeUploads.set(id, { fd, tmpPath, size: 0 });
|
|
319
|
-
return id;
|
|
524
|
+
return await this.getDiskSource().disk.startLargeUpload();
|
|
320
525
|
}
|
|
321
526
|
public async appendLargeUpload(id: string, data: Buffer): Promise<void> {
|
|
322
|
-
|
|
323
|
-
if (!upload) throw new Error(`Unknown large upload ${id}`);
|
|
324
|
-
await upload.fd.write(data, 0, data.length);
|
|
325
|
-
upload.size += data.length;
|
|
527
|
+
await this.getDiskSource().disk.appendLargeUpload(id, data);
|
|
326
528
|
}
|
|
327
529
|
public async finishLargeUpload(id: string, key: string): Promise<void> {
|
|
328
|
-
let
|
|
329
|
-
|
|
330
|
-
this.largeUploads.delete(id);
|
|
331
|
-
await upload.fd.close();
|
|
332
|
-
let blobName = `large_${id}.bin`;
|
|
333
|
-
await fs.promises.rename(upload.tmpPath, this.blobPath(blobName));
|
|
334
|
-
this.memCache.delete(key);
|
|
530
|
+
let { disk, sourceIndex } = this.getDiskSource();
|
|
531
|
+
await disk.finishLargeUpload(id, key);
|
|
335
532
|
this.overlay.delete(key);
|
|
336
|
-
|
|
533
|
+
let info = await disk.getInfo(key);
|
|
534
|
+
if (info) {
|
|
535
|
+
this.setIndexEntry(key, { writeTime: info.writeTime, size: info.size, source: sourceIndex });
|
|
536
|
+
}
|
|
337
537
|
}
|
|
338
538
|
public async cancelLargeUpload(id: string): Promise<void> {
|
|
339
|
-
|
|
340
|
-
if (!upload) return;
|
|
341
|
-
this.largeUploads.delete(id);
|
|
342
|
-
await upload.fd.close();
|
|
343
|
-
await fs.promises.unlink(upload.tmpPath).catch(() => { });
|
|
539
|
+
await this.getDiskSource().disk.cancelLargeUpload(id);
|
|
344
540
|
}
|
|
345
541
|
|
|
346
|
-
private async flushOverlay(): Promise<void> {
|
|
542
|
+
private async flushOverlay(force?: boolean): Promise<void> {
|
|
347
543
|
let now = Date.now();
|
|
348
544
|
for (let [key, entry] of this.overlay) {
|
|
349
|
-
if (entry.flushAt > now) continue;
|
|
545
|
+
if (!force && entry.flushAt > now) continue;
|
|
350
546
|
if (entry.data) {
|
|
351
|
-
|
|
352
|
-
await this.setIndexEntry(key, { ...location, t: entry.t });
|
|
547
|
+
await this.writeToSources(key, entry.data, entry.t);
|
|
353
548
|
} else {
|
|
354
|
-
|
|
355
|
-
if (prev) {
|
|
356
|
-
await this.index.remove(key);
|
|
357
|
-
await this.addDeadBytes(prev);
|
|
358
|
-
}
|
|
549
|
+
await this.deleteFromSources(key);
|
|
359
550
|
}
|
|
360
551
|
// Only remove if it wasn't overwritten while we were flushing
|
|
361
552
|
if (this.overlay.get(key) === entry) {
|
|
@@ -363,41 +554,4 @@ export class BlobStore {
|
|
|
363
554
|
}
|
|
364
555
|
}
|
|
365
556
|
}
|
|
366
|
-
|
|
367
|
-
private async compact(): Promise<void> {
|
|
368
|
-
let currentBlob = this.blobName(this.currentBlobNumber);
|
|
369
|
-
for (let blobName of await this.deadBytes.getKeys()) {
|
|
370
|
-
if (blobName === currentBlob) continue;
|
|
371
|
-
let dead = await this.deadBytes.get(blobName) || 0;
|
|
372
|
-
let blobPath = this.blobPath(blobName);
|
|
373
|
-
let size = 0;
|
|
374
|
-
try {
|
|
375
|
-
size = fs.statSync(blobPath).size;
|
|
376
|
-
} catch {
|
|
377
|
-
await this.deadBytes.remove(blobName);
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
if (dead < size * COMPACTION_DEAD_FRACTION) continue;
|
|
381
|
-
|
|
382
|
-
console.log(`Compacting blob ${blobName} (${formatNumber(dead)}B dead of ${formatNumber(size)}B)`);
|
|
383
|
-
for (let key of await this.index.getKeys()) {
|
|
384
|
-
let entry = await this.index.get(key);
|
|
385
|
-
if (!entry || entry.f !== blobName) continue;
|
|
386
|
-
let data = await this.get(key);
|
|
387
|
-
if (!data) continue;
|
|
388
|
-
let location = await this.appendData(data);
|
|
389
|
-
// Only move it if it wasn't rewritten while we were reading
|
|
390
|
-
let latest = await this.index.get(key);
|
|
391
|
-
if (latest && latest.f === entry.f && latest.o === entry.o) {
|
|
392
|
-
await this.index.set(key, { ...location, t: latest.t });
|
|
393
|
-
} else {
|
|
394
|
-
// Our copy is stale, so its bytes are immediately dead
|
|
395
|
-
await this.deadBytes.set(location.f, (await this.deadBytes.get(location.f) || 0) + location.l);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
await this.closeBlobHandle(blobName);
|
|
399
|
-
await fs.promises.unlink(blobPath).catch(() => { });
|
|
400
|
-
await this.deadBytes.remove(blobName);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
557
|
}
|