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