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.
Files changed (41) hide show
  1. package/bin/storageserver.js +4 -0
  2. package/{teststorage → examplestorage}/browser.tsx +18 -22
  3. package/{teststorage/server.ts → examplestorage/exampleserver.ts} +5 -5
  4. package/index.d.ts +528 -78
  5. package/misc/https/dns.d.ts +7 -3
  6. package/misc/https/dns.ts +4 -9
  7. package/misc/https/hostServer.d.ts +6 -3
  8. package/misc/https/hostServer.ts +6 -6
  9. package/package.json +2 -1
  10. package/spec.txt +77 -42
  11. package/storage/ArchivesDisk.d.ts +65 -0
  12. package/storage/ArchivesDisk.ts +365 -0
  13. package/storage/IArchives.d.ts +95 -1
  14. package/storage/IArchives.ts +146 -1
  15. package/storage/backblaze.d.ts +14 -2
  16. package/storage/backblaze.ts +18 -2
  17. package/storage/remoteStorage/ArchivesRemote.d.ts +29 -17
  18. package/storage/remoteStorage/ArchivesRemote.ts +63 -63
  19. package/storage/remoteStorage/ArchivesUrl.d.ts +46 -0
  20. package/storage/remoteStorage/ArchivesUrl.ts +74 -0
  21. package/storage/remoteStorage/accessPage.tsx +111 -28
  22. package/storage/remoteStorage/blobStore.d.ts +79 -25
  23. package/storage/remoteStorage/blobStore.ts +441 -287
  24. package/storage/remoteStorage/cliArgs.d.ts +1 -0
  25. package/storage/remoteStorage/cliArgs.ts +9 -0
  26. package/storage/remoteStorage/createArchives.d.ts +64 -0
  27. package/storage/remoteStorage/createArchives.ts +395 -0
  28. package/storage/remoteStorage/grantAccess.js +4 -0
  29. package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
  30. package/storage/remoteStorage/grantAccessCli.ts +27 -0
  31. package/storage/remoteStorage/remoteConfig.d.ts +21 -0
  32. package/storage/remoteStorage/remoteConfig.ts +94 -0
  33. package/storage/remoteStorage/storageController.d.ts +19 -27
  34. package/storage/remoteStorage/storageController.ts +205 -136
  35. package/storage/remoteStorage/storageServer.d.ts +11 -0
  36. package/storage/remoteStorage/storageServer.ts +80 -93
  37. package/storage/remoteStorage/storageServerCli.d.ts +1 -0
  38. package/storage/remoteStorage/storageServerCli.ts +41 -0
  39. package/storage/remoteStorage/storageServerState.d.ts +37 -0
  40. package/storage/remoteStorage/storageServerState.ts +358 -0
  41. 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 { runInSerial, runInfinitePoll } from "socket-function/src/batching";
5
- import { timeInMinute, sort } from "socket-function/src/misc";
6
- import { formatNumber } from "socket-function/src/formatting/format";
7
- import { TransactionStorage } from "../TransactionStorage";
8
- import { JSONStorage } from "../JSONStorage";
9
- import { getFileStorageNested2 } from "../FileFolderAPI";
10
- import { ArchiveFileInfo } from "../IArchives";
11
-
12
- // Disk engine for the remote storage server. Files are packed into large append-only blob files
13
- // (instead of one file on disk per stored file), with a transaction-log index mapping
14
- // key -> (blob, offset, length). This keeps us efficient with many small files and scales to
15
- // terabytes: reads are single pread calls, writes are appends, and deleted space is reclaimed by
16
- // compacting blobs that are mostly dead.
17
-
18
- // Roll to a new blob file once the current one reaches this size
19
- const MAX_BLOB_SIZE = 4 * 1024 * 1024 * 1024;
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
- const COMPACTION_INTERVAL = timeInMinute * 10;
26
- // Compact a blob once this fraction of its bytes are dead (deleted/overwritten)
27
- const COMPACTION_DEAD_FRACTION = 0.5;
28
- const MAX_OPEN_BLOBS = 64;
29
-
30
- type IndexEntry = {
31
- // Blob file name
32
- f: string;
33
- // Offset + length within the blob
34
- o: number;
35
- l: number;
36
- // Write time
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 disk after writeDelay, coalescing writes 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
- class ByteLRU {
55
- private map = new Map<string, Buffer>();
56
- private bytes = 0;
57
- constructor(private budget: number, private maxEntry: number) { }
58
- public get(key: string): Buffer | undefined {
59
- let value = this.map.get(key);
60
- if (value) {
61
- this.map.delete(key);
62
- this.map.set(key, value);
63
- }
64
- return value;
65
- }
66
- public set(key: string, value: Buffer) {
67
- this.delete(key);
68
- if (value.length > this.maxEntry) return;
69
- this.map.set(key, value);
70
- this.bytes += value.length;
71
- while (this.bytes > this.budget && this.map.size > 0) {
72
- let oldest = this.map.keys().next().value;
73
- if (oldest === undefined) break;
74
- this.delete(oldest);
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
- export class BlobStore {
86
- constructor(private folder: string) { }
107
+ private stopped = { stop: false };
87
108
 
88
- private memCache = new ByteLRU(MEMORY_CACHE_BYTES, MEMORY_CACHE_MAX_FILE);
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 writeQueue = runInSerial(async (fnc: () => Promise<void>) => fnc());
91
- private openBlobs = new Map<string, Promise<fs.promises.FileHandle>>();
92
- private largeUploads = new Map<string, { fd: fs.promises.FileHandle; tmpPath: string; size: number }>();
93
- private nextLargeUploadId = 1;
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
- private currentBlobNumber = 0;
96
- private currentBlobOffset = 0;
97
- private currentBlobFd: fs.promises.FileHandle | undefined;
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
- private index!: JSONStorage<IndexEntry>;
100
- // Dead (deleted/overwritten) byte count per blob file, for compaction
101
- private deadBytes!: JSONStorage<number>;
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 blobsDir = path.join(this.folder, "blobs");
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
- public init = lazy(async () => {
106
- fs.mkdirSync(this.blobsDir, { recursive: true });
107
- let root = await getFileStorageNested2(path.resolve(this.folder));
108
- let indexRaw = await root.folder.getStorage("index");
109
- this.index = new JSONStorage<IndexEntry>(new TransactionStorage(indexRaw, "blobStoreIndex"));
110
- let metaRaw = await root.folder.getStorage("meta");
111
- this.deadBytes = new JSONStorage<number>(new TransactionStorage(metaRaw, "blobStoreDeadBytes"));
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
- for (let file of fs.readdirSync(this.blobsDir)) {
114
- let match = /^blob_(\d+)\.bin$/.exec(file);
115
- if (!match) continue;
116
- this.currentBlobNumber = Math.max(this.currentBlobNumber, +match[1]);
117
- }
118
- if (this.currentBlobNumber > 0) {
119
- this.currentBlobOffset = fs.statSync(path.join(this.blobsDir, this.blobName(this.currentBlobNumber))).size;
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
- runInfinitePoll(FAST_FLUSH_POLL, () => this.flushOverlay());
123
- runInfinitePoll(COMPACTION_INTERVAL, () => this.compact());
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
- private blobName(n: number) {
127
- return `blob_${String(n).padStart(6, "0")}.bin`;
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
- private blobPath(name: string) {
130
- return path.join(this.blobsDir, name);
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 getBlobHandle(name: string): Promise<fs.promises.FileHandle> {
134
- let cached = this.openBlobs.get(name);
135
- if (cached) {
136
- // Re-insert for LRU ordering
137
- this.openBlobs.delete(name);
138
- this.openBlobs.set(name, cached);
139
- return cached;
140
- }
141
- let handle = fs.promises.open(this.blobPath(name), "r");
142
- this.openBlobs.set(name, handle);
143
- while (this.openBlobs.size > MAX_OPEN_BLOBS) {
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
- private async closeBlobHandle(name: string) {
153
- let handle = this.openBlobs.get(name);
154
- if (!handle) return;
155
- this.openBlobs.delete(name);
156
- await handle.then(h => h.close()).catch(() => { });
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
- private async addDeadBytes(entry: IndexEntry) {
160
- // Large files get a dedicated blob file, so on delete we can unlink it immediately
161
- if (entry.f.startsWith("large_")) {
162
- await this.closeBlobHandle(entry.f);
163
- await fs.promises.unlink(this.blobPath(entry.f)).catch(() => { });
164
- return;
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
- // Appends data to the current blob file, returning where it landed
171
- private async appendData(data: Buffer): Promise<{ f: string; o: number; l: number }> {
172
- let result: { f: string; o: number; l: number } | undefined;
173
- await this.writeQueue(async () => {
174
- if (!this.currentBlobFd || this.currentBlobOffset >= MAX_BLOB_SIZE) {
175
- if (this.currentBlobFd) {
176
- await this.currentBlobFd.close();
177
- this.currentBlobFd = undefined;
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
- this.currentBlobNumber++;
180
- this.currentBlobOffset = 0;
322
+ continue;
181
323
  }
182
- if (!this.currentBlobFd) {
183
- this.currentBlobFd = await fs.promises.open(this.blobPath(this.blobName(this.currentBlobNumber)), "a");
184
- this.currentBlobOffset = (await this.currentBlobFd.stat()).size;
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
- let offset = this.currentBlobOffset;
187
- await this.currentBlobFd.write(data, 0, data.length);
188
- this.currentBlobOffset += data.length;
189
- result = { f: this.blobName(this.currentBlobNumber), o: offset, l: data.length };
190
- });
191
- if (!result) throw new Error(`Append did not run, this should be impossible`);
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
- private async setIndexEntry(key: string, entry: IndexEntry) {
196
- let prev = await this.index.get(key);
197
- await this.index.set(key, entry);
198
- if (prev) {
199
- await this.addDeadBytes(prev);
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
- this.memCache.set(key, data);
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: Date.now(), flushAt: Date.now() + writeDelay });
404
+ this.overlay.set(key, { data, t: writeTime, flushAt: Date.now() + writeDelay });
209
405
  return;
210
406
  }
211
407
  this.overlay.delete(key);
212
- let location = await this.appendData(data);
213
- await this.setIndexEntry(key, { ...location, t: Date.now() });
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
- let prev = await this.index.get(key);
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
- public async get(key: string, range?: { start: number; end: number }): Promise<Buffer | undefined> {
232
- await this.init();
233
- let overlayEntry = this.overlay.get(key);
234
- if (overlayEntry) {
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
- let cached = this.memCache.get(key);
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.index.get(key);
450
+ let entry = await this.getIndexEntry(key);
269
451
  if (!entry) return undefined;
270
- return { writeTime: entry.t, size: entry.l };
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 await this.index.getKeys()) {
459
+ for (let [key, entry] of this.mem) {
277
460
  if (!key.startsWith(prefix)) continue;
278
- if (this.overlay.has(key)) continue;
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) continue;
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
- if (config?.type === "folders") {
290
- let folders = new Map<string, ArchiveFileInfo>();
291
- for (let file of files) {
292
- let rest = file.path.slice(prefix.length);
293
- let restParts = rest.split("/");
294
- if (restParts.length < 2) continue;
295
- let folder: string;
296
- if (config.shallow) {
297
- folder = prefix + restParts[0];
298
- } else {
299
- folder = file.path.split("/").slice(0, -1).join("/");
300
- }
301
- folders.set(folder, { path: folder, createTime: file.createTime, size: file.size });
302
- }
303
- files = Array.from(folders.values());
304
- } else if (config?.shallow) {
305
- files = files.filter(file => !file.path.slice(prefix.length).includes("/"));
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
- // Large files stream into their own dedicated blob file, so concurrent small writes don't
312
- // interleave into the middle of them.
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
- let id = `${Date.now()}_${this.nextLargeUploadId++}`;
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
- let upload = this.largeUploads.get(id);
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 upload = this.largeUploads.get(id);
329
- if (!upload) throw new Error(`Unknown large upload ${id}`);
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
- await this.setIndexEntry(key, { f: blobName, o: 0, l: upload.size, t: Date.now() });
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
- let upload = this.largeUploads.get(id);
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
- let location = await this.appendData(entry.data);
352
- await this.setIndexEntry(key, { ...location, t: entry.t });
547
+ await this.writeToSources(key, entry.data, entry.t);
353
548
  } else {
354
- let prev = await this.index.get(key);
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
  }