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.
- package/bin/storageserver.js +4 -0
- package/{teststorage → examplestorage}/browser.tsx +18 -22
- package/{teststorage/server.ts → examplestorage/exampleserver.ts} +4 -6
- package/index.d.ts +770 -84
- 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 -8
- package/misc/https/dns.ts +104 -106
- package/misc/https/hostServer.d.ts +0 -3
- package/misc/https/hostServer.ts +12 -8
- package/package.json +3 -2
- package/render-utils/dist/observer.tsx.cache +3 -2
- package/spec.txt +0 -55
- package/storage/ArchivesDisk.d.ts +67 -0
- package/storage/ArchivesDisk.ts +369 -0
- 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 +127 -1
- package/storage/IArchives.ts +201 -1
- package/storage/backblaze.d.ts +29 -2
- package/storage/backblaze.ts +214 -78
- 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 +34 -18
- package/storage/remoteStorage/ArchivesRemote.ts +85 -65
- package/storage/remoteStorage/ArchivesUrl.d.ts +48 -0
- package/storage/remoteStorage/ArchivesUrl.ts +86 -0
- package/storage/remoteStorage/accessPage.tsx +111 -28
- package/storage/remoteStorage/blobStore.d.ts +128 -25
- package/storage/remoteStorage/blobStore.ts +757 -290
- package/storage/remoteStorage/cliArgs.d.ts +1 -0
- package/storage/remoteStorage/cliArgs.ts +9 -0
- package/storage/remoteStorage/createArchives.d.ts +85 -0
- package/storage/remoteStorage/createArchives.ts +612 -0
- 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/grantAccess.js +4 -0
- package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
- package/storage/remoteStorage/grantAccessCli.ts +27 -0
- package/storage/remoteStorage/remoteConfig.d.ts +29 -0
- package/storage/remoteStorage/remoteConfig.ts +194 -0
- 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 +30 -27
- package/storage/remoteStorage/storageController.ts +297 -159
- package/storage/remoteStorage/storageServer.d.ts +6 -0
- package/storage/remoteStorage/storageServer.ts +88 -94
- package/storage/remoteStorage/storageServerCli.d.ts +1 -0
- package/storage/remoteStorage/storageServerCli.ts +38 -0
- package/storage/remoteStorage/storageServerState.d.ts +43 -0
- package/storage/remoteStorage/storageServerState.ts +540 -0
- package/testsite/server.ts +0 -2
- 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 {
|
|
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 {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
//
|
|
49
|
-
data: Buffer
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
100
|
-
// Dead (deleted/overwritten) byte count per blob file, for compaction
|
|
101
|
-
private deadBytes!: JSONStorage<number>;
|
|
128
|
+
private stopped = { stop: false };
|
|
102
129
|
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
let
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this.
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
let
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
639
|
+
this.overlay.set(key, { data, t: writeTime, flushAt: Date.now() + writeDelay });
|
|
222
640
|
return;
|
|
223
641
|
}
|
|
224
642
|
this.overlay.delete(key);
|
|
225
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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.
|
|
699
|
+
let entry = await this.getIndexEntry(key);
|
|
269
700
|
if (!entry) return undefined;
|
|
270
|
-
return { writeTime: entry.
|
|
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
|
|
708
|
+
for (let [key, entry] of this.mem) {
|
|
277
709
|
if (!key.startsWith(prefix)) continue;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 (
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
329
|
-
|
|
330
|
-
this.largeUploads.delete(id);
|
|
331
|
-
await upload.fd.close();
|
|
332
|
-
let blobName = `large_${id}.bin`;
|
|
333
|
-
await fs.promises.rename(upload.tmpPath, this.blobPath(blobName));
|
|
334
|
-
this.memCache.delete(key);
|
|
783
|
+
let { disk, sourceIndex } = this.getDiskSource();
|
|
784
|
+
await disk.finishLargeUpload(id, key);
|
|
335
785
|
this.overlay.delete(key);
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
let
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
}
|