sliftutils 1.7.9 → 1.7.11

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 (92) 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} +4 -6
  4. package/index.d.ts +770 -84
  5. package/misc/dist/environment.ts.cache +3 -3
  6. package/misc/dist/getSecret.ts.cache +139 -0
  7. package/misc/dist/strings.ts.cache +13 -0
  8. package/misc/dist/zip.ts.cache +9 -0
  9. package/misc/getSecret.ts +38 -16
  10. package/misc/https/cloudflareHelpers.d.ts +19 -0
  11. package/misc/https/cloudflareHelpers.ts +69 -0
  12. package/misc/https/dist/certs.ts.cache +115 -179
  13. package/misc/https/dist/cloudflareHelpers.ts.cache +68 -0
  14. package/misc/https/dist/dns.ts.cache +112 -100
  15. package/misc/https/dist/hostServer.ts.cache +130 -0
  16. package/misc/https/dist/httpsCerts.ts.cache +24 -5
  17. package/misc/https/dist/persistentLocalStorage.ts.cache +3 -2
  18. package/misc/https/dns.d.ts +41 -8
  19. package/misc/https/dns.ts +104 -106
  20. package/misc/https/hostServer.d.ts +0 -3
  21. package/misc/https/hostServer.ts +12 -8
  22. package/package.json +3 -2
  23. package/render-utils/dist/observer.tsx.cache +3 -2
  24. package/spec.txt +0 -55
  25. package/storage/ArchivesDisk.d.ts +67 -0
  26. package/storage/ArchivesDisk.ts +369 -0
  27. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +1242 -0
  28. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +513 -0
  29. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +352 -0
  30. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +306 -0
  31. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +435 -0
  32. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +58 -0
  33. package/storage/BulkDatabase2/dist/blockCache.ts.cache +179 -0
  34. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +149 -0
  35. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +101 -0
  36. package/storage/BulkDatabase2/dist/streamLog.ts.cache +227 -0
  37. package/storage/BulkDatabase2/dist/syncClient.ts.cache +104 -0
  38. package/storage/IArchives.d.ts +127 -1
  39. package/storage/IArchives.ts +201 -1
  40. package/storage/backblaze.d.ts +29 -2
  41. package/storage/backblaze.ts +214 -78
  42. package/storage/dist/ArchivesDisk.ts.cache +373 -0
  43. package/storage/dist/FileFolderAPI.tsx.cache +508 -65
  44. package/storage/dist/IArchives.ts.cache +44 -0
  45. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +8 -2
  46. package/storage/dist/JSONStorage.ts.cache +6 -2
  47. package/storage/dist/PendingManager.tsx.cache +1 -1
  48. package/storage/dist/TransactionStorage.ts.cache +231 -92
  49. package/storage/dist/backblaze.ts.cache +797 -0
  50. package/storage/dist/fileSystemPointer.ts.cache +36 -3
  51. package/storage/dist/remoteFileStorage.ts.cache +495 -0
  52. package/storage/remoteStorage/ArchivesRemote.d.ts +34 -18
  53. package/storage/remoteStorage/ArchivesRemote.ts +85 -65
  54. package/storage/remoteStorage/ArchivesUrl.d.ts +48 -0
  55. package/storage/remoteStorage/ArchivesUrl.ts +86 -0
  56. package/storage/remoteStorage/accessPage.tsx +111 -28
  57. package/storage/remoteStorage/blobStore.d.ts +128 -25
  58. package/storage/remoteStorage/blobStore.ts +757 -290
  59. package/storage/remoteStorage/cliArgs.d.ts +1 -0
  60. package/storage/remoteStorage/cliArgs.ts +9 -0
  61. package/storage/remoteStorage/createArchives.d.ts +85 -0
  62. package/storage/remoteStorage/createArchives.ts +612 -0
  63. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +197 -0
  64. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +85 -0
  65. package/storage/remoteStorage/dist/accessPage.tsx.cache +222 -0
  66. package/storage/remoteStorage/dist/blobStore.ts.cache +837 -0
  67. package/storage/remoteStorage/dist/cliArgs.ts.cache +16 -0
  68. package/storage/remoteStorage/dist/createArchives.ts.cache +610 -0
  69. package/storage/remoteStorage/dist/remoteConfig.ts.cache +206 -0
  70. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +223 -0
  71. package/storage/remoteStorage/dist/storageController.ts.cache +500 -0
  72. package/storage/remoteStorage/dist/storageServer.ts.cache +106 -0
  73. package/storage/remoteStorage/dist/storageServerCli.ts.cache +39 -0
  74. package/storage/remoteStorage/dist/storageServerState.ts.cache +518 -0
  75. package/storage/remoteStorage/grantAccess.js +4 -0
  76. package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
  77. package/storage/remoteStorage/grantAccessCli.ts +27 -0
  78. package/storage/remoteStorage/remoteConfig.d.ts +29 -0
  79. package/storage/remoteStorage/remoteConfig.ts +194 -0
  80. package/storage/remoteStorage/sourceWrapper.d.ts +41 -0
  81. package/storage/remoteStorage/sourceWrapper.ts +223 -0
  82. package/storage/remoteStorage/spec.md +31 -0
  83. package/storage/remoteStorage/storageController.d.ts +30 -27
  84. package/storage/remoteStorage/storageController.ts +297 -159
  85. package/storage/remoteStorage/storageServer.d.ts +6 -0
  86. package/storage/remoteStorage/storageServer.ts +88 -94
  87. package/storage/remoteStorage/storageServerCli.d.ts +1 -0
  88. package/storage/remoteStorage/storageServerCli.ts +38 -0
  89. package/storage/remoteStorage/storageServerState.d.ts +43 -0
  90. package/storage/remoteStorage/storageServerState.ts +540 -0
  91. package/testsite/server.ts +0 -2
  92. package/yarn.lock +11 -6
@@ -1,362 +1,802 @@
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";
3
+ import { runInfinitePoll, delay } from "socket-function/src/batching";
4
+ import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
6
5
  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;
6
+ import {
7
+ IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, assertValidLastModified,
8
+ windowAcceptsWrites, SyncActivity,
9
+ } from "../IArchives";
10
+ import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
11
+ import { ArchivesBackblaze } from "../backblaze";
12
+ import { ROUTING_FILE, getRoute, routeContains } from "./remoteConfig";
13
+ import { BulkDatabaseBase, noopReactiveDeps } from "../BulkDatabase2/BulkDatabaseBase";
14
+ import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../FileFolderAPI";
15
+
16
+ // The storage engine of the remote storage server. Data lives in synchronization sources (at
17
+ // minimum an ArchivesDisk, the local disk); BlobStore keeps an index of every file (path, last
18
+ // modified time, size, and which source currently holds the data) in a BulkDatabase2, and
19
+ // synchronizes the index from all sources (see ArchivesSource in IArchives.ts).
20
+ // Every startup fully rescans each source's metadata, so the index self-heals; the file with the
21
+ // highest write time wins across all sources, so multiple sources need no stacking order.
22
+
23
23
  export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
24
24
  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
- };
25
+ // Index changes are buffered in memory and written to the BulkDatabase2 in batches
26
+ const INDEX_FLUSH_INTERVAL = 1000 * 30;
27
+ // Sources that support getChangesAfter are polled this often
28
+ const CHANGES_POLL_INTERVAL = 1000 * 60;
29
+ // Sources that don't support getChangesAfter get a full metadata rescan this often
30
+ const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
31
+ // On a request for a file the index doesn't know, changes-after sources are re-polled, at most
32
+ // this often
33
+ const MISS_CHECK_INTERVAL = 1000 * 5;
34
+ // Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
35
+ const CHANGES_POLL_OVERLAP = timeInMinute;
36
+ const SCAN_RETRY_DELAY = 1000 * 30;
37
+ // Deletes are tombstones (an empty file IS a missing file): the size-0 index entry is what lets a
38
+ // deletion propagate/reconcile like any other write, and it expires after this long
39
+ const TOMBSTONE_EXPIRY = 1000 * 60 * 60 * 24 * 7;
40
+ const TOMBSTONE_CLEANUP_INTERVAL = 1000 * 60 * 60;
41
+ // While a metadata scan or full sync is running, its progress is logged this often
42
+ const SYNC_PROGRESS_LOG_INTERVAL = 1000 * 60;
43
+ const DISK_LIMIT_CHECK_INTERVAL = 1000 * 60;
44
+ // Full syncs download this many files concurrently (high-latency sources like backblaze would
45
+ // otherwise crawl one round-trip at a time)
46
+ const FULL_SYNC_PARALLEL = 8;
39
47
 
40
48
  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.
49
+ // Resolve once the write is in memory; flush to the sources after writeDelay, coalescing
50
+ // writes to the same key (only the latest is written). Data is lost if the process crashes first.
43
51
  fast?: boolean;
44
52
  writeDelay?: number;
53
+ // Stamps the write with this last-write time instead of now. Older than the current file's
54
+ // time no-ops; more than 15 minutes in the future throws. See IArchives.set.
55
+ lastModified?: number;
56
+ };
57
+
58
+ // What the storage server needs from a bucket's store. BlobStore implements it fully; ArchivesDisk
59
+ // also satisfies it (used directly for rawDisk buckets), minus the optional index-backed methods.
60
+ export type IBucketStore = {
61
+ get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined>;
62
+ get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
63
+ set(fileName: string, data: Buffer, config?: WriteConfig): Promise<void>;
64
+ del(fileName: string, config?: WriteConfig): Promise<void>;
65
+ getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined>;
66
+ findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
67
+ getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
68
+ getSyncStatus?(): Promise<ArchivesSyncStatus>;
69
+ getSyncProgress?(): { index: { fileCount: number; byteCount: number }; syncing: SyncActivity[] };
70
+ computeIndexTotals?(): Promise<{ fileCount: number; byteCount: number }>;
71
+ startLargeUpload(): Promise<string>;
72
+ appendLargeUpload(id: string, data: Buffer): Promise<void>;
73
+ finishLargeUpload(id: string, key: string): Promise<void>;
74
+ cancelLargeUpload(id: string): Promise<void>;
45
75
  };
46
76
 
47
77
  type OverlayEntry = {
48
- // undefined data means a pending delete
49
- data: Buffer | undefined;
78
+ // A zero-length buffer is a pending delete (tombstone)
79
+ data: Buffer;
50
80
  t: number;
51
81
  flushAt: number;
52
82
  };
53
83
 
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);
75
- }
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
- }
84
-
85
- export class BlobStore {
86
- constructor(private folder: string) { }
84
+ // One row of the BulkDatabase2 index (key is the file path)
85
+ type BlobIndexEntry = {
86
+ key: string;
87
+ writeTime: number;
88
+ size: number;
89
+ // Which synchronization source currently holds the data (an index into the sources array)
90
+ source: number;
91
+ };
92
+ type IndexEntry = {
93
+ writeTime: number;
94
+ size: number;
95
+ source: number;
96
+ // When WE last changed this entry (not the file's write time) — what getChangesAfter filters
97
+ // on, so late-arriving files with old write times are still reported as changes
98
+ changedAt: number;
99
+ // In-memory only (not persisted): when the file was last served, for readerDiskLimit's LRU
100
+ // eviction. Starts as changedAt on load.
101
+ lastAccess: number;
102
+ };
87
103
 
88
- private memCache = new ByteLRU(MEMORY_CACHE_BYTES, MEMORY_CACHE_MAX_FILE);
89
- 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;
104
+ type SourceState = {
105
+ supportsChangesAfter: boolean;
106
+ initialScan: ReturnType<typeof promiseObj>;
107
+ scanComplete: boolean;
108
+ // Files seen in this source's scans / change polls so far
109
+ scannedCount: number;
110
+ // Watermark for getChangesAfter polls
111
+ changesAfterTime: number;
112
+ lastMissCheck: number;
113
+ };
94
114
 
95
- private currentBlobNumber = 0;
96
- private currentBlobOffset = 0;
97
- private currentBlobFd: fs.promises.FileHandle | undefined;
115
+ export class BlobStore implements IBucketStore {
116
+ constructor(
117
+ private folder: string,
118
+ private sources: ArchivesSource[],
119
+ private config?: {
120
+ // Called whenever a key's index entry changes (our own writes AND files pulled in via
121
+ // synchronization) — how the storage server notices routing config updates.
122
+ onIndexChanged?: (key: string) => void;
123
+ // LRU-bound the disk (base source) to this many bytes; see CommonConfig.readerDiskLimit
124
+ readerDiskLimit?: number;
125
+ }
126
+ ) { }
98
127
 
99
- private index!: JSONStorage<IndexEntry>;
100
- // Dead (deleted/overwritten) byte count per blob file, for compaction
101
- private deadBytes!: JSONStorage<number>;
128
+ private stopped = { stop: false };
102
129
 
103
- private blobsDir = path.join(this.folder, "blobs");
130
+ // The index's BulkDatabase2 files live under <folder>/index
131
+ private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex", noopReactiveDeps, async (p: string) => {
132
+ let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(path.join(this.folder, "index"));
133
+ for (let part of p.split("/")) {
134
+ if (part) base = await base.getDirectoryHandle(part, { create: true });
135
+ }
136
+ return wrapHandle(base);
137
+ });
138
+ // The in-memory copy of the index. All reads are served from it; changes are buffered in
139
+ // dirty and flushed to the BulkDatabase2 in batches (undefined = delete).
140
+ private mem = new Map<string, IndexEntry>();
141
+ // Live totals over mem (tombstones excluded), adjusted on every mutation and recomputed on
142
+ // load - so any drift heals on restart. computeIndexTotals gives the walk-the-index truth.
143
+ private indexFileCount = 0;
144
+ private indexByteCount = 0;
145
+ // The same totals per holding source (index 0 = our disk, which readerDiskLimit bounds)
146
+ private sourceFileCounts = this.sources.map(() => 0);
147
+ private sourceByteCounts = this.sources.map(() => 0);
148
+ // Background scans / full syncs currently in progress (a Set - one source can have a change
149
+ // poll's full sync and a rescan overlapping)
150
+ private syncActivities = new Set<SyncActivity>();
151
+ private dirty = new Map<string, IndexEntry | undefined>();
152
+ private overlay = new Map<string, OverlayEntry>();
153
+ private sourceStates = this.sources.map((): SourceState => ({
154
+ supportsChangesAfter: false,
155
+ initialScan: promiseObj(),
156
+ scanComplete: false,
157
+ scannedCount: 0,
158
+ changesAfterTime: 0,
159
+ lastMissCheck: 0,
160
+ }));
104
161
 
105
162
  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"));
163
+ await this.loadIndex();
164
+ for (let i = 0; i < this.sources.length; i++) {
165
+ void this.runSourceSync(i);
166
+ }
167
+ runInfinitePoll(FAST_FLUSH_POLL, () => this.flushOverlay(), this.stopped);
168
+ runInfinitePoll(INDEX_FLUSH_INTERVAL, () => this.flushIndex(), this.stopped);
169
+ runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.stopped);
170
+ if (this.config?.readerDiskLimit) {
171
+ runInfinitePoll(DISK_LIMIT_CHECK_INTERVAL, () => this.enforceDiskLimit(), this.stopped);
172
+ }
173
+ });
174
+
175
+ // Stops all synchronization scans/polls and flushes pending writes. Used when a bucket's
176
+ // routing config changes and the store is rebuilt with new sources.
177
+ public async dispose(): Promise<void> {
178
+ this.stopped.stop = true;
179
+ await this.flushOverlay(true);
180
+ await this.flushIndex();
181
+ }
112
182
 
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]);
183
+ private async loadIndex(): Promise<void> {
184
+ let [writeTimes, sizes, sources] = await Promise.all([
185
+ this.index.getColumn("writeTime"),
186
+ this.index.getColumn("size"),
187
+ this.index.getColumn("source"),
188
+ ]);
189
+ let sizeMap = new Map(sizes.map(x => [x.key, x.value]));
190
+ let sourceMap = new Map(sources.map(x => [x.key, x.value]));
191
+ for (let entry of writeTimes) {
192
+ let size = sizeMap.get(entry.key);
193
+ let source = sourceMap.get(entry.key);
194
+ // Explicit checks, as 0 is a valid size and a valid source number
195
+ if (size === undefined || source === undefined) continue;
196
+ let full: IndexEntry = { writeTime: entry.value, size, source, changedAt: entry.time, lastAccess: entry.time };
197
+ this.mem.set(entry.key, full);
198
+ this.countEntry(full, 1);
117
199
  }
118
- if (this.currentBlobNumber > 0) {
119
- this.currentBlobOffset = fs.statSync(path.join(this.blobsDir, this.blobName(this.currentBlobNumber))).size;
200
+ }
201
+
202
+ private countEntry(entry: IndexEntry | undefined, direction: number): void {
203
+ if (!entry || entry.size === 0) return;
204
+ this.indexFileCount += direction;
205
+ this.indexByteCount += entry.size * direction;
206
+ // A stale entry can reference a source that no longer exists (getIndexEntry cleans those)
207
+ if (this.sources[entry.source]) {
208
+ this.sourceFileCounts[entry.source] += direction;
209
+ this.sourceByteCounts[entry.source] += entry.size * direction;
120
210
  }
211
+ }
121
212
 
122
- runInfinitePoll(FAST_FLUSH_POLL, () => this.flushOverlay());
123
- runInfinitePoll(COMPACTION_INTERVAL, () => this.compact());
124
- });
213
+ private setIndexEntry(key: string, entry: { writeTime: number; size: number; source: number }): void {
214
+ let full: IndexEntry = { ...entry, changedAt: Date.now(), lastAccess: Date.now() };
215
+ this.countEntry(this.mem.get(key), -1);
216
+ this.countEntry(full, 1);
217
+ this.mem.set(key, full);
218
+ this.dirty.set(key, full);
219
+ this.config?.onIndexChanged?.(key);
220
+ }
221
+ private deleteIndexEntry(key: string): void {
222
+ let existing = this.mem.get(key);
223
+ if (!existing) return;
224
+ this.countEntry(existing, -1);
225
+ this.mem.delete(key);
226
+ this.dirty.set(key, undefined);
227
+ }
228
+
229
+ /** The cheap always-current totals plus any in-progress background synchronization. */
230
+ public getSyncProgress(): {
231
+ index: { fileCount: number; byteCount: number };
232
+ sources: { debugName: string; fileCount: number; byteCount: number }[];
233
+ readerDiskLimit?: number;
234
+ syncing: SyncActivity[];
235
+ } {
236
+ return {
237
+ index: { fileCount: this.indexFileCount, byteCount: this.indexByteCount },
238
+ sources: this.sources.map((x, i) => ({
239
+ debugName: x.source.getDebugName(),
240
+ fileCount: this.sourceFileCounts[i],
241
+ byteCount: this.sourceByteCounts[i],
242
+ })),
243
+ readerDiskLimit: this.config?.readerDiskLimit,
244
+ syncing: [...this.syncActivities],
245
+ };
246
+ }
125
247
 
126
- private blobName(n: number) {
127
- return `blob_${String(n).padStart(6, "0")}.bin`;
248
+ /** Walks the whole index for exact totals - more expensive than getSyncProgress, but immune to
249
+ * any drift in the maintained counters (and loads the index first, so it's never cold zeros). */
250
+ public async computeIndexTotals(): Promise<{
251
+ fileCount: number;
252
+ byteCount: number;
253
+ sources: { debugName: string; fileCount: number; byteCount: number }[];
254
+ }> {
255
+ await this.init();
256
+ let fileCount = 0;
257
+ let byteCount = 0;
258
+ let sources = this.sources.map(x => ({ debugName: x.source.getDebugName(), fileCount: 0, byteCount: 0 }));
259
+ for (let entry of this.mem.values()) {
260
+ if (entry.size === 0) continue;
261
+ fileCount++;
262
+ byteCount += entry.size;
263
+ let source = sources[entry.source];
264
+ if (source) {
265
+ source.fileCount++;
266
+ source.byteCount += entry.size;
267
+ }
268
+ }
269
+ return { fileCount, byteCount, sources };
128
270
  }
129
- private blobPath(name: string) {
130
- return path.join(this.blobsDir, name);
271
+
272
+ private async flushIndex(): Promise<void> {
273
+ if (!this.dirty.size) return;
274
+ let dirty = this.dirty;
275
+ this.dirty = new Map();
276
+ let writes: BlobIndexEntry[] = [];
277
+ let deletes: string[] = [];
278
+ for (let [key, entry] of dirty) {
279
+ if (entry) {
280
+ writes.push({ key, writeTime: entry.writeTime, size: entry.size, source: entry.source });
281
+ } else {
282
+ deletes.push(key);
283
+ }
284
+ }
285
+ if (writes.length) await this.index.writeBatch(writes);
286
+ if (deletes.length) await this.index.deleteBatch(deletes);
131
287
  }
132
288
 
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;
289
+ // ── synchronization ──
290
+
291
+ private async runSourceSync(sourceIndex: number): Promise<void> {
292
+ let { source, noFullSync } = this.sources[sourceIndex];
293
+ let state = this.sourceStates[sourceIndex];
294
+ let listing: Map<string, number> | undefined;
295
+ while (!this.stopped.stop) {
296
+ try {
297
+ let config = await source.getConfig();
298
+ state.supportsChangesAfter = !!(config.supportsChangesAfter && source.getChangesAfter);
299
+ listing = await this.scanSource(sourceIndex);
300
+ break;
301
+ } catch (e) {
302
+ console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
303
+ await delay(SCAN_RETRY_DELAY);
304
+ }
305
+ }
306
+ state.scanComplete = true;
307
+ state.initialScan.resolve(undefined);
308
+ if (this.stopped.stop) return;
309
+ if (listing) {
310
+ await this.reconcileSource(sourceIndex, listing);
311
+ }
312
+ if (!noFullSync) {
313
+ try {
314
+ await this.copySourceFiles(sourceIndex);
315
+ } catch (e) {
316
+ console.error(`Copying files from sync source ${source.getDebugName()} failed:`, e);
317
+ }
140
318
  }
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(() => { });
319
+ if (state.supportsChangesAfter) {
320
+ runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
321
+ await this.pollChanges(sourceIndex);
322
+ if (!noFullSync) await this.copySourceFiles(sourceIndex);
323
+ }, this.stopped);
324
+ // Change polls only show what the source HAS, never what it's missing, so pushes run on
325
+ // the full-rescan cadence (findInfo on an index-backed source is cheap)
326
+ runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
327
+ let files = await source.findInfo("");
328
+ await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
329
+ }, this.stopped);
330
+ } else {
331
+ runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
332
+ let rescan = await this.scanSource(sourceIndex);
333
+ await this.reconcileSource(sourceIndex, rescan);
334
+ if (!noFullSync) await this.copySourceFiles(sourceIndex);
335
+ }, this.stopped);
149
336
  }
150
- return handle;
151
337
  }
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(() => { });
338
+
339
+ // Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the
340
+ // source's listing (path -> write time), which reconcileSource uses for the push direction.
341
+ private async scanSource(sourceIndex: number): Promise<Map<string, number>> {
342
+ let { source } = this.sources[sourceIndex];
343
+ let state = this.sourceStates[sourceIndex];
344
+ let scanStart = Date.now();
345
+ let scanLogged = false;
346
+ let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
347
+ this.syncActivities.add(activity);
348
+ let progressTimer = setInterval(() => {
349
+ scanLogged = true;
350
+ console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.folder})`);
351
+ }, SYNC_PROGRESS_LOG_INTERVAL);
352
+ (progressTimer as { unref?: () => void }).unref?.();
353
+ let files: ArchiveFileInfo[];
354
+ try {
355
+ files = await source.findInfo("");
356
+ } finally {
357
+ clearInterval(progressTimer);
358
+ this.syncActivities.delete(activity);
359
+ }
360
+ if (scanLogged) {
361
+ console.log(`Metadata scan of ${source.getDebugName()} finished: ${files.length} files in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder})`);
362
+ }
363
+ let seen = new Map<string, number>();
364
+ for (let file of files) {
365
+ seen.set(file.path, file.createTime);
366
+ this.applyScanned(sourceIndex, file);
367
+ }
368
+ state.scannedCount = files.length;
369
+ // Index entries this source was the holder of, but that vanished from it (e.g. deleted
370
+ // while we were offline), come out of the index. Entries changed after the scan started
371
+ // are kept — the scan listing may simply predate them. Tombstones have no physical file
372
+ // for a listing to vouch for, so they're exempt (cleanupTombstones expires them instead).
373
+ for (let [key, entry] of this.mem) {
374
+ if (entry.source !== sourceIndex) continue;
375
+ if (entry.size === 0) continue;
376
+ if (seen.has(key)) continue;
377
+ if (entry.changedAt >= scanStart) continue;
378
+ this.deleteIndexEntry(key);
379
+ }
380
+ state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
381
+ return seen;
157
382
  }
158
383
 
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;
384
+ // The push direction of synchronization: everything we know that the source is missing (or
385
+ // holds an older copy of) is written to it including deletions, as tombstone writes. This is
386
+ // what heals a source whose background writes failed (e.g. it was down): the next scan sees
387
+ // what's missing and re-sends it.
388
+ private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
389
+ let { source, validWindow, route } = this.sources[sourceIndex];
390
+ let acceptsWrites = windowAcceptsWrites(validWindow);
391
+ try {
392
+ for (let [key, entry] of this.mem) {
393
+ if (this.stopped.stop) return;
394
+ if (entry.source === sourceIndex) continue;
395
+ // Past-window sources only receive the routing file (see writeToSources), and
396
+ // sharded sources only the keys routing into them
397
+ if (key !== ROUTING_FILE) {
398
+ if (!acceptsWrites) continue;
399
+ if (!routeContains(route, getRoute(key))) continue;
400
+ }
401
+ let theirTime = listing.get(key);
402
+ if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
403
+ if (entry.size === 0) {
404
+ // A deletion only needs pushing while the source still holds an older copy
405
+ if (theirTime === undefined) continue;
406
+ await source.set(key, Buffer.alloc(0), { lastModified: entry.writeTime });
407
+ continue;
408
+ }
409
+ let result = await this.sources[entry.source].source.get2(key);
410
+ if (!result) continue;
411
+ await source.set(key, result.data, { lastModified: result.writeTime });
412
+ }
413
+ } catch (e) {
414
+ // Abort the pass instead of logging per file; the next scan cycle retries
415
+ console.error(`Reconciling sync source ${source.getDebugName()} failed: ${(e as Error).stack ?? e}`);
165
416
  }
166
- let dead = await this.deadBytes.get(entry.f) || 0;
167
- await this.deadBytes.set(entry.f, dead + entry.l);
168
417
  }
169
418
 
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;
419
+ private applyScanned(sourceIndex: number, file: ArchiveFileInfo): void {
420
+ let { validWindow, route } = this.sources[sourceIndex];
421
+ let [windowStart, windowEnd] = validWindow;
422
+ if (file.createTime < windowStart || file.createTime > windowEnd) return;
423
+ // A partially-overlapping shard's listing includes keys outside our route; ignore them
424
+ if (file.path !== ROUTING_FILE && !routeContains(route, getRoute(file.path))) return;
425
+ let existing = this.mem.get(file.path);
426
+ // The highest write time wins across all sources (ties keep the existing entry)
427
+ if (existing && file.createTime <= existing.writeTime) return;
428
+ this.setIndexEntry(file.path, { writeTime: file.createTime, size: file.size, source: sourceIndex });
429
+ }
430
+
431
+ private async pollChanges(sourceIndex: number): Promise<void> {
432
+ let { source } = this.sources[sourceIndex];
433
+ if (!source.getChangesAfter) return;
434
+ let state = this.sourceStates[sourceIndex];
435
+ let pollStart = Date.now();
436
+ let changes = await source.getChangesAfter(state.changesAfterTime);
437
+ for (let file of changes) {
438
+ this.applyScanned(sourceIndex, file);
439
+ }
440
+ state.scannedCount += changes.length;
441
+ state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
442
+ }
443
+
444
+ // Downloads the files a source currently holds onto our own base source (the local disk),
445
+ // preserving their modified times — so a newer local write always wins. Skipped for noFullSync
446
+ // sources (fronting a large database without copying it); reads still down-cache lazily.
447
+ private async copySourceFiles(sourceIndex: number): Promise<void> {
448
+ if (sourceIndex === 0) return;
449
+ let { source } = this.sources[sourceIndex];
450
+ let pending: { key: string; entry: IndexEntry }[] = [];
451
+ let totalBytes = 0;
452
+ for (let [key, entry] of this.mem) {
453
+ if (entry.source !== sourceIndex) continue;
454
+ if (entry.size === 0) continue;
455
+ pending.push({ key, entry });
456
+ totalBytes += entry.size;
457
+ }
458
+ if (!pending.length) return;
459
+ let activity: SyncActivity = {
460
+ type: "fullSync",
461
+ sourceDebugName: source.getDebugName(),
462
+ startTime: Date.now(),
463
+ doneFiles: 0,
464
+ totalFiles: pending.length,
465
+ doneBytes: 0,
466
+ totalBytes,
467
+ };
468
+ this.syncActivities.add(activity);
469
+ let progressLogged = false;
470
+ let logProgress = () => {
471
+ progressLogged = true;
472
+ console.log(`Full sync from ${source.getDebugName()} (store ${this.folder}): ${activity.doneFiles}/${pending.length} files (${((activity.doneFiles || 0) / pending.length * 100).toFixed(1)}%), ${formatNumber(activity.doneBytes || 0)}B/${formatNumber(totalBytes)}B (${(totalBytes && (activity.doneBytes || 0) / totalBytes * 100 || 100).toFixed(1)}%)`);
473
+ };
474
+ let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
475
+ (progressTimer as { unref?: () => void }).unref?.();
476
+ try {
477
+ let nextIndex = 0;
478
+ let failed = false;
479
+ let copyWorker = async () => {
480
+ while (!failed && !this.stopped.stop) {
481
+ let index = nextIndex++;
482
+ if (index >= pending.length) return;
483
+ let { key, entry } = pending[index];
484
+ let result = await source.get2(key);
485
+ if (result) {
486
+ await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime });
487
+ // Only move the entry's source if it wasn't changed while we copied
488
+ if (this.mem.get(key) === entry) {
489
+ this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: 0 });
490
+ }
491
+ }
492
+ activity.doneFiles = (activity.doneFiles || 0) + 1;
493
+ activity.doneBytes = (activity.doneBytes || 0) + entry.size;
178
494
  }
179
- this.currentBlobNumber++;
180
- this.currentBlobOffset = 0;
495
+ };
496
+ let workers: Promise<void>[] = [];
497
+ for (let i = 0; i < Math.min(FULL_SYNC_PARALLEL, pending.length); i++) {
498
+ workers.push(copyWorker().catch((e: Error) => {
499
+ // Stop the other workers pulling new files, then surface the error
500
+ failed = true;
501
+ throw e;
502
+ }));
181
503
  }
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;
504
+ await Promise.all(workers);
505
+ } finally {
506
+ clearInterval(progressTimer);
507
+ this.syncActivities.delete(activity);
508
+ // A sync slow enough to have logged progress also logs its completion
509
+ if (progressLogged) {
510
+ logProgress();
185
511
  }
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`);
192
- return result;
512
+ }
513
+ }
514
+
515
+ // findInfo and getChangesAfter list from the index, so they must wait for our own base
516
+ // source's initial scan (which might lag minutes) before the listing is trustworthy. The base
517
+ // (local disk) is implicitly required - remote sources are not, they come and go.
518
+ private async waitForRequiredScans(): Promise<void> {
519
+ await this.sourceStates[0].initialScan.promise;
193
520
  }
194
521
 
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);
522
+ // A requested file isn't in the index: our own base source (implicitly required) is checked
523
+ // directly if its initial scan hasn't finished, and changes-after sources are re-polled (at
524
+ // most every 5 seconds)
525
+ private async checkMissingKey(key: string): Promise<void> {
526
+ for (let i = 0; i < this.sources.length; i++) {
527
+ let { source } = this.sources[i];
528
+ let state = this.sourceStates[i];
529
+ if (i === 0 && !state.scanComplete) {
530
+ let info = await source.getInfo(key);
531
+ if (info) {
532
+ this.applyScanned(i, { path: key, createTime: info.writeTime, size: info.size });
533
+ }
534
+ continue;
535
+ }
536
+ if (state.supportsChangesAfter && Date.now() - state.lastMissCheck > MISS_CHECK_INTERVAL) {
537
+ state.lastMissCheck = Date.now();
538
+ await this.pollChanges(i);
539
+ }
200
540
  }
201
541
  }
202
542
 
203
- public async set(key: string, data: Buffer, config?: WriteConfig): Promise<void> {
543
+ private async getIndexEntry(key: string): Promise<IndexEntry | undefined> {
544
+ let entry = this.mem.get(key);
545
+ if (entry && this.sources[entry.source]) return entry;
546
+ if (entry) {
547
+ // The source list changed and this entry's source no longer exists; treat as missing
548
+ this.deleteIndexEntry(key);
549
+ }
550
+ await this.checkMissingKey(key);
551
+ return this.mem.get(key);
552
+ }
553
+
554
+ // ── data operations ──
555
+
556
+ public async get(key: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
557
+ let result = await this.get2(key, config);
558
+ return result && result.data || undefined;
559
+ }
560
+
561
+ public async get2(key: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
204
562
  await this.init();
205
- this.memCache.set(key, data);
206
- if (config?.fast) {
207
- let writeDelay = config.writeDelay || DEFAULT_FAST_WRITE_DELAY;
208
- this.overlay.set(key, { data, t: Date.now(), flushAt: Date.now() + writeDelay });
209
- return;
563
+ let range = config?.range;
564
+ let overlayEntry = this.overlay.get(key);
565
+ if (overlayEntry) {
566
+ // An empty file IS a missing file (tombstone)
567
+ if (overlayEntry.data.length === 0) return undefined;
568
+ let data = overlayEntry.data;
569
+ let size = data.length;
570
+ if (range) {
571
+ data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
572
+ }
573
+ return { data, writeTime: overlayEntry.t, size };
210
574
  }
211
- this.overlay.delete(key);
212
- let location = await this.appendData(data);
213
- await this.setIndexEntry(key, { ...location, t: Date.now() });
575
+ let entry = await this.getIndexEntry(key);
576
+ if (!entry) return undefined;
577
+ if (entry.size === 0) return undefined;
578
+ entry.lastAccess = Date.now();
579
+ let holder = entry.source;
580
+ let result: { data: Buffer; writeTime: number; size: number } | undefined;
581
+ let holderError: Error | undefined;
582
+ try {
583
+ result = await this.sources[holder].source.get2(key, { range });
584
+ } catch (e) {
585
+ holderError = e as Error;
586
+ }
587
+ if (result) {
588
+ // Ranged reads can't populate a cache (they're partial)
589
+ if (holder !== 0 && !range) {
590
+ await this.cacheRead(key, result);
591
+ }
592
+ return result;
593
+ }
594
+ // The holder is down or lost the file. ANY other source's copy beats no value - even an
595
+ // OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
596
+ for (let i = 0; i < this.sources.length; i++) {
597
+ if (i === holder) continue;
598
+ let fallback: typeof result;
599
+ try {
600
+ fallback = await this.sources[i].source.get2(key);
601
+ } catch {
602
+ continue;
603
+ }
604
+ if (!fallback) continue;
605
+ await this.cacheRead(key, fallback);
606
+ let data = fallback.data;
607
+ if (range) {
608
+ data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
609
+ }
610
+ return { data, writeTime: fallback.writeTime, size: fallback.size };
611
+ }
612
+ if (holderError) throw holderError;
613
+ // The holder answered "not there" and no other source has it either: the entry was stale
614
+ this.deleteIndexEntry(key);
615
+ return undefined;
214
616
  }
215
617
 
216
- public async del(key: string, config?: WriteConfig): Promise<void> {
618
+ // The read's bytes came from a remote source, so write them onto our own base source (the
619
+ // local disk), which becomes the entry's new holder - reads only pay the remote fetch once
620
+ private async cacheRead(key: string, result: { data: Buffer; writeTime: number }): Promise<void> {
621
+ await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime });
622
+ this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: 0 });
623
+ }
624
+
625
+ public async set(key: string, data: Buffer, config?: WriteConfig): Promise<void> {
217
626
  await this.init();
218
- this.memCache.delete(key);
627
+ let lastModified = config?.lastModified;
628
+ if (lastModified) {
629
+ assertValidLastModified(lastModified);
630
+ let overlayEntry = this.overlay.get(key);
631
+ let entry = this.mem.get(key);
632
+ let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
633
+ // An older write never overwrites a newer one (see IArchives.set)
634
+ if (lastModified < currentTime) return;
635
+ }
636
+ let writeTime = lastModified || Date.now();
219
637
  if (config?.fast) {
220
638
  let writeDelay = config.writeDelay || DEFAULT_FAST_WRITE_DELAY;
221
- this.overlay.set(key, { data: undefined, t: Date.now(), flushAt: Date.now() + writeDelay });
639
+ this.overlay.set(key, { data, t: writeTime, flushAt: Date.now() + writeDelay });
222
640
  return;
223
641
  }
224
642
  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);
643
+ await this.writeToSources(key, data, writeTime);
229
644
  }
230
645
 
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));
646
+ public async del(key: string, config?: WriteConfig): Promise<void> {
647
+ // Deletes are tombstone writes (an empty file IS a missing file): the size-0 index entry is
648
+ // ordered by write time like any other write, propagates through synchronization, and
649
+ // expires after TOMBSTONE_EXPIRY
650
+ await this.set(key, Buffer.alloc(0), config);
651
+ }
652
+
653
+ private getWritableSources(config?: { ignoreWindow?: boolean }): number[] {
654
+ let writable: number[] = [];
655
+ for (let i = 0; i < this.sources.length; i++) {
656
+ if (!config?.ignoreWindow && !windowAcceptsWrites(this.sources[i].validWindow)) continue;
657
+ writable.push(i);
238
658
  }
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));
659
+ return writable;
660
+ }
661
+
662
+ private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
663
+ // The routing file is exempt from the valid-window and route write filters: the CONFIG
664
+ // must keep flowing to every source, or a client probing one of them first would adopt a
665
+ // stale config. Only data writes stop.
666
+ let isRouting = key === ROUTING_FILE;
667
+ let writable = this.getWritableSources({ ignoreWindow: isRouting });
668
+ let first = writable.shift();
669
+ if (first === undefined) {
670
+ throw new Error(`No source accepts writes (every source's valid window is in the past), so writes cannot be stored (store ${this.folder})`);
243
671
  }
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}`);
672
+ // Only our own (first) source blocks the write. Downstream sources are written in the
673
+ // background: a down downstream source must not fail or stall writes, and reconcileSource
674
+ // re-sends anything they missed once they come back.
675
+ if (data.length === 0) {
676
+ // A tombstone stores nothing on our own source - the index entry alone records it
677
+ await this.sources[first].source.del(key);
678
+ } else {
679
+ await this.sources[first].source.set(key, data, { lastModified: writeTime });
254
680
  }
255
- if (!range && buffer.length <= MEMORY_CACHE_MAX_FILE) {
256
- this.memCache.set(key, buffer);
681
+ this.setIndexEntry(key, { writeTime, size: data.length, source: first });
682
+ let route = !isRouting && getRoute(key) || 0;
683
+ for (let i of writable) {
684
+ if (!isRouting && !routeContains(this.sources[i].route, route)) continue;
685
+ // Downstream sources receive tombstones as actual empty writes, so their listings show
686
+ // the deletion (size 0) and other stores scan it in as a tombstone
687
+ void this.sources[i].source.set(key, data, { lastModified: writeTime }).catch((e: Error) => {
688
+ console.error(`Background write of ${key} to sync source ${this.sources[i].source.getDebugName()} failed: ${e.stack ?? e}`);
689
+ });
257
690
  }
258
- return buffer;
259
691
  }
260
692
 
261
693
  public async getInfo(key: string): Promise<{ writeTime: number; size: number } | undefined> {
262
694
  await this.init();
263
695
  let overlayEntry = this.overlay.get(key);
264
696
  if (overlayEntry) {
265
- if (!overlayEntry.data) return undefined;
266
697
  return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
267
698
  }
268
- let entry = await this.index.get(key);
699
+ let entry = await this.getIndexEntry(key);
269
700
  if (!entry) return undefined;
270
- return { writeTime: entry.t, size: entry.l };
701
+ return { writeTime: entry.writeTime, size: entry.size };
271
702
  }
272
703
 
273
704
  public async findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
274
705
  await this.init();
706
+ await this.waitForRequiredScans();
275
707
  let infos = new Map<string, ArchiveFileInfo>();
276
- for (let key of await this.index.getKeys()) {
708
+ for (let [key, entry] of this.mem) {
277
709
  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 });
710
+ // Tombstones are missing files, so listings hide them
711
+ if (entry.size === 0) continue;
712
+ infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
282
713
  }
283
714
  for (let [key, overlayEntry] of this.overlay) {
284
715
  if (!key.startsWith(prefix)) continue;
285
- if (!overlayEntry.data) continue;
716
+ if (overlayEntry.data.length === 0) {
717
+ infos.delete(key);
718
+ continue;
719
+ }
286
720
  infos.set(key, { path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
287
721
  }
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("/"));
722
+ let files = applyFindInfoShape(Array.from(infos.values()), prefix, config);
723
+ sort(files, x => x.path);
724
+ return files;
725
+ }
726
+
727
+ // All files changed after the given time — fast, straight from the in-memory index. Filters on
728
+ // when WE learned of the change (changedAt), so files synchronized late (with old write times)
729
+ // are still reported. Deletions ARE reported, as size-0 tombstone entries — that's how they
730
+ // propagate to stores syncing from us.
731
+ public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
732
+ await this.init();
733
+ await this.waitForRequiredScans();
734
+ let files: ArchiveFileInfo[] = [];
735
+ for (let [key, entry] of this.mem) {
736
+ if (entry.changedAt <= time) continue;
737
+ if (this.overlay.has(key)) continue;
738
+ files.push({ path: key, createTime: entry.writeTime, size: entry.size });
739
+ }
740
+ for (let [key, overlayEntry] of this.overlay) {
741
+ if (overlayEntry.t <= time) continue;
742
+ files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
306
743
  }
307
744
  sort(files, x => x.path);
308
745
  return files;
309
746
  }
310
747
 
311
- // Large files stream into their own dedicated blob file, so concurrent small writes don't
312
- // interleave into the middle of them.
748
+ public async getSyncStatus(): Promise<ArchivesSyncStatus> {
749
+ await this.init();
750
+ return {
751
+ allScansComplete: this.sourceStates.every(x => x.scanComplete),
752
+ indexSize: this.mem.size,
753
+ sources: this.sources.map((x, i) => ({
754
+ debugName: x.source.getDebugName(),
755
+ validWindow: x.validWindow,
756
+ route: x.route,
757
+ noFullSync: x.noFullSync,
758
+ supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
759
+ initialScanComplete: this.sourceStates[i].scanComplete,
760
+ scannedCount: this.sourceStates[i].scannedCount,
761
+ })),
762
+ };
763
+ }
764
+
765
+ // ── large uploads ──
766
+ // Large uploads stream onto the local disk source directly (they may not fit in memory)
767
+
768
+ private getDiskSource(): { disk: ArchivesDisk; sourceIndex: number } {
769
+ for (let i = 0; i < this.sources.length; i++) {
770
+ let source = this.sources[i].source;
771
+ if (source instanceof ArchivesDisk) return { disk: source, sourceIndex: i };
772
+ }
773
+ throw new Error(`Large uploads require an ArchivesDisk source, and this store has none (store ${this.folder})`);
774
+ }
313
775
  public async startLargeUpload(): Promise<string> {
314
776
  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;
777
+ return await this.getDiskSource().disk.startLargeUpload();
320
778
  }
321
779
  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;
780
+ await this.getDiskSource().disk.appendLargeUpload(id, data);
326
781
  }
327
782
  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);
783
+ let { disk, sourceIndex } = this.getDiskSource();
784
+ await disk.finishLargeUpload(id, key);
335
785
  this.overlay.delete(key);
336
- await this.setIndexEntry(key, { f: blobName, o: 0, l: upload.size, t: Date.now() });
786
+ let info = await disk.getInfo(key);
787
+ if (info) {
788
+ this.setIndexEntry(key, { writeTime: info.writeTime, size: info.size, source: sourceIndex });
789
+ }
337
790
  }
338
791
  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(() => { });
792
+ await this.getDiskSource().disk.cancelLargeUpload(id);
344
793
  }
345
794
 
346
- private async flushOverlay(): Promise<void> {
795
+ private async flushOverlay(force?: boolean): Promise<void> {
347
796
  let now = Date.now();
348
797
  for (let [key, entry] of this.overlay) {
349
- if (entry.flushAt > now) continue;
350
- if (entry.data) {
351
- let location = await this.appendData(entry.data);
352
- await this.setIndexEntry(key, { ...location, t: entry.t });
353
- } else {
354
- let prev = await this.index.get(key);
355
- if (prev) {
356
- await this.index.remove(key);
357
- await this.addDeadBytes(prev);
358
- }
359
- }
798
+ if (!force && entry.flushAt > now) continue;
799
+ await this.writeToSources(key, entry.data, entry.t);
360
800
  // Only remove if it wasn't overwritten while we were flushing
361
801
  if (this.overlay.get(key) === entry) {
362
802
  this.overlay.delete(key);
@@ -364,40 +804,67 @@ export class BlobStore {
364
804
  }
365
805
  }
366
806
 
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;
807
+ // readerDiskLimit: the disk is only a bounded read cache, so once it exceeds the limit, the
808
+ // least recently used files are deleted from it - but ONLY when another source verifiably
809
+ // holds a same-or-newer copy (the only copy of a file is never deleted), and the index entry
810
+ // repoints to that source so reads keep working (re-caching on the next read).
811
+ private evicting = false;
812
+ private async enforceDiskLimit(): Promise<void> {
813
+ let limit = this.config?.readerDiskLimit;
814
+ if (!limit || this.evicting) return;
815
+ if (this.sourceByteCounts[0] <= limit) return;
816
+ this.evicting = true;
817
+ try {
818
+ let candidates: { key: string; entry: IndexEntry }[] = [];
819
+ for (let [key, entry] of this.mem) {
820
+ if (entry.source !== 0 || entry.size === 0 || key === ROUTING_FILE) continue;
821
+ candidates.push({ key, entry });
379
822
  }
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);
823
+ sort(candidates, x => x.entry.lastAccess);
824
+ for (let { key, entry } of candidates) {
825
+ if (this.stopped.stop) return;
826
+ if (this.sourceByteCounts[0] <= limit) break;
827
+ if (this.mem.get(key) !== entry) continue;
828
+ let holder: number | undefined;
829
+ for (let i = 1; i < this.sources.length; i++) {
830
+ try {
831
+ let info = await this.sources[i].source.getInfo(key);
832
+ if (info && info.writeTime >= entry.writeTime) {
833
+ holder = i;
834
+ break;
835
+ }
836
+ } catch {
837
+ // A down source just can't vouch for this file right now
838
+ }
396
839
  }
840
+ if (holder === undefined) continue;
841
+ await this.sources[0].source.del(key);
842
+ this.setIndexEntry(key, { writeTime: entry.writeTime, size: entry.size, source: holder });
843
+ }
844
+ } finally {
845
+ this.evicting = false;
846
+ }
847
+ }
848
+
849
+ // Tombstones only need to exist long enough for every store to learn of the deletion; expired
850
+ // ones come out of the index. The physical empty file is removed only on backblaze sources:
851
+ // remote stores expire their own tombstones (a del there would just mint a fresh one), and our
852
+ // own disk never stored anything for it.
853
+ private async cleanupTombstones(): Promise<void> {
854
+ let cutoff = Date.now() - TOMBSTONE_EXPIRY;
855
+ for (let [key, entry] of this.mem) {
856
+ if (this.stopped.stop) return;
857
+ if (entry.size !== 0) continue;
858
+ if (entry.writeTime > cutoff) continue;
859
+ this.deleteIndexEntry(key);
860
+ for (let sourceEntry of this.sources) {
861
+ if (!windowAcceptsWrites(sourceEntry.validWindow)) continue;
862
+ let source = sourceEntry.source;
863
+ if (!(source instanceof ArchivesBackblaze)) continue;
864
+ void source.del(key).catch((e: Error) => {
865
+ console.error(`Removing expired tombstone ${key} from ${source.getDebugName()} failed: ${e.stack ?? e}`);
866
+ });
397
867
  }
398
- await this.closeBlobHandle(blobName);
399
- await fs.promises.unlink(blobPath).catch(() => { });
400
- await this.deadBytes.remove(blobName);
401
868
  }
402
869
  }
403
870
  }