sliftutils 1.7.10 → 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.
- package/examplestorage/exampleserver.ts +0 -2
- package/index.d.ts +290 -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 +52 -3
- package/storage/remoteStorage/blobStore.ts +414 -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,15 @@ 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?(): { index: { fileCount: number; byteCount: number }; syncing: SyncActivity[] };
|
|
70
|
+
computeIndexTotals?(): Promise<{ fileCount: number; byteCount: number }>;
|
|
55
71
|
startLargeUpload(): Promise<string>;
|
|
56
72
|
appendLargeUpload(id: string, data: Buffer): Promise<void>;
|
|
57
73
|
finishLargeUpload(id: string, key: string): Promise<void>;
|
|
@@ -59,8 +75,8 @@ export type IBucketStore = {
|
|
|
59
75
|
};
|
|
60
76
|
|
|
61
77
|
type OverlayEntry = {
|
|
62
|
-
//
|
|
63
|
-
data: Buffer
|
|
78
|
+
// A zero-length buffer is a pending delete (tombstone)
|
|
79
|
+
data: Buffer;
|
|
64
80
|
t: number;
|
|
65
81
|
flushAt: number;
|
|
66
82
|
};
|
|
@@ -80,6 +96,9 @@ type IndexEntry = {
|
|
|
80
96
|
// When WE last changed this entry (not the file's write time) — what getChangesAfter filters
|
|
81
97
|
// on, so late-arriving files with old write times are still reported as changes
|
|
82
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;
|
|
83
102
|
};
|
|
84
103
|
|
|
85
104
|
type SourceState = {
|
|
@@ -101,6 +120,8 @@ export class BlobStore implements IBucketStore {
|
|
|
101
120
|
// Called whenever a key's index entry changes (our own writes AND files pulled in via
|
|
102
121
|
// synchronization) — how the storage server notices routing config updates.
|
|
103
122
|
onIndexChanged?: (key: string) => void;
|
|
123
|
+
// LRU-bound the disk (base source) to this many bytes; see CommonConfig.readerDiskLimit
|
|
124
|
+
readerDiskLimit?: number;
|
|
104
125
|
}
|
|
105
126
|
) { }
|
|
106
127
|
|
|
@@ -117,6 +138,16 @@ export class BlobStore implements IBucketStore {
|
|
|
117
138
|
// The in-memory copy of the index. All reads are served from it; changes are buffered in
|
|
118
139
|
// dirty and flushed to the BulkDatabase2 in batches (undefined = delete).
|
|
119
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>();
|
|
120
151
|
private dirty = new Map<string, IndexEntry | undefined>();
|
|
121
152
|
private overlay = new Map<string, OverlayEntry>();
|
|
122
153
|
private sourceStates = this.sources.map((): SourceState => ({
|
|
@@ -135,6 +166,10 @@ export class BlobStore implements IBucketStore {
|
|
|
135
166
|
}
|
|
136
167
|
runInfinitePoll(FAST_FLUSH_POLL, () => this.flushOverlay(), this.stopped);
|
|
137
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
|
+
}
|
|
138
173
|
});
|
|
139
174
|
|
|
140
175
|
// Stops all synchronization scans/polls and flushes pending writes. Used when a bucket's
|
|
@@ -158,22 +193,82 @@ export class BlobStore implements IBucketStore {
|
|
|
158
193
|
let source = sourceMap.get(entry.key);
|
|
159
194
|
// Explicit checks, as 0 is a valid size and a valid source number
|
|
160
195
|
if (size === undefined || source === undefined) continue;
|
|
161
|
-
|
|
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);
|
|
199
|
+
}
|
|
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;
|
|
162
210
|
}
|
|
163
211
|
}
|
|
164
212
|
|
|
165
213
|
private setIndexEntry(key: string, entry: { writeTime: number; size: number; source: number }): void {
|
|
166
|
-
let full: IndexEntry = { ...entry, changedAt: Date.now() };
|
|
214
|
+
let full: IndexEntry = { ...entry, changedAt: Date.now(), lastAccess: Date.now() };
|
|
215
|
+
this.countEntry(this.mem.get(key), -1);
|
|
216
|
+
this.countEntry(full, 1);
|
|
167
217
|
this.mem.set(key, full);
|
|
168
218
|
this.dirty.set(key, full);
|
|
169
219
|
this.config?.onIndexChanged?.(key);
|
|
170
220
|
}
|
|
171
221
|
private deleteIndexEntry(key: string): void {
|
|
172
|
-
|
|
222
|
+
let existing = this.mem.get(key);
|
|
223
|
+
if (!existing) return;
|
|
224
|
+
this.countEntry(existing, -1);
|
|
173
225
|
this.mem.delete(key);
|
|
174
226
|
this.dirty.set(key, undefined);
|
|
175
227
|
}
|
|
176
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
|
+
}
|
|
247
|
+
|
|
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 };
|
|
270
|
+
}
|
|
271
|
+
|
|
177
272
|
private async flushIndex(): Promise<void> {
|
|
178
273
|
if (!this.dirty.size) return;
|
|
179
274
|
let dirty = this.dirty;
|
|
@@ -194,13 +289,14 @@ export class BlobStore implements IBucketStore {
|
|
|
194
289
|
// ── synchronization ──
|
|
195
290
|
|
|
196
291
|
private async runSourceSync(sourceIndex: number): Promise<void> {
|
|
197
|
-
let { source,
|
|
292
|
+
let { source, noFullSync } = this.sources[sourceIndex];
|
|
198
293
|
let state = this.sourceStates[sourceIndex];
|
|
294
|
+
let listing: Map<string, number> | undefined;
|
|
199
295
|
while (!this.stopped.stop) {
|
|
200
296
|
try {
|
|
201
297
|
let config = await source.getConfig();
|
|
202
298
|
state.supportsChangesAfter = !!(config.supportsChangesAfter && source.getChangesAfter);
|
|
203
|
-
await this.scanSource(sourceIndex);
|
|
299
|
+
listing = await this.scanSource(sourceIndex);
|
|
204
300
|
break;
|
|
205
301
|
} catch (e) {
|
|
206
302
|
console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
|
|
@@ -210,7 +306,10 @@ export class BlobStore implements IBucketStore {
|
|
|
210
306
|
state.scanComplete = true;
|
|
211
307
|
state.initialScan.resolve(undefined);
|
|
212
308
|
if (this.stopped.stop) return;
|
|
213
|
-
if (
|
|
309
|
+
if (listing) {
|
|
310
|
+
await this.reconcileSource(sourceIndex, listing);
|
|
311
|
+
}
|
|
312
|
+
if (!noFullSync) {
|
|
214
313
|
try {
|
|
215
314
|
await this.copySourceFiles(sourceIndex);
|
|
216
315
|
} catch (e) {
|
|
@@ -220,43 +319,109 @@ export class BlobStore implements IBucketStore {
|
|
|
220
319
|
if (state.supportsChangesAfter) {
|
|
221
320
|
runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
|
|
222
321
|
await this.pollChanges(sourceIndex);
|
|
223
|
-
if (
|
|
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])));
|
|
224
329
|
}, this.stopped);
|
|
225
330
|
} else {
|
|
226
331
|
runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
|
|
227
|
-
await this.scanSource(sourceIndex);
|
|
228
|
-
|
|
332
|
+
let rescan = await this.scanSource(sourceIndex);
|
|
333
|
+
await this.reconcileSource(sourceIndex, rescan);
|
|
334
|
+
if (!noFullSync) await this.copySourceFiles(sourceIndex);
|
|
229
335
|
}, this.stopped);
|
|
230
336
|
}
|
|
231
337
|
}
|
|
232
338
|
|
|
233
|
-
// Full metadata scan (size, writeTime, path) of one source, applied to the index
|
|
234
|
-
|
|
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>> {
|
|
235
342
|
let { source } = this.sources[sourceIndex];
|
|
236
343
|
let state = this.sourceStates[sourceIndex];
|
|
237
344
|
let scanStart = Date.now();
|
|
238
|
-
let
|
|
239
|
-
let
|
|
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>();
|
|
240
364
|
for (let file of files) {
|
|
241
|
-
seen.
|
|
365
|
+
seen.set(file.path, file.createTime);
|
|
242
366
|
this.applyScanned(sourceIndex, file);
|
|
243
367
|
}
|
|
244
368
|
state.scannedCount = files.length;
|
|
245
369
|
// Index entries this source was the holder of, but that vanished from it (e.g. deleted
|
|
246
370
|
// 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.
|
|
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).
|
|
248
373
|
for (let [key, entry] of this.mem) {
|
|
249
374
|
if (entry.source !== sourceIndex) continue;
|
|
375
|
+
if (entry.size === 0) continue;
|
|
250
376
|
if (seen.has(key)) continue;
|
|
251
377
|
if (entry.changedAt >= scanStart) continue;
|
|
252
378
|
this.deleteIndexEntry(key);
|
|
253
379
|
}
|
|
254
380
|
state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
|
|
381
|
+
return seen;
|
|
382
|
+
}
|
|
383
|
+
|
|
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}`);
|
|
416
|
+
}
|
|
255
417
|
}
|
|
256
418
|
|
|
257
419
|
private applyScanned(sourceIndex: number, file: ArchiveFileInfo): void {
|
|
258
|
-
let
|
|
420
|
+
let { validWindow, route } = this.sources[sourceIndex];
|
|
421
|
+
let [windowStart, windowEnd] = validWindow;
|
|
259
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;
|
|
260
425
|
let existing = this.mem.get(file.path);
|
|
261
426
|
// The highest write time wins across all sources (ties keep the existing entry)
|
|
262
427
|
if (existing && file.createTime <= existing.writeTime) return;
|
|
@@ -276,45 +441,92 @@ export class BlobStore implements IBucketStore {
|
|
|
276
441
|
state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
|
|
277
442
|
}
|
|
278
443
|
|
|
279
|
-
// Downloads the files a
|
|
280
|
-
//
|
|
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.
|
|
281
447
|
private async copySourceFiles(sourceIndex: number): Promise<void> {
|
|
448
|
+
if (sourceIndex === 0) return;
|
|
282
449
|
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;
|
|
450
|
+
let pending: { key: string; entry: IndexEntry }[] = [];
|
|
451
|
+
let totalBytes = 0;
|
|
288
452
|
for (let [key, entry] of this.mem) {
|
|
289
453
|
if (entry.source !== sourceIndex) continue;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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;
|
|
494
|
+
}
|
|
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
|
+
}));
|
|
294
503
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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();
|
|
298
511
|
}
|
|
299
512
|
}
|
|
300
513
|
}
|
|
301
514
|
|
|
302
|
-
// findInfo and getChangesAfter list from the index, so they must wait for
|
|
303
|
-
//
|
|
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.
|
|
304
518
|
private async waitForRequiredScans(): Promise<void> {
|
|
305
|
-
|
|
306
|
-
if (!this.sources[i].options.required) continue;
|
|
307
|
-
await this.sourceStates[i].initialScan.promise;
|
|
308
|
-
}
|
|
519
|
+
await this.sourceStates[0].initialScan.promise;
|
|
309
520
|
}
|
|
310
521
|
|
|
311
|
-
// A requested file isn't in the index:
|
|
312
|
-
// scan
|
|
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)
|
|
313
525
|
private async checkMissingKey(key: string): Promise<void> {
|
|
314
526
|
for (let i = 0; i < this.sources.length; i++) {
|
|
315
|
-
let { source
|
|
527
|
+
let { source } = this.sources[i];
|
|
316
528
|
let state = this.sourceStates[i];
|
|
317
|
-
if (
|
|
529
|
+
if (i === 0 && !state.scanComplete) {
|
|
318
530
|
let info = await source.getInfo(key);
|
|
319
531
|
if (info) {
|
|
320
532
|
this.applyScanned(i, { path: key, createTime: info.writeTime, size: info.size });
|
|
@@ -346,45 +558,68 @@ export class BlobStore implements IBucketStore {
|
|
|
346
558
|
return result && result.data || undefined;
|
|
347
559
|
}
|
|
348
560
|
|
|
349
|
-
public async get2(key: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined> {
|
|
561
|
+
public async get2(key: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
350
562
|
await this.init();
|
|
351
563
|
let range = config?.range;
|
|
352
564
|
let overlayEntry = this.overlay.get(key);
|
|
353
565
|
if (overlayEntry) {
|
|
354
|
-
|
|
566
|
+
// An empty file IS a missing file (tombstone)
|
|
567
|
+
if (overlayEntry.data.length === 0) return undefined;
|
|
355
568
|
let data = overlayEntry.data;
|
|
569
|
+
let size = data.length;
|
|
356
570
|
if (range) {
|
|
357
571
|
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
358
572
|
}
|
|
359
|
-
return { data, writeTime: overlayEntry.t };
|
|
573
|
+
return { data, writeTime: overlayEntry.t, size };
|
|
360
574
|
}
|
|
361
575
|
let entry = await this.getIndexEntry(key);
|
|
362
576
|
if (!entry) return undefined;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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;
|
|
369
593
|
}
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
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 };
|
|
373
611
|
}
|
|
374
|
-
|
|
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;
|
|
375
616
|
}
|
|
376
617
|
|
|
377
|
-
// The read
|
|
378
|
-
//
|
|
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
|
|
379
620
|
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 });
|
|
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 });
|
|
388
623
|
}
|
|
389
624
|
|
|
390
625
|
public async set(key: string, data: Buffer, config?: WriteConfig): Promise<void> {
|
|
@@ -408,43 +643,57 @@ export class BlobStore implements IBucketStore {
|
|
|
408
643
|
await this.writeToSources(key, data, writeTime);
|
|
409
644
|
}
|
|
410
645
|
|
|
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 });
|
|
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);
|
|
422
651
|
}
|
|
423
652
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
return;
|
|
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);
|
|
430
658
|
}
|
|
431
|
-
|
|
432
|
-
await this.deleteFromSources(key);
|
|
659
|
+
return writable;
|
|
433
660
|
}
|
|
434
661
|
|
|
435
|
-
private async
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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})`);
|
|
671
|
+
}
|
|
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 });
|
|
680
|
+
}
|
|
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
|
+
});
|
|
439
690
|
}
|
|
440
|
-
this.deleteIndexEntry(key);
|
|
441
691
|
}
|
|
442
692
|
|
|
443
693
|
public async getInfo(key: string): Promise<{ writeTime: number; size: number } | undefined> {
|
|
444
694
|
await this.init();
|
|
445
695
|
let overlayEntry = this.overlay.get(key);
|
|
446
696
|
if (overlayEntry) {
|
|
447
|
-
if (!overlayEntry.data) return undefined;
|
|
448
697
|
return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
|
|
449
698
|
}
|
|
450
699
|
let entry = await this.getIndexEntry(key);
|
|
@@ -458,11 +707,13 @@ export class BlobStore implements IBucketStore {
|
|
|
458
707
|
let infos = new Map<string, ArchiveFileInfo>();
|
|
459
708
|
for (let [key, entry] of this.mem) {
|
|
460
709
|
if (!key.startsWith(prefix)) continue;
|
|
710
|
+
// Tombstones are missing files, so listings hide them
|
|
711
|
+
if (entry.size === 0) continue;
|
|
461
712
|
infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
|
|
462
713
|
}
|
|
463
714
|
for (let [key, overlayEntry] of this.overlay) {
|
|
464
715
|
if (!key.startsWith(prefix)) continue;
|
|
465
|
-
if (
|
|
716
|
+
if (overlayEntry.data.length === 0) {
|
|
466
717
|
infos.delete(key);
|
|
467
718
|
continue;
|
|
468
719
|
}
|
|
@@ -475,7 +726,8 @@ export class BlobStore implements IBucketStore {
|
|
|
475
726
|
|
|
476
727
|
// All files changed after the given time — fast, straight from the in-memory index. Filters on
|
|
477
728
|
// when WE learned of the change (changedAt), so files synchronized late (with old write times)
|
|
478
|
-
// are still reported. Deletions
|
|
729
|
+
// are still reported. Deletions ARE reported, as size-0 tombstone entries — that's how they
|
|
730
|
+
// propagate to stores syncing from us.
|
|
479
731
|
public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
|
|
480
732
|
await this.init();
|
|
481
733
|
await this.waitForRequiredScans();
|
|
@@ -486,7 +738,6 @@ export class BlobStore implements IBucketStore {
|
|
|
486
738
|
files.push({ path: key, createTime: entry.writeTime, size: entry.size });
|
|
487
739
|
}
|
|
488
740
|
for (let [key, overlayEntry] of this.overlay) {
|
|
489
|
-
if (!overlayEntry.data) continue;
|
|
490
741
|
if (overlayEntry.t <= time) continue;
|
|
491
742
|
files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
|
|
492
743
|
}
|
|
@@ -501,7 +752,9 @@ export class BlobStore implements IBucketStore {
|
|
|
501
752
|
indexSize: this.mem.size,
|
|
502
753
|
sources: this.sources.map((x, i) => ({
|
|
503
754
|
debugName: x.source.getDebugName(),
|
|
504
|
-
|
|
755
|
+
validWindow: x.validWindow,
|
|
756
|
+
route: x.route,
|
|
757
|
+
noFullSync: x.noFullSync,
|
|
505
758
|
supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
|
|
506
759
|
initialScanComplete: this.sourceStates[i].scanComplete,
|
|
507
760
|
scannedCount: this.sourceStates[i].scannedCount,
|
|
@@ -543,15 +796,75 @@ export class BlobStore implements IBucketStore {
|
|
|
543
796
|
let now = Date.now();
|
|
544
797
|
for (let [key, entry] of this.overlay) {
|
|
545
798
|
if (!force && entry.flushAt > now) continue;
|
|
546
|
-
|
|
547
|
-
await this.writeToSources(key, entry.data, entry.t);
|
|
548
|
-
} else {
|
|
549
|
-
await this.deleteFromSources(key);
|
|
550
|
-
}
|
|
799
|
+
await this.writeToSources(key, entry.data, entry.t);
|
|
551
800
|
// Only remove if it wasn't overwritten while we were flushing
|
|
552
801
|
if (this.overlay.get(key) === entry) {
|
|
553
802
|
this.overlay.delete(key);
|
|
554
803
|
}
|
|
555
804
|
}
|
|
556
805
|
}
|
|
806
|
+
|
|
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 });
|
|
822
|
+
}
|
|
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
|
+
}
|
|
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
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
557
870
|
}
|