sliftutils 1.7.88 → 1.7.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +3 -1
- package/dist/treeSummary.ts.cache +2 -2
- package/index.d.ts +674 -295
- package/misc/dist/environment.ts.cache +2 -2
- package/misc/dist/getSecret.ts.cache +2 -2
- package/misc/dist/strings.ts.cache +2 -2
- package/misc/dist/zip.ts.cache +2 -2
- package/misc/https/dist/certs.ts.cache +2 -2
- package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
- package/misc/https/dist/dns.ts.cache +2 -2
- package/misc/https/dist/hostServer.ts.cache +49 -21
- package/misc/https/dist/hostsFile.ts.cache +82 -0
- package/misc/https/dist/httpsCerts.ts.cache +2 -2
- package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
- package/misc/https/hostServer.d.ts +6 -2
- package/misc/https/hostServer.ts +52 -19
- package/misc/https/hostsFile.d.ts +7 -0
- package/misc/https/hostsFile.ts +66 -0
- package/package.json +1 -1
- package/render-utils/dist/observer.tsx.cache +2 -2
- package/storage/ArchivesDisk.d.ts +3 -6
- package/storage/ArchivesDisk.ts +26 -2
- package/storage/BulkDatabase2/BulkDatabaseReader.ts +1 -1
- package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +3 -3
- package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
- package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
- package/storage/IArchives.d.ts +37 -24
- package/storage/IArchives.ts +47 -62
- package/storage/LogMap.d.ts +56 -0
- package/storage/LogMap.ts +220 -0
- package/storage/archiveHelpers.d.ts +36 -0
- package/storage/archiveHelpers.ts +103 -0
- package/storage/backblaze.d.ts +7 -12
- package/storage/backblaze.ts +53 -48
- package/storage/dist/ArchivesDisk.ts.cache +28 -2
- package/storage/dist/FileFolderAPI.tsx.cache +2 -2
- package/storage/dist/IArchives.ts.cache +16 -52
- package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
- package/storage/dist/JSONStorage.ts.cache +2 -2
- package/storage/dist/LogMap.ts.cache +212 -0
- package/storage/dist/PendingManager.tsx.cache +2 -2
- package/storage/dist/TransactionStorage.ts.cache +2 -2
- package/storage/dist/backblaze.ts.cache +58 -44
- package/storage/dist/fileSystemPointer.ts.cache +2 -2
- package/storage/dist/remoteFileStorage.ts.cache +2 -2
- package/storage/remoteStorage/ArchivesDelayed.d.ts +66 -0
- package/storage/remoteStorage/ArchivesDelayed.ts +237 -0
- package/storage/remoteStorage/ArchivesRemote.d.ts +5 -6
- package/storage/remoteStorage/ArchivesRemote.ts +26 -5
- package/storage/remoteStorage/ArchivesUrl.d.ts +2 -5
- package/storage/remoteStorage/ArchivesUrl.ts +2 -2
- package/storage/remoteStorage/accessPage.tsx +12 -0
- package/storage/remoteStorage/blobStore.d.ts +162 -189
- package/storage/remoteStorage/blobStore.ts +495 -952
- package/storage/remoteStorage/bucketDisk.d.ts +24 -3
- package/storage/remoteStorage/bucketDisk.ts +56 -22
- package/storage/remoteStorage/certTrustModal.d.ts +1 -0
- package/storage/remoteStorage/certTrustModal.tsx +48 -0
- package/storage/remoteStorage/cliArgs.d.ts +2 -0
- package/storage/remoteStorage/cliArgs.ts +5 -0
- package/storage/remoteStorage/createArchives.d.ts +8 -7
- package/storage/remoteStorage/createArchives.ts +65 -26
- package/storage/remoteStorage/deployTakeover.ts +1 -1
- package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +220 -0
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +25 -5
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +3 -3
- package/storage/remoteStorage/dist/accessPage.tsx.cache +13 -3
- package/storage/remoteStorage/dist/accessStats.ts.cache +2 -2
- package/storage/remoteStorage/dist/blobStore.ts.cache +483 -965
- package/storage/remoteStorage/dist/bucketDisk.ts.cache +60 -29
- package/storage/remoteStorage/dist/certTrustModal.tsx.cache +55 -0
- package/storage/remoteStorage/dist/cliArgs.ts.cache +9 -4
- package/storage/remoteStorage/dist/createArchives.ts.cache +63 -26
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +5 -5
- package/storage/remoteStorage/dist/intermediateManagement.ts.cache +286 -0
- package/storage/remoteStorage/dist/intermediateSources.ts.cache +2 -2
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +122 -56
- package/storage/remoteStorage/dist/serverConfig.ts.cache +4 -4
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +7 -11
- package/storage/remoteStorage/dist/sourcesList.ts.cache +3 -6
- package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
- package/storage/remoteStorage/dist/storageController.ts.cache +27 -12
- package/storage/remoteStorage/dist/storageServer.ts.cache +27 -7
- package/storage/remoteStorage/dist/storageServerCli.ts.cache +7 -3
- package/storage/remoteStorage/dist/storageServerState.ts.cache +133 -696
- package/storage/remoteStorage/dist/storeConfig.ts.cache +52 -0
- package/storage/remoteStorage/dist/storePlan.ts.cache +110 -81
- package/storage/remoteStorage/dist/storeSources.ts.cache +51 -0
- package/storage/remoteStorage/dist/storeSync.ts.cache +650 -0
- package/storage/remoteStorage/dist/validation.ts.cache +14 -4
- package/storage/remoteStorage/intermediateManagement.d.ts +12 -0
- package/storage/remoteStorage/intermediateManagement.ts +264 -0
- package/storage/remoteStorage/remoteConfig.d.ts +32 -0
- package/storage/remoteStorage/remoteConfig.ts +115 -50
- package/storage/remoteStorage/serverConfig.d.ts +1 -1
- package/storage/remoteStorage/serverConfig.ts +1 -1
- package/storage/remoteStorage/sourceWrapper.ts +4 -8
- package/storage/remoteStorage/sourcesList.d.ts +0 -2
- package/storage/remoteStorage/sourcesList.ts +0 -3
- package/storage/remoteStorage/spec.md +18 -4
- package/storage/remoteStorage/storageController.d.ts +10 -0
- package/storage/remoteStorage/storageController.ts +29 -16
- package/storage/remoteStorage/storageServer.d.ts +2 -0
- package/storage/remoteStorage/storageServer.ts +28 -4
- package/storage/remoteStorage/storageServerCli.ts +5 -1
- package/storage/remoteStorage/storageServerState.d.ts +34 -21
- package/storage/remoteStorage/storageServerState.ts +147 -662
- package/storage/remoteStorage/storeConfig.d.ts +30 -0
- package/storage/remoteStorage/storeConfig.ts +62 -0
- package/storage/remoteStorage/storePlan.d.ts +29 -17
- package/storage/remoteStorage/storePlan.ts +112 -85
- package/storage/remoteStorage/storeSources.d.ts +11 -0
- package/storage/remoteStorage/storeSources.ts +47 -0
- package/storage/remoteStorage/storeSync.d.ts +53 -0
- package/storage/remoteStorage/storeSync.ts +632 -0
- package/storage/remoteStorage/validation.d.ts +2 -0
- package/storage/remoteStorage/validation.ts +10 -0
|
@@ -1,163 +1,41 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { lazy } from "socket-function/src/caching";
|
|
3
|
-
import {
|
|
4
|
-
import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
|
|
5
|
-
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
3
|
+
import { timeInMinute, sort } from "socket-function/src/misc";
|
|
6
4
|
import {
|
|
7
|
-
IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, SourceConfig, assertValidLastModified,
|
|
8
|
-
|
|
5
|
+
IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, RemoteConfig, SourceConfig, assertValidLastModified,
|
|
6
|
+
windowsAcceptWrites, SyncActivity, FULL_ROUTE, FULL_VALID_WINDOW, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE,
|
|
9
7
|
} from "../IArchives";
|
|
10
8
|
import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
|
|
11
|
-
import {
|
|
12
|
-
import { ROUTING_FILE, getRoute, routeContains } from "./remoteConfig";
|
|
9
|
+
import { ROUTING_FILE, getRoute, routeContains, routeIntersection, parseRoutingData, assertValidRemoteConfig, getConfigVersion, sourceIdentity, sourcePersistentUrl } from "./remoteConfig";
|
|
13
10
|
import { selectEntryAt } from "./storePlan";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
11
|
+
import { sourceWriteDelay } from "./ArchivesDelayed";
|
|
12
|
+
import { LogMap } from "../LogMap";
|
|
16
13
|
import { SourcesList } from "./sourcesList";
|
|
14
|
+
import { StoreSync } from "./storeSync";
|
|
15
|
+
import { StoreConfig } from "./storeConfig";
|
|
16
|
+
import { asDelayed, unwrapDelayed } from "./ArchivesDelayed";
|
|
17
17
|
|
|
18
|
-
// The storage engine of the remote storage server. Data lives in synchronization sources (at minimum an ArchivesDisk, the local disk); BlobStore keeps an index of every file (path, last modified time, size, and which source currently holds the data) in a BulkDatabase2, and
|
|
18
|
+
// The storage engine of the remote storage server. Data lives in synchronization sources (at minimum an ArchivesDisk, the local disk); BlobStore keeps an index of every file (path, last modified time, size, and which source currently holds the data) in a BulkDatabase2, and answers every read from it (see ArchivesSource in IArchives.ts). Keeping that index in agreement with the sources is StoreSync's job, not this file's; what lives here is the index itself, reads, writes, and the validation a write has to pass.
|
|
19
19
|
|
|
20
|
-
export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
|
|
21
|
-
// The most a fast write is buffered before reaching our OWN storage servers (type-"remote" downstream sources) and our own disk - short, so cross-node redundancy and read-your-writes across nodes don't wait the full writeDelay. The writeDelay (default 5 min) is reserved for the non-"remote" sources (backblaze etc). A fast write with a writeDelay shorter than this uses the shorter one for both.
|
|
22
|
-
const MAX_REMOTE_FAST_BUFFER = 1000 * 5;
|
|
23
|
-
// The overlay is polled this often for due flushes - fine enough that the fast (remote) flush lands close to MAX_REMOTE_FAST_BUFFER, not one coarse tick later
|
|
24
|
-
const FAST_FLUSH_POLL = 1000 * 2;
|
|
25
20
|
// Fast writes are never delayed past our own valid window, and within this margin of the window's end they write through immediately - so when the next window's source takes over, the writes are already on disk
|
|
26
21
|
export const WINDOW_END_FLUSH_MARGIN = timeInMinute * 5;
|
|
27
|
-
// Index changes are buffered in memory and written to the BulkDatabase2 in batches
|
|
28
|
-
const INDEX_FLUSH_INTERVAL = 1000 * 30;
|
|
29
|
-
// Sources with a native (index-backed) change feed are polled this often
|
|
30
|
-
const CHANGES_POLL_INTERVAL = 1000 * 60;
|
|
31
|
-
// Full metadata rescans. supportsChangesAfter is the heuristic for "one of our own storage servers": their index-backed listings are cheap, so hourly is fine. Everything else (backblaze, plain disk) pays the full listing cost, so it rescans much less often.
|
|
32
|
-
const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
|
|
33
|
-
const FULL_RESCAN_UNINDEXED_INTERVAL = 1000 * 60 * 60 * 6;
|
|
34
|
-
// On a request for a file the index doesn't know, changes-after sources are re-polled, at most this often
|
|
35
|
-
const MISS_CHECK_INTERVAL = 1000 * 5;
|
|
36
|
-
// Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
|
|
37
|
-
const CHANGES_POLL_OVERLAP = timeInMinute;
|
|
38
|
-
const SCAN_RETRY_DELAY = 1000 * 30;
|
|
39
|
-
// Deletes are tombstones (an empty file IS a missing file): the size-0 index entry is what lets a deletion propagate/reconcile like any other write, and it expires after this long
|
|
40
|
-
const TOMBSTONE_EXPIRY = 1000 * 60 * 60 * 24 * 7;
|
|
41
|
-
const TOMBSTONE_CLEANUP_INTERVAL = 1000 * 60 * 60;
|
|
42
|
-
// While a metadata scan or full sync is running, its progress is logged this often
|
|
43
|
-
const SYNC_PROGRESS_LOG_INTERVAL = 1000 * 60;
|
|
44
|
-
const DISK_LIMIT_CHECK_INTERVAL = 1000 * 60;
|
|
45
|
-
// Full syncs download this many files concurrently (high-latency sources like backblaze would otherwise crawl one round-trip at a time)
|
|
46
|
-
const FULL_SYNC_PARALLEL = 8;
|
|
47
|
-
// A full sync running longer than this is console.errored (and again every interval after), so a sync that will take days is loud instead of a quiet console.log every minute
|
|
48
|
-
const FULL_SYNC_SLOW_ERROR_INTERVAL = 1000 * 60 * 60;
|
|
49
|
-
// A reconcile pass skips failing files (one bad value must not stop the rest), but this many failures in a row means the target itself is down, so the pass aborts until the next scan cycle
|
|
50
|
-
const RECONCILE_MAX_CONSECUTIVE_FAILURES = 5;
|
|
51
|
-
const RECONCILE_ERROR_LOG_LIMIT = 3;
|
|
52
22
|
const WRONG_TARGET_LOG_THROTTLE = 60 * 1000;
|
|
23
|
+
// Marks an upload id that never reached the disk, so it can never collide with one ArchivesDisk handed out
|
|
24
|
+
const DISCARDED_UPLOAD_PREFIX = "discarded_";
|
|
53
25
|
|
|
54
|
-
// What the storage server needs from a bucket's store. BlobStore implements it fully (with validation from its routing entries); RawDiskStore adapts an ArchivesDisk for rawDisk buckets (no index, no sync, no validation - raw means raw).
|
|
55
|
-
export type IBucketStore = {
|
|
56
|
-
/** internal (store-to-store) reads answer purely from the local disk; see GetConfig.internal */
|
|
57
|
-
get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
|
|
58
|
-
/** internal (store-to-store) writes go to the local disk + index with no fan-out; see SetConfig.internal */
|
|
59
|
-
set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void>;
|
|
60
|
-
del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void>;
|
|
61
|
-
getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined>;
|
|
62
|
-
findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]>;
|
|
63
|
-
getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
|
|
64
|
-
getSyncStatus?(): Promise<ArchivesSyncStatus>;
|
|
65
|
-
getSyncProgress?(): {
|
|
66
|
-
index: { fileCount: number; byteCount: number };
|
|
67
|
-
sources: { debugName: string; fileCount: number; byteCount: number }[];
|
|
68
|
-
readerDiskLimit?: number;
|
|
69
|
-
syncing: SyncActivity[];
|
|
70
|
-
};
|
|
71
|
-
computeIndexTotals?(): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }>;
|
|
72
|
-
/** path/lastModified let the store reject an upload into an immutable bucket before any bytes move */
|
|
73
|
-
startLargeUpload(config?: { path?: string; lastModified?: number }): Promise<string>;
|
|
74
|
-
appendLargeUpload(config: { id: string; data: Buffer }): Promise<void>;
|
|
75
|
-
finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void>;
|
|
76
|
-
cancelLargeUpload(config: { id: string }): Promise<void>;
|
|
77
|
-
};
|
|
78
26
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
constructor(private disk: ArchivesDisk) { }
|
|
82
|
-
|
|
83
|
-
public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
84
|
-
return await this.disk.get2(config.path, { range: config.range, includeTombstones: config.includeTombstones });
|
|
85
|
-
}
|
|
86
|
-
public async set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
|
|
87
|
-
await this.disk.set(config.path, config.data, { lastModified: config.lastModified });
|
|
88
|
-
}
|
|
89
|
-
public async del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void> {
|
|
90
|
-
if (config.path === ROUTING_FILE) {
|
|
91
|
-
throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
|
|
92
|
-
}
|
|
93
|
-
await this.disk.del(config.path, { lastModified: config.lastModified });
|
|
94
|
-
}
|
|
95
|
-
public async getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined> {
|
|
96
|
-
return await this.disk.getInfo(config.path, { includeTombstones: config.includeTombstones });
|
|
97
|
-
}
|
|
98
|
-
public async findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]> {
|
|
99
|
-
return await this.disk.findInfo(config.prefix, { shallow: config.shallow, type: config.type });
|
|
100
|
-
}
|
|
101
|
-
public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
102
|
-
return await this.disk.getChangesAfter2(config);
|
|
103
|
-
}
|
|
104
|
-
public async startLargeUpload(): Promise<string> {
|
|
105
|
-
return await this.disk.startLargeUpload();
|
|
106
|
-
}
|
|
107
|
-
public async appendLargeUpload(config: { id: string; data: Buffer }): Promise<void> {
|
|
108
|
-
await this.disk.appendLargeUpload(config.id, config.data);
|
|
109
|
-
}
|
|
110
|
-
public async finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void> {
|
|
111
|
-
await this.disk.finishLargeUpload(config.id, config.path, config.lastModified);
|
|
112
|
-
}
|
|
113
|
-
public async cancelLargeUpload(config: { id: string }): Promise<void> {
|
|
114
|
-
await this.disk.cancelLargeUpload(config.id);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
type OverlayEntry = {
|
|
119
|
-
// A zero-length buffer is a pending delete (tombstone)
|
|
120
|
-
data: Buffer;
|
|
121
|
-
t: number;
|
|
122
|
-
// When to flush to our own disk + type-"remote" downstream sources (our own storage servers): short, so cross-node reads and redundancy don't wait the full writeDelay
|
|
123
|
-
fastFlushAt: number;
|
|
124
|
-
// When to flush to the remaining (non-"remote") downstream sources, e.g. backblaze: the full writeDelay, coalescing to keep expensive external writes rare
|
|
125
|
-
slowFlushAt: number;
|
|
126
|
-
// Set once the fast flush has run (own disk + remote peers written); the slow flush then only needs the non-remote sources
|
|
127
|
-
fastFlushed?: boolean;
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
// One row of the BulkDatabase2 index (key is the file path)
|
|
131
|
-
type BlobIndexEntry = {
|
|
132
|
-
key: string;
|
|
133
|
-
writeTime: number;
|
|
27
|
+
/** What we store about a file. Its times are not in here: the index keeps those for every key, deleted ones included (see LogMap). */
|
|
28
|
+
type IndexValue = {
|
|
134
29
|
size: number;
|
|
135
30
|
// Which synchronization source currently holds the data: the line number of the source's URL in the store's append-only sources list (see SourcesList) - NOT a position in the in-memory sources array, which changes between runs
|
|
136
31
|
sourcesListIndex: number;
|
|
137
32
|
};
|
|
138
|
-
|
|
33
|
+
|
|
34
|
+
/** One file we hold, as everything outside the index sees it. */
|
|
35
|
+
export type IndexEntry = IndexValue & {
|
|
139
36
|
writeTime: number;
|
|
140
|
-
size: number;
|
|
141
|
-
sourcesListIndex: number;
|
|
142
37
|
// When WE last changed this entry (not the file's write time) — what getChangesAfter2 filters on, so late-arriving files with old write times are still reported
|
|
143
38
|
changedAt: number;
|
|
144
|
-
// In-memory only (not persisted): when the file was last served, for readerDiskLimit's LRU eviction. Starts as changedAt on load.
|
|
145
|
-
lastAccess: number;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
type SourceState = {
|
|
149
|
-
supportsChangesAfter: boolean;
|
|
150
|
-
initialScan: ReturnType<typeof promiseObj>;
|
|
151
|
-
scanComplete: boolean;
|
|
152
|
-
// Files seen in this source's scans / change polls so far
|
|
153
|
-
scannedCount: number;
|
|
154
|
-
// Watermark for getChangesAfter2 polls
|
|
155
|
-
changesAfterTime: number;
|
|
156
|
-
lastMissCheck: number;
|
|
157
|
-
// Per-slot stop token: a removed source's loops stop without touching the rest of the store
|
|
158
|
-
stopped: { stop: boolean };
|
|
159
|
-
// A removed source's slot stays in the arrays (they are never spliced, so slot numbers held by running loops stay meaningful), marked dead - never scanned, written, or read. Index entries don't reference slots at all; they persist the sources list's sourcesListIndex.
|
|
160
|
-
dead?: boolean;
|
|
161
39
|
};
|
|
162
40
|
|
|
163
41
|
// One source of a live source-list update; see BlobStore.updateSources
|
|
@@ -175,31 +53,10 @@ export type BlobSourceSpec = {
|
|
|
175
53
|
sourceConfig?: SourceConfig;
|
|
176
54
|
// Only called for sources that don't match an existing live slot
|
|
177
55
|
create: () => IArchives;
|
|
56
|
+
// The opposite case: the endpoint is already running and only its config changed, so the RUNNING source is handed the new one (see applySourceConfig)
|
|
57
|
+
applyConfig?: (source: IArchives) => void;
|
|
178
58
|
};
|
|
179
59
|
|
|
180
|
-
// What a scanned listing entry meant when compared against our index
|
|
181
|
-
type ScanOutcome = "filtered" | "new" | "updated" | "tombstone" | "unchanged";
|
|
182
|
-
type ScanTally = Record<ScanOutcome, number>;
|
|
183
|
-
function newScanTally(): ScanTally {
|
|
184
|
-
return { filtered: 0, new: 0, updated: 0, tombstone: 0, unchanged: 0 };
|
|
185
|
-
}
|
|
186
|
-
function formatScanTally(tally: ScanTally, total: number): string {
|
|
187
|
-
let pct = (n: number) => `${Math.round(n / Math.max(total, 1) * 1000) / 10}%`;
|
|
188
|
-
return `${tally.new} new paths (${pct(tally.new)}), ${tally.updated} newer writes (${pct(tally.updated)}), ${tally.tombstone} deletions (${pct(tally.tombstone)}), ${tally.unchanged} unchanged (${pct(tally.unchanged)}), ${tally.filtered} outside route (${pct(tally.filtered)})`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function newSourceState(): SourceState {
|
|
192
|
-
return {
|
|
193
|
-
supportsChangesAfter: false,
|
|
194
|
-
initialScan: promiseObj(),
|
|
195
|
-
scanComplete: false,
|
|
196
|
-
scannedCount: 0,
|
|
197
|
-
changesAfterTime: 0,
|
|
198
|
-
lastMissCheck: 0,
|
|
199
|
-
stopped: { stop: false },
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
60
|
let lastWrongTargetLog = 0;
|
|
204
61
|
function logWrongTargetRejection(message: string): void {
|
|
205
62
|
if (Date.now() - lastWrongTargetLog < WRONG_TARGET_LOG_THROTTLE) return;
|
|
@@ -207,52 +64,181 @@ function logWrongTargetRejection(message: string): void {
|
|
|
207
64
|
console.log(message);
|
|
208
65
|
}
|
|
209
66
|
|
|
210
|
-
|
|
67
|
+
// The bucket store, and the only one: every bucket a server holds is served by these, one per store name in its routing config. Its sources are plain IArchives - the first is always the store's own disk folder, the rest are the configured peers (see createStoreSource) - so "no synchronization" is simply a store with no peers, not a different kind of store.
|
|
68
|
+
export class BlobStore {
|
|
69
|
+
// ── state ──
|
|
70
|
+
// Set once the store is shutting down; every loop it owns (and StoreSync's maintenance loops) stops on it
|
|
71
|
+
public stopped = { stop: false };
|
|
72
|
+
// Whether init has reached the point where a newly added source can start scanning immediately
|
|
73
|
+
public syncStarted = false;
|
|
74
|
+
/** Its sources, in config order: slot 0 is always its own disk folder, the rest are the peers it synchronizes with. Filled by updateSources, which is also how they change. The store OWNS them - writes pick among them, reads resolve holders through them - and StoreSync only scans whatever is in here at the time. */
|
|
75
|
+
public sources: ArchivesSource[] = [];
|
|
76
|
+
// Uploads immutability already decided against: the client is mid-stream by the time we know, so it keeps sending and every part is dropped
|
|
77
|
+
private discardedUploads = new Set<string>();
|
|
78
|
+
private nextDiscardedUpload = 1;
|
|
79
|
+
// The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
|
|
80
|
+
private sourcesList: SourcesList;
|
|
81
|
+
// Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
|
|
82
|
+
private slotSourcesListIndexes: number[] = [];
|
|
83
|
+
private slotRegistrations: Promise<void>[] = [];
|
|
84
|
+
// Every file we know of, in memory, with an append-only log of the changes behind it (see LogMap). A new file name each time the entry format changes generation: the previous ones cannot be read, and a scan rebuilds the index anyway, so they are simply never read again.
|
|
85
|
+
private index: LogMap<IndexValue>;
|
|
86
|
+
/** Keeping the index in agreement with the sources: scanning, pulling, pushing, and the maintenance that follows from holding an index (disk-limit eviction, tombstone expiry). It reads and writes this store's index and sources - it does not own them. */
|
|
87
|
+
public sync: StoreSync;
|
|
88
|
+
|
|
211
89
|
constructor(
|
|
212
|
-
|
|
213
|
-
|
|
90
|
+
// Public for StoreSync, which logs it with everything it does
|
|
91
|
+
public folder: string,
|
|
92
|
+
/** The name this store answers to (see CommonConfig.name) - the entries of the routing config that carry it are the ones that configure it, and the rest are its peers. */
|
|
93
|
+
public storeName: string,
|
|
214
94
|
private config?: {
|
|
95
|
+
/** Whether a config entry is THIS SERVER (same account, same bucket, our own address). Injected because a store knows nothing about servers - it only needs to tell its own entries apart from its peers'. Absent means nothing is us, which is what a bare store (no server around it) wants. */
|
|
96
|
+
isSelf?: (source: SourceConfig) => boolean;
|
|
97
|
+
/** Builds one of its sources. Injected for the same reason, and because the delay it is created with is this store's policy. */
|
|
98
|
+
createSource?: (config: { sourceConfig?: SourceConfig; writeDelay: number }) => IArchives;
|
|
99
|
+
/** Hands a running source a changed config, so an endpoint we already talk to is never rebuilt just because a flag moved. */
|
|
100
|
+
applySource?: (source: IArchives, sourceConfig: SourceConfig | undefined, writeDelay: number) => void;
|
|
215
101
|
// Called whenever a key's index entry changes (our own writes AND files pulled in via synchronization) — how the storage server notices routing config updates.
|
|
216
102
|
onIndexChanged?: (key: string) => void;
|
|
217
|
-
// LRU-bound the disk (base source) to this many bytes; see CommonConfig.readerDiskLimit
|
|
218
|
-
readerDiskLimit?: number;
|
|
219
103
|
// Every accepted write ("original") and every write that actually reached the sources ("flushed"). Fast writes coalesce, so the two counts differ.
|
|
220
104
|
onWriteCounted?: (kind: "original" | "flushed", bytes: number) => void;
|
|
221
105
|
// Resolves a persisted source URL (see ArchivesSource.url) to a cached IArchives, so entries whose holder is no longer configured can still be read
|
|
222
106
|
resolveSourceUrl?: (url: string) => IArchives;
|
|
223
|
-
// This store's own entries in the routing config (all the same route, different valid windows). What set/del validate against: valid windows and routes for fresh writes, immutability, internal-push acceptance, and the fast/writeDelay flags. Empty = no validation (a store serving leftover disk data with no config).
|
|
224
|
-
entries?: HostedConfig[];
|
|
225
107
|
}
|
|
226
|
-
) {
|
|
108
|
+
) {
|
|
109
|
+
this.sourcesList = new SourcesList(path.join(folder, "index", "sourcesList.txt"));
|
|
110
|
+
this.index = new LogMap<IndexValue>(path.join(folder, "index", "blobIndex3.log"));
|
|
111
|
+
this.sync = new StoreSync(this);
|
|
112
|
+
this.storeConfig = new StoreConfig(storeName, []);
|
|
113
|
+
this.ownDisk = new ArchivesDisk(folder);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** This store's folder, unwrapped: the same bytes slot 0 serves, but reached without its write delay. Used for the two things that cannot go through a buffered source - reading our own routing config before we have any sources, and streaming a large upload that must not sit in memory. */
|
|
117
|
+
private ownDisk: ArchivesDisk;
|
|
118
|
+
|
|
119
|
+
/** What this store is configured to be. It owns this: the routing config is a file IN the store, so the store reads it, applies it to itself, and re-applies it whenever the file changes - by our own write, or by a peer's copy arriving through synchronization. */
|
|
120
|
+
public storeConfig: StoreConfig;
|
|
121
|
+
// The routing config this store last applied, so a write of an identical (or older) one is not re-applied
|
|
122
|
+
private appliedRoutingVersion = -1;
|
|
227
123
|
|
|
228
124
|
// #region Main interface
|
|
229
125
|
|
|
230
126
|
public init = lazy(async () => {
|
|
127
|
+
// Configure first: the config decides what the sources ARE, and everything below is per source. With no config at all that is just our own disk, which is enough to serve and to be written to.
|
|
128
|
+
await this.applyRoutingConfig();
|
|
231
129
|
for (let i = 0; i < this.sources.length; i++) {
|
|
232
130
|
await this.registerSlot(i);
|
|
233
131
|
}
|
|
132
|
+
// After the slots, which is what index entries name as their holder
|
|
234
133
|
await this.loadIndex();
|
|
235
134
|
this.syncStarted = true;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
135
|
+
this.sync.start();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Re-reads the routing config out of this store and applies it to itself. Called at startup and
|
|
140
|
+
* whenever that file changes here - which is the ONE mechanism: a config written by an operator
|
|
141
|
+
* and a config pulled off a peer are the same event, a write of that path into this store.
|
|
142
|
+
*
|
|
143
|
+
* A store with no routing config configures itself as its own disk, valid always, for the whole
|
|
144
|
+
* key space. That is a complete, working store - it just has nobody to synchronize with - and it
|
|
145
|
+
* is what lets a store exist before it has ever heard of a configuration.
|
|
146
|
+
*/
|
|
147
|
+
public async applyRoutingConfig(): Promise<void> {
|
|
148
|
+
let routing = await this.readRoutingConfig();
|
|
149
|
+
let entries: HostedConfig[] = [];
|
|
150
|
+
let peers: SourceConfig[] = [];
|
|
151
|
+
for (let source of routing?.sources || []) {
|
|
152
|
+
if (typeof source === "string") continue;
|
|
153
|
+
if (source.name === this.storeName && this.config?.isSelf?.(source)) {
|
|
154
|
+
entries.push(source as HostedConfig);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
peers.push(source);
|
|
239
158
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.stopped);
|
|
243
|
-
if (this.config?.readerDiskLimit) {
|
|
244
|
-
runInfinitePoll(DISK_LIMIT_CHECK_INTERVAL, () => this.enforceDiskLimit(), this.stopped);
|
|
159
|
+
if (routing && !entries.length) {
|
|
160
|
+
console.warn(`The routing config in store ${this.folder} (version ${getConfigVersion(routing)}) does not name this store (${JSON.stringify(this.storeName)}) as one of ours, so it stays a disk-only store`);
|
|
245
161
|
}
|
|
246
|
-
|
|
162
|
+
this.storeConfig.update(entries);
|
|
163
|
+
this.updateSources(this.planSources(peers));
|
|
164
|
+
this.appliedRoutingVersion = routing && getConfigVersion(routing) || -1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The routing config as this store holds it, read straight off its folder: the store may be configuring itself before it has scanned anything, and this file is what decides what a scan would even talk to
|
|
168
|
+
private async readRoutingConfig(): Promise<RemoteConfig | undefined> {
|
|
169
|
+
let data = await this.ownDisk.get(ROUTING_FILE);
|
|
170
|
+
if (!data || !data.length) return undefined;
|
|
171
|
+
try {
|
|
172
|
+
return parseRoutingData(data);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
console.error(`Ignoring the routing config in store ${this.folder}: it could not be parsed (${(e as Error).message})`);
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The version of the routing config this store is running, so a copy found on a peer is only taken when it is genuinely newer. -1 means it has none. */
|
|
180
|
+
public routingVersion(): number {
|
|
181
|
+
return this.appliedRoutingVersion;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Applying it touches the sources, so it is serialized behind whatever application is already running rather than interleaving with it
|
|
185
|
+
private routingApplies: Promise<void> = Promise.resolve();
|
|
186
|
+
public reapplyRoutingConfig(): void {
|
|
187
|
+
let next = this.routingApplies.then(() => this.applyRoutingConfig());
|
|
188
|
+
this.routingApplies = next.catch(() => { });
|
|
189
|
+
void next.catch((e: Error) => console.error(`Applying the routing config in store ${this.folder} failed: ${e.stack ?? e}`));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Our own disk first (always), then every peer whose route overlaps ours - the sources this store synchronizes with, as its own config describes them
|
|
193
|
+
private planSources(peers: SourceConfig[]): BlobSourceSpec[] {
|
|
194
|
+
let self = this.storeConfig.current();
|
|
195
|
+
let makeSpec = (sourceConfig: SourceConfig | undefined, validWindows: [number, number][], route: [number, number] | undefined, noFullSync: boolean | undefined): BlobSourceSpec => {
|
|
196
|
+
let writeDelay = sourceWriteDelay({ sourceConfig, fast: self.fast, writeDelay: self.writeDelay });
|
|
197
|
+
return {
|
|
198
|
+
identity: sourceIdentity(sourceConfig),
|
|
199
|
+
url: sourcePersistentUrl(sourceConfig, this.folder),
|
|
200
|
+
validWindows,
|
|
201
|
+
route,
|
|
202
|
+
noFullSync,
|
|
203
|
+
intermediate: sourceConfig?.intermediate,
|
|
204
|
+
sourceConfig,
|
|
205
|
+
create: () => {
|
|
206
|
+
let created = this.config?.createSource?.({ sourceConfig, writeDelay });
|
|
207
|
+
if (!created) {
|
|
208
|
+
throw new Error(`This store has no way to build sources, so it cannot use ${JSON.stringify(sourceConfig?.url || this.folder)} (store ${this.folder}). Pass createSource.`);
|
|
209
|
+
}
|
|
210
|
+
return created;
|
|
211
|
+
},
|
|
212
|
+
applyConfig: (source: IArchives) => this.config?.applySource?.(source, sourceConfig, writeDelay),
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
// Our own disk accepts writes while ANY window this store holds is open; with no config at all it is simply always valid
|
|
216
|
+
let ownWindows = this.storeConfig.all().map(x => x.validWindow);
|
|
217
|
+
let specs: BlobSourceSpec[] = [makeSpec(undefined, ownWindows.length && ownWindows || [FULL_VALID_WINDOW], undefined, undefined)];
|
|
218
|
+
// One spec per endpoint, carrying all of its windows: a switchover splits one window around an intermediate, and that is still one source to us
|
|
219
|
+
let byIdentity = new Map<string, BlobSourceSpec>();
|
|
220
|
+
for (let peer of peers) {
|
|
221
|
+
let shared = routeIntersection(self.route, peer.route);
|
|
222
|
+
if (!shared) continue;
|
|
223
|
+
let identity = sourceIdentity(peer);
|
|
224
|
+
let existing = byIdentity.get(identity);
|
|
225
|
+
if (existing) {
|
|
226
|
+
existing.validWindows.push(peer.validWindow);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
let spec = makeSpec(peer, [peer.validWindow], shared, peer.noFullSync || self.noFullSync);
|
|
230
|
+
byIdentity.set(identity, spec);
|
|
231
|
+
specs.push(spec);
|
|
232
|
+
}
|
|
233
|
+
return specs;
|
|
234
|
+
}
|
|
247
235
|
|
|
248
|
-
// Stops all synchronization scans/polls and flushes pending writes. Only used when the store genuinely cannot continue (
|
|
236
|
+
// Stops all synchronization scans/polls and flushes pending writes. Only used when the store genuinely cannot continue (its route or disk limit changed, process shutdown) - routine config changes go through updateSources instead, which the store survives.
|
|
249
237
|
public async dispose(): Promise<void> {
|
|
250
238
|
this.stopped.stop = true;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await this.flushOverlay(true);
|
|
255
|
-
await this.flushIndex();
|
|
239
|
+
this.sync.stop();
|
|
240
|
+
await this.flushDelayedWrites(true);
|
|
241
|
+
await this.index.flush();
|
|
256
242
|
}
|
|
257
243
|
|
|
258
244
|
public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
@@ -262,25 +248,16 @@ export class BlobStore implements IBucketStore {
|
|
|
262
248
|
await this.init();
|
|
263
249
|
let key = config.path;
|
|
264
250
|
let range = config.range;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (overlayEntry.data.length === 0 && !config.includeTombstones) return undefined;
|
|
269
|
-
let data = overlayEntry.data;
|
|
270
|
-
let size = data.length;
|
|
271
|
-
if (range) {
|
|
272
|
-
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
273
|
-
}
|
|
274
|
-
return { data, writeTime: overlayEntry.t, size };
|
|
275
|
-
}
|
|
276
|
-
let entry = await this.getIndexEntry(key);
|
|
277
|
-
if (!entry) return undefined;
|
|
278
|
-
if (entry.size === 0) {
|
|
251
|
+
// Not in the index means it does not exist here: the index IS the answer, and scanning heals it on its own schedule. An entry whose holder is no longer in the source list is still valid - getEntryHolder resolves the persisted URL directly, and get2's fallback loop covers a holder that is gone entirely.
|
|
252
|
+
let entry = this.getIndexEntry(key);
|
|
253
|
+
if (!entry) {
|
|
279
254
|
if (!config.includeTombstones) return undefined;
|
|
280
|
-
// A
|
|
281
|
-
|
|
255
|
+
// A deletion has no bytes - the tombstone IS the answer, so a flag-caller gets its time with empty data
|
|
256
|
+
let deleted = this.getDeletedEntry(key);
|
|
257
|
+
if (!deleted) return undefined;
|
|
258
|
+
return { data: Buffer.alloc(0), writeTime: deleted.writeTime, size: 0 };
|
|
282
259
|
}
|
|
283
|
-
|
|
260
|
+
this.sync.noteAccess(key);
|
|
284
261
|
let holderArchives = await this.getEntryHolder(entry);
|
|
285
262
|
let result: { data: Buffer; writeTime: number; size: number } | undefined;
|
|
286
263
|
let holderError: Error | undefined;
|
|
@@ -334,8 +311,8 @@ export class BlobStore implements IBucketStore {
|
|
|
334
311
|
return { data, writeTime: fallback.writeTime, size: fallback.size };
|
|
335
312
|
}
|
|
336
313
|
if (holderError) throw holderError;
|
|
337
|
-
// The holder answered "not there" and no other source has it either: the entry was stale
|
|
338
|
-
this.
|
|
314
|
+
// The holder answered "not there" and no other source has it either: the entry was stale. Forgotten rather than deleted - nothing happened to this file, we were simply wrong about holding it, and saying otherwise would push a deletion out to everyone else.
|
|
315
|
+
this.purgeIndexEntry(key);
|
|
339
316
|
return undefined;
|
|
340
317
|
}
|
|
341
318
|
|
|
@@ -347,25 +324,19 @@ export class BlobStore implements IBucketStore {
|
|
|
347
324
|
await this.init();
|
|
348
325
|
let writeTime = config.lastModified || Date.now();
|
|
349
326
|
let route = getRoute(key);
|
|
350
|
-
// The routing file defines the windows/routes, so they can't possibly apply to it (and it never flows through validation)
|
|
351
|
-
if (key
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
if (!timeValid.some(x => routeContains(x.route, route))) {
|
|
359
|
-
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): route ${route} is outside our routes ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))} at writeTime ${writeTime} (the client's shard config is stale)`);
|
|
360
|
-
throw new Error(`${STORAGE_WRONG_ROUTE} This store does not handle route ${route} (key ${JSON.stringify(key)}, our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}, store ${this.folder}). Re-resolve the source for this key and retry.`);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
327
|
+
// The routing file defines the windows/routes, so they can't possibly apply to it (and it never flows through validation) - but it has a rule of its own, which is ours to enforce because the file is ours
|
|
328
|
+
if (key === ROUTING_FILE) {
|
|
329
|
+
this.assertRoutingConfigWritable(data);
|
|
330
|
+
} else {
|
|
331
|
+
this.assertWriteTarget(key, route, config.lastModified);
|
|
332
|
+
}
|
|
333
|
+
if (key !== ROUTING_FILE && this.storeConfig.all().length) {
|
|
363
334
|
if (config.forceSetImmutable) {
|
|
364
335
|
if (!config.lastModified) {
|
|
365
336
|
throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${JSON.stringify(key)} (store ${this.folder})`);
|
|
366
337
|
}
|
|
367
338
|
// Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable)
|
|
368
|
-
let self = selectEntryAt(this.
|
|
339
|
+
let self = selectEntryAt(this.storeConfig.all(), writeTime, route);
|
|
369
340
|
if (self?.immutable && await this.getInfo({ path: key })) return;
|
|
370
341
|
} else {
|
|
371
342
|
await this.assertMutable(key, writeTime);
|
|
@@ -375,12 +346,14 @@ export class BlobStore implements IBucketStore {
|
|
|
375
346
|
if (!config.lastModified) {
|
|
376
347
|
throw new Error(`Internal writes must carry lastModified (they are synchronization pushes, ordered by their write time), writing ${JSON.stringify(key)} (store ${this.folder})`);
|
|
377
348
|
}
|
|
378
|
-
|
|
349
|
+
// The routing file is exempt for the same reason it skips every other check: it is the file that DEFINES our windows and routes, so judging it by the config it is about to replace is how a store gets stuck on a config it can never be told to leave
|
|
350
|
+
if (key !== ROUTING_FILE) {
|
|
351
|
+
this.assertInternalWriteAccepted(key, config.lastModified, route);
|
|
352
|
+
}
|
|
379
353
|
await this.setInternal(key, data, { lastModified: config.lastModified });
|
|
380
354
|
return;
|
|
381
355
|
}
|
|
382
|
-
|
|
383
|
-
await this.setOrDelete(key, data, { fast: self?.fast, writeDelay: self?.writeDelay, lastModified: config.lastModified });
|
|
356
|
+
await this.setOrDelete(key, data, { lastModified: config.lastModified });
|
|
384
357
|
}
|
|
385
358
|
|
|
386
359
|
public async del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void> {
|
|
@@ -389,6 +362,8 @@ export class BlobStore implements IBucketStore {
|
|
|
389
362
|
throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
|
|
390
363
|
}
|
|
391
364
|
await this.init();
|
|
365
|
+
// A deletion lands in exactly the same place a write would, so it is judged the same way: one aimed at a shard we don't serve is just as invisible as a write to it
|
|
366
|
+
this.assertWriteTarget(key, getRoute(key), config.lastModified);
|
|
392
367
|
if (config.internal) {
|
|
393
368
|
if (!config.lastModified) {
|
|
394
369
|
throw new Error(`Internal deletions must carry lastModified (they are synchronization pushes, ordered by their write time), deleting ${JSON.stringify(key)} (store ${this.folder})`);
|
|
@@ -398,66 +373,67 @@ export class BlobStore implements IBucketStore {
|
|
|
398
373
|
await this.setInternal(key, Buffer.alloc(0), { lastModified: config.lastModified });
|
|
399
374
|
return;
|
|
400
375
|
}
|
|
401
|
-
// Deletes are
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
376
|
+
// Deletes are writes (an empty file IS a missing file): the tombstone is ordered by write time like any other write, propagates through synchronization, and is eventually expired
|
|
377
|
+
await this.setOrDelete(key, Buffer.alloc(0), { lastModified: config.lastModified });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A node-side move: the bytes never travel through the client. Deliberately just get2 + set + del rather than a disk rename, so the destination write passes EVERY rule a set passes (windows, routes, immutability, only-take-latest, index, fan-out to peers) and the deletion propagates as a normal tombstone - a rename would bypass all of it. The set stamps fresh, so the moved file beats any tombstone at its new path. */
|
|
381
|
+
public async move(config: { fromPath: string; toPath: string }): Promise<void> {
|
|
382
|
+
await this.init();
|
|
383
|
+
if (config.fromPath === config.toPath) return;
|
|
384
|
+
if (config.fromPath === ROUTING_FILE || config.toPath === ROUTING_FILE) {
|
|
385
|
+
throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be moved (store ${this.folder})`);
|
|
386
|
+
}
|
|
387
|
+
let result = await this.get2({ path: config.fromPath });
|
|
388
|
+
if (!result || !result.data.length) {
|
|
389
|
+
throw new Error(`Cannot move ${JSON.stringify(config.fromPath)} to ${JSON.stringify(config.toPath)}: the source file does not exist (store ${this.folder})`);
|
|
390
|
+
}
|
|
391
|
+
await this.set({ path: config.toPath, data: result.data });
|
|
392
|
+
await this.del({ path: config.fromPath });
|
|
405
393
|
}
|
|
406
394
|
|
|
407
395
|
public async getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined> {
|
|
408
396
|
await this.init();
|
|
409
397
|
let key = config.path;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (!entry.size && !config.includeTombstones) return undefined;
|
|
418
|
-
return { writeTime: entry.writeTime, size: entry.size };
|
|
398
|
+
// Not in the index means it does not exist here: the index IS the answer, and scanning heals it on its own schedule. An entry whose holder is no longer in the source list is still valid - getEntryHolder resolves the persisted URL directly, and get2's fallback loop covers a holder that is gone entirely.
|
|
399
|
+
let entry = this.getIndexEntry(key);
|
|
400
|
+
if (entry) return { writeTime: entry.writeTime, size: entry.size };
|
|
401
|
+
if (!config.includeTombstones) return undefined;
|
|
402
|
+
let deleted = this.getDeletedEntry(key);
|
|
403
|
+
if (!deleted) return undefined;
|
|
404
|
+
return { writeTime: deleted.writeTime, size: 0 };
|
|
419
405
|
}
|
|
420
406
|
|
|
421
407
|
public async findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]> {
|
|
422
408
|
await this.init();
|
|
423
|
-
await this.waitForRequiredScans();
|
|
409
|
+
await this.sync.waitForRequiredScans();
|
|
424
410
|
let prefix = config.prefix;
|
|
425
411
|
let infos = new Map<string, ArchiveFileInfo>();
|
|
426
|
-
|
|
412
|
+
// Deletions are not in here at all, which is what a listing wants: an empty file IS a missing file
|
|
413
|
+
for (let [key, entry] of this.indexEntries()) {
|
|
427
414
|
if (!key.startsWith(prefix)) continue;
|
|
428
|
-
// Tombstones are missing files, so listings hide them
|
|
429
|
-
if (entry.size === 0) continue;
|
|
430
415
|
infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
|
|
431
416
|
}
|
|
432
|
-
for (let [key, overlayEntry] of this.overlay) {
|
|
433
|
-
if (!key.startsWith(prefix)) continue;
|
|
434
|
-
if (overlayEntry.data.length === 0) {
|
|
435
|
-
infos.delete(key);
|
|
436
|
-
continue;
|
|
437
|
-
}
|
|
438
|
-
infos.set(key, { path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
|
|
439
|
-
}
|
|
440
417
|
let files = applyFindInfoShape(Array.from(infos.values()), prefix, { shallow: config.shallow, type: config.type });
|
|
441
418
|
sort(files, x => x.path);
|
|
442
419
|
return files;
|
|
443
420
|
}
|
|
444
421
|
|
|
445
|
-
// All files changed after config.time —
|
|
422
|
+
// All files changed after config.time — straight from the index. Filters on when WE learned of the change (changedAt), so files synchronized late (with old write times) are still reported. Deletions ARE reported, as size-0 entries: a caller reading a change feed has to hear about them, and size 0 is how the feed says "gone" (an empty file IS a missing file). config.routes lets a store syncing a partial shard ask for just its slice.
|
|
446
423
|
public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
447
424
|
await this.init();
|
|
448
|
-
await this.waitForRequiredScans();
|
|
425
|
+
await this.sync.waitForRequiredScans();
|
|
449
426
|
let inRoutes = (key: string) => !config.routes || config.routes.some(route => routeContains(route, getRoute(key)));
|
|
450
427
|
let files: ArchiveFileInfo[] = [];
|
|
451
|
-
for (let [key, entry] of this.
|
|
428
|
+
for (let [key, entry] of this.indexEntries()) {
|
|
452
429
|
if (entry.changedAt <= config.time) continue;
|
|
453
|
-
if (this.overlay.has(key)) continue;
|
|
454
430
|
if (!inRoutes(key)) continue;
|
|
455
431
|
files.push({ path: key, createTime: entry.writeTime, size: entry.size });
|
|
456
432
|
}
|
|
457
|
-
for (let [key,
|
|
458
|
-
if (
|
|
433
|
+
for (let [key, tombstone] of this.deletedEntries()) {
|
|
434
|
+
if (tombstone.changedAt <= config.time) continue;
|
|
459
435
|
if (!inRoutes(key)) continue;
|
|
460
|
-
files.push({ path: key, createTime:
|
|
436
|
+
files.push({ path: key, createTime: tombstone.writeTime, size: 0 });
|
|
461
437
|
}
|
|
462
438
|
sort(files, x => x.path);
|
|
463
439
|
return files;
|
|
@@ -465,70 +441,68 @@ export class BlobStore implements IBucketStore {
|
|
|
465
441
|
|
|
466
442
|
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
467
443
|
await this.init();
|
|
468
|
-
return
|
|
469
|
-
allScansComplete: this.sourceStates.every(x => x.scanComplete),
|
|
470
|
-
indexSize: this.mem.size,
|
|
471
|
-
sources: this.sources.map((x, i) => ({
|
|
472
|
-
debugName: x.source.getDebugName(),
|
|
473
|
-
validWindows: x.validWindows,
|
|
474
|
-
route: x.route,
|
|
475
|
-
noFullSync: x.noFullSync,
|
|
476
|
-
supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
|
|
477
|
-
initialScanComplete: this.sourceStates[i].scanComplete,
|
|
478
|
-
scannedCount: this.sourceStates[i].scannedCount,
|
|
479
|
-
})).filter((x, i) => this.isLive(i)),
|
|
480
|
-
};
|
|
444
|
+
return this.sync.getStatus();
|
|
481
445
|
}
|
|
482
446
|
|
|
483
|
-
/** The
|
|
447
|
+
/** The index's totals plus any in-progress background synchronization. */
|
|
484
448
|
public getSyncProgress(): {
|
|
485
449
|
index: { fileCount: number; byteCount: number };
|
|
486
450
|
sources: { debugName: string; fileCount: number; byteCount: number }[];
|
|
487
451
|
readerDiskLimit?: number;
|
|
488
452
|
syncing: SyncActivity[];
|
|
489
453
|
} {
|
|
454
|
+
let totals = this.namedIndexTotals();
|
|
490
455
|
return {
|
|
491
|
-
index: { fileCount:
|
|
492
|
-
sources:
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
byteCount: this.sourceByteCounts[i],
|
|
496
|
-
})).filter((x, i) => this.isLive(i)),
|
|
497
|
-
readerDiskLimit: this.config?.readerDiskLimit,
|
|
498
|
-
syncing: [...this.syncActivities],
|
|
456
|
+
index: { fileCount: totals.fileCount, byteCount: totals.byteCount },
|
|
457
|
+
sources: totals.sources,
|
|
458
|
+
readerDiskLimit: this.readerDiskLimit,
|
|
459
|
+
syncing: this.sync.getActivities(),
|
|
499
460
|
};
|
|
500
461
|
}
|
|
501
462
|
|
|
502
|
-
/**
|
|
463
|
+
/** getSyncProgress's totals, but loading the index first, so they are never the zeroes of a store nothing has touched yet. */
|
|
503
464
|
public async computeIndexTotals(): Promise<{
|
|
504
465
|
fileCount: number;
|
|
505
466
|
byteCount: number;
|
|
506
467
|
sources: { debugName: string; fileCount: number; byteCount: number }[];
|
|
507
468
|
}> {
|
|
508
469
|
await this.init();
|
|
509
|
-
|
|
510
|
-
let byteCount = 0;
|
|
511
|
-
let sources = this.sources.map(x => ({ debugName: x.source.getDebugName(), fileCount: 0, byteCount: 0 }));
|
|
512
|
-
for (let entry of this.mem.values()) {
|
|
513
|
-
if (entry.size === 0) continue;
|
|
514
|
-
fileCount++;
|
|
515
|
-
byteCount += entry.size;
|
|
516
|
-
let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
517
|
-
if (slot !== undefined) {
|
|
518
|
-
sources[slot].fileCount++;
|
|
519
|
-
sources[slot].byteCount += entry.size;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
return { fileCount, byteCount, sources: sources.filter((x, i) => this.isLive(i)) };
|
|
470
|
+
return this.namedIndexTotals();
|
|
523
471
|
}
|
|
524
472
|
|
|
525
|
-
|
|
526
|
-
|
|
473
|
+
private namedIndexTotals(): { fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } {
|
|
474
|
+
let totals = this.indexTotals();
|
|
475
|
+
return {
|
|
476
|
+
fileCount: totals.fileCount,
|
|
477
|
+
byteCount: totals.byteCount,
|
|
478
|
+
sources: this.sources
|
|
479
|
+
.map((x, i) => ({ debugName: x.source.getDebugName(), ...totals.slots[i] }))
|
|
480
|
+
.filter((x, i) => this.isLive(i)),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The store's sources, as the current routing config says they should be. This is the ONLY way
|
|
486
|
+
* they are ever set: the first call populates an empty store, every later one applies a change to
|
|
487
|
+
* the running one. Windows, routes and flags move in place, genuinely new endpoints are added and
|
|
488
|
+
* start scanning, and endpoints that are gone go dead (their scans stop, their index entries
|
|
489
|
+
* drop).
|
|
490
|
+
*
|
|
491
|
+
* A store is never rebuilt for a config change. Its name decides its folder and its identity, and
|
|
492
|
+
* a config change cannot change either - so there is nothing a change can do to a store except
|
|
493
|
+
* this.
|
|
494
|
+
*/
|
|
495
|
+
public updateSources(specs: BlobSourceSpec[]): void {
|
|
527
496
|
if (!specs.length || specs[0].identity !== "disk") {
|
|
528
497
|
throw new Error(`updateSources expects the disk source first (identity "disk"), got ${JSON.stringify(specs.map(x => x.identity))} (store ${this.folder})`);
|
|
529
498
|
}
|
|
530
|
-
if (
|
|
531
|
-
|
|
499
|
+
if (!this.sources.length) {
|
|
500
|
+
// First call: the disk slot has to exist before anything can be matched against it
|
|
501
|
+
let disk = specs[0];
|
|
502
|
+
let source = disk.create();
|
|
503
|
+
asDelayed(source)?.bindFlushDeadline(() => this.writeFlushDeadline());
|
|
504
|
+
this.sources.push({ source, url: disk.url, validWindows: disk.validWindows, route: disk.route, noFullSync: disk.noFullSync, sourceConfig: disk.sourceConfig, identity: disk.identity });
|
|
505
|
+
this.sync.addSource(0);
|
|
532
506
|
}
|
|
533
507
|
let setWindows = (i: number, windows: [number, number][]) => {
|
|
534
508
|
let old = this.sources[i].validWindows;
|
|
@@ -555,6 +529,7 @@ export class BlobStore implements IBucketStore {
|
|
|
555
529
|
for (let spec of specs.slice(1)) {
|
|
556
530
|
let slot = liveByIdentity.get(spec.identity)?.shift();
|
|
557
531
|
if (slot !== undefined) {
|
|
532
|
+
// The SAME endpoint (see sourceIdentity), so we are already synchronized with it: the slot keeps its connection, its completed scans, and its index entries, and only the policy on it moves. Recreating it here would mean rescanning the endpoint from scratch every time a flag changes.
|
|
558
533
|
matched.add(slot);
|
|
559
534
|
setWindows(slot, spec.validWindows);
|
|
560
535
|
let existing = this.sources[slot];
|
|
@@ -562,110 +537,98 @@ export class BlobStore implements IBucketStore {
|
|
|
562
537
|
console.log(`Route changed for ${existing.source.getDebugName()} (store ${this.folder}): ${JSON.stringify(existing.route)} -> ${JSON.stringify(spec.route)}`);
|
|
563
538
|
existing.route = spec.route;
|
|
564
539
|
}
|
|
540
|
+
if (JSON.stringify(existing.sourceConfig) !== JSON.stringify(spec.sourceConfig)) {
|
|
541
|
+
console.log(`Config changed for ${existing.source.getDebugName()} (store ${this.folder}), keeping the source (so it is not rescanned): ${JSON.stringify(existing.sourceConfig)} -> ${JSON.stringify(spec.sourceConfig)}`);
|
|
542
|
+
}
|
|
543
|
+
// The source carries its config (and its write delay) into everything it does, so it has to learn the new one - see applySourceConfig
|
|
544
|
+
spec.applyConfig?.(existing.source);
|
|
565
545
|
existing.noFullSync = spec.noFullSync;
|
|
566
546
|
existing.sourceConfig = spec.sourceConfig;
|
|
567
547
|
continue;
|
|
568
548
|
}
|
|
569
549
|
let source = spec.create();
|
|
550
|
+
asDelayed(source)?.bindFlushDeadline(() => this.writeFlushDeadline());
|
|
570
551
|
this.sources.push({ source, url: spec.url, validWindows: spec.validWindows, route: spec.route, noFullSync: spec.noFullSync, intermediate: spec.intermediate, sourceConfig: spec.sourceConfig, identity: spec.identity });
|
|
571
|
-
|
|
572
|
-
this.
|
|
573
|
-
this.sourceByteCounts.push(0);
|
|
574
|
-
console.log(`Added sync source ${source.getDebugName()} (store ${this.folder})`);
|
|
575
|
-
if (this.syncStarted) {
|
|
576
|
-
void this.runSourceSync(this.sources.length - 1);
|
|
577
|
-
}
|
|
552
|
+
console.log(`Added sync source ${source.getDebugName()} (store ${this.folder}): a genuinely new endpoint, so it is scanned from scratch`);
|
|
553
|
+
this.sync.addSource(this.sources.length - 1);
|
|
578
554
|
}
|
|
579
555
|
for (let i = 1; i < originalLength; i++) {
|
|
580
556
|
if (!this.isLive(i) || matched.has(i)) continue;
|
|
581
|
-
this
|
|
582
|
-
|
|
583
|
-
let deadline = this.baseWriteWindowEnd() - WINDOW_END_FLUSH_MARGIN;
|
|
584
|
-
let recapped = 0;
|
|
585
|
-
for (let entry of this.overlay.values()) {
|
|
586
|
-
if (entry.fastFlushAt <= deadline && entry.slowFlushAt <= deadline) continue;
|
|
587
|
-
entry.fastFlushAt = Math.min(entry.fastFlushAt, deadline);
|
|
588
|
-
entry.slowFlushAt = Math.min(entry.slowFlushAt, deadline);
|
|
589
|
-
recapped++;
|
|
590
|
-
}
|
|
591
|
-
if (recapped) {
|
|
592
|
-
console.log(`Re-capped ${recapped} pending fast writes to the new flush deadline ${new Date(deadline).toISOString()} (store ${this.folder})`);
|
|
593
|
-
if (deadline <= Date.now()) {
|
|
594
|
-
void this.flushOverlay().catch((e: Error) => console.error(`Flushing fast writes after a valid window change failed (store ${this.folder}): ${e.stack ?? e}`));
|
|
595
|
-
}
|
|
557
|
+
// The slot is dead the moment this returns; dropping the index entries it held is the part that reads the index, so it finishes in the background
|
|
558
|
+
void this.sync.removeSource(i).catch((e: Error) => console.error(`Removing sync source ${this.sources[i].source.getDebugName()} (store ${this.folder}) failed: ${e.stack ?? e}`));
|
|
596
559
|
}
|
|
597
560
|
}
|
|
598
561
|
|
|
599
562
|
/** Rescans our own disk's metadata into the index - used around valid window handoffs, where another process wrote files to the shared folder that our index hasn't seen. */
|
|
600
563
|
public async rescanBase(): Promise<void> {
|
|
601
564
|
await this.init();
|
|
602
|
-
await this.
|
|
565
|
+
await this.sync.rescanBase();
|
|
603
566
|
}
|
|
604
567
|
|
|
605
|
-
/** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes)
|
|
568
|
+
/** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes). */
|
|
606
569
|
public async boundaryScanRemote(source: IArchives, config: { since: number; route?: [number, number] }): Promise<void> {
|
|
607
570
|
await this.init();
|
|
608
|
-
|
|
609
|
-
console.log(`Boundary scan of ${source.getDebugName()} starting: changes since ${new Date(config.since).toISOString()}, route ${JSON.stringify(config.route || FULL_ROUTE)} (store ${this.folder})`);
|
|
610
|
-
let changes = await source.getChangesAfter2({ time: config.since, routes: config.route && [config.route] || undefined });
|
|
611
|
-
let tally = newScanTally();
|
|
612
|
-
for (let file of changes) {
|
|
613
|
-
if (file.path === ROUTING_FILE) {
|
|
614
|
-
tally.filtered++;
|
|
615
|
-
continue;
|
|
616
|
-
}
|
|
617
|
-
let overlayEntry = this.overlay.get(file.path);
|
|
618
|
-
let entry = this.mem.get(file.path);
|
|
619
|
-
let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
|
|
620
|
-
if (file.createTime <= currentTime) {
|
|
621
|
-
tally.unchanged++;
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
if (file.size === 0) {
|
|
625
|
-
// A tombstone stores nothing on our own source - the index entry alone records it
|
|
626
|
-
this.setIndexEntry(file.path, { writeTime: file.createTime, size: 0, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
|
|
627
|
-
tally.tombstone++;
|
|
628
|
-
continue;
|
|
629
|
-
}
|
|
630
|
-
let copied = await copyArchiveFile({ from: source, to: this.sources[0].source, path: file.path, size: file.size, writeTime: file.createTime, forceSetImmutable: true, noChecks: true, internal: true });
|
|
631
|
-
if (!copied) continue;
|
|
632
|
-
if (copied.size === 0) {
|
|
633
|
-
this.setIndexEntry(file.path, { writeTime: copied.writeTime, size: 0, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
|
|
634
|
-
tally.tombstone++;
|
|
635
|
-
continue;
|
|
636
|
-
}
|
|
637
|
-
this.setIndexEntry(file.path, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
|
|
638
|
-
if (entry || overlayEntry) {
|
|
639
|
-
tally.updated++;
|
|
640
|
-
} else {
|
|
641
|
-
tally.new++;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
console.log(`Boundary scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
|
|
571
|
+
await this.sync.boundaryScanRemote(source, config);
|
|
645
572
|
}
|
|
646
573
|
|
|
647
|
-
// Large uploads stream onto the local disk source directly (they may not fit in memory)
|
|
648
|
-
public async startLargeUpload(config?: { path?: string; lastModified?: number }): Promise<string> {
|
|
574
|
+
// Large uploads stream onto the local disk source directly (they may not fit in memory), and reconciliation carries them to the other sources - there is no delayed-write stage they could pass through. The write itself is validated exactly like set's, right here: the client is about to send the whole file, so every reason to refuse it must be found before the first byte.
|
|
575
|
+
public async startLargeUpload(config?: { path?: string; lastModified?: number; forceSetImmutable?: boolean; noChecks?: boolean; internal?: boolean }): Promise<string> {
|
|
649
576
|
await this.init();
|
|
650
|
-
|
|
651
|
-
|
|
577
|
+
let key = config?.path;
|
|
578
|
+
if (key) {
|
|
579
|
+
let writeTime = config?.lastModified || Date.now();
|
|
580
|
+
let route = getRoute(key);
|
|
581
|
+
if (key !== ROUTING_FILE) {
|
|
582
|
+
this.assertWriteTarget(key, route, config?.lastModified);
|
|
583
|
+
}
|
|
584
|
+
if (key !== ROUTING_FILE && this.storeConfig.all().length) {
|
|
585
|
+
if (config?.forceSetImmutable) {
|
|
586
|
+
if (!config.lastModified) {
|
|
587
|
+
throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), uploading ${JSON.stringify(key)} (store ${this.folder})`);
|
|
588
|
+
}
|
|
589
|
+
// Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable). The client is already streaming, so the upload is accepted and every part discarded - the alternative is throwing at a caller that did nothing wrong.
|
|
590
|
+
let self = selectEntryAt(this.storeConfig.all(), writeTime, route);
|
|
591
|
+
if (self?.immutable && await this.getInfo({ path: key })) {
|
|
592
|
+
let id = `${DISCARDED_UPLOAD_PREFIX}${this.nextDiscardedUpload++}`;
|
|
593
|
+
this.discardedUploads.add(id);
|
|
594
|
+
return id;
|
|
595
|
+
}
|
|
596
|
+
} else {
|
|
597
|
+
await this.assertMutable(key, writeTime);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (config?.internal) {
|
|
601
|
+
if (!config.lastModified) {
|
|
602
|
+
throw new Error(`Internal writes must carry lastModified (they are synchronization pushes, ordered by their write time), uploading ${JSON.stringify(key)} (store ${this.folder})`);
|
|
603
|
+
}
|
|
604
|
+
this.assertInternalWriteAccepted(key, config.lastModified, route);
|
|
605
|
+
}
|
|
652
606
|
}
|
|
653
607
|
return await this.getDiskSource().disk.startLargeUpload();
|
|
654
608
|
}
|
|
655
609
|
public async appendLargeUpload(config: { id: string; data: Buffer }): Promise<void> {
|
|
610
|
+
if (this.discardedUploads.has(config.id)) return;
|
|
656
611
|
await this.getDiskSource().disk.appendLargeUpload(config.id, config.data);
|
|
657
612
|
}
|
|
658
|
-
public async finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void> {
|
|
613
|
+
public async finishLargeUpload(config: { id: string; path: string; lastModified?: number; forceSetImmutable?: boolean; noChecks?: boolean; internal?: boolean }): Promise<void> {
|
|
614
|
+
if (this.discardedUploads.delete(config.id)) return;
|
|
659
615
|
let { disk, sourceIndex } = this.getDiskSource();
|
|
616
|
+
if (config.lastModified) {
|
|
617
|
+
assertValidLastModified(config.lastModified);
|
|
618
|
+
// An older write never overwrites a newer one (see IArchives.set) - a newer value can land while a long upload is still streaming, so this is re-checked here rather than only at the start
|
|
619
|
+
if (config.lastModified < await this.currentWriteTime(config.path)) {
|
|
620
|
+
await disk.cancelLargeUpload(config.id);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
660
624
|
await disk.finishLargeUpload(config.id, config.path, config.lastModified);
|
|
661
|
-
|
|
662
|
-
// includeTombstones: a zero-byte upload is still a real file whose index entry must be written
|
|
663
|
-
let info = await disk.getInfo(config.path, { includeTombstones: true });
|
|
625
|
+
let info = await disk.getInfo(config.path);
|
|
664
626
|
if (info) {
|
|
665
627
|
this.setIndexEntry(config.path, { writeTime: info.writeTime, size: info.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
|
|
666
628
|
}
|
|
667
629
|
}
|
|
668
630
|
public async cancelLargeUpload(config: { id: string }): Promise<void> {
|
|
631
|
+
if (this.discardedUploads.delete(config.id)) return;
|
|
669
632
|
await this.getDiskSource().disk.cancelLargeUpload(config.id);
|
|
670
633
|
}
|
|
671
634
|
|
|
@@ -673,42 +636,21 @@ export class BlobStore implements IBucketStore {
|
|
|
673
636
|
|
|
674
637
|
// #region Internals
|
|
675
638
|
|
|
676
|
-
|
|
639
|
+
/** Bytes of read cache the disk may hold; see CommonConfig.readerDiskLimit (StoreSync enforces it). Read from the config in effect, so raising or removing the limit takes effect on the next eviction pass. */
|
|
640
|
+
public get readerDiskLimit(): number | undefined {
|
|
641
|
+
return this.storeConfig.current().readerDiskLimit;
|
|
642
|
+
}
|
|
677
643
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
if (part) base = await base.getDirectoryHandle(part, { create: true });
|
|
683
|
-
}
|
|
684
|
-
return wrapHandle(base);
|
|
685
|
-
});
|
|
686
|
-
// The in-memory copy of the index. All reads are served from it; changes are buffered in dirty and flushed to the BulkDatabase2 in batches (undefined = delete).
|
|
687
|
-
private mem = new Map<string, IndexEntry>();
|
|
688
|
-
// Live totals over mem (tombstones excluded), adjusted on every mutation and recomputed on load - so any drift heals on restart. computeIndexTotals gives the walk-the-index truth.
|
|
689
|
-
private indexFileCount = 0;
|
|
690
|
-
private indexByteCount = 0;
|
|
691
|
-
// The same totals per holding source (index 0 = our disk, which readerDiskLimit bounds)
|
|
692
|
-
private sourceFileCounts = this.sources.map(() => 0);
|
|
693
|
-
private sourceByteCounts = this.sources.map(() => 0);
|
|
694
|
-
// Background scans / full syncs currently in progress (a Set - one source can have a change poll's full sync and a rescan overlapping)
|
|
695
|
-
private syncActivities = new Set<SyncActivity>();
|
|
696
|
-
private dirty = new Map<string, IndexEntry | undefined>();
|
|
697
|
-
private overlay = new Map<string, OverlayEntry>();
|
|
698
|
-
private sourceStates = this.sources.map(() => newSourceState());
|
|
699
|
-
private syncStarted = false;
|
|
700
|
-
private entries = this.config?.entries || [];
|
|
701
|
-
// The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
|
|
702
|
-
private sourcesList = new SourcesList(path.join(this.folder, "index", "sourcesList.txt"));
|
|
703
|
-
// Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
|
|
704
|
-
private slotSourcesListIndexes: number[] = [];
|
|
705
|
-
private slotRegistrations: Promise<void>[] = [];
|
|
644
|
+
/** The write time a new write has to beat, or 0 when we have never heard of the key. Counts DELETIONS too: a write older than the deletion that removed it must not bring it back. The index is authoritative even for a write still buffered in a delayed source, since the entry is recorded when the write is accepted rather than when it reaches storage. */
|
|
645
|
+
public currentWriteTime(key: string): number {
|
|
646
|
+
return this.index.timeOf(key);
|
|
647
|
+
}
|
|
706
648
|
|
|
707
649
|
private isLive(sourceIndex: number): boolean {
|
|
708
|
-
return
|
|
650
|
+
return this.sync.isLive(sourceIndex);
|
|
709
651
|
}
|
|
710
652
|
|
|
711
|
-
|
|
653
|
+
public registerSlot(slot: number): Promise<void> {
|
|
712
654
|
let existing = this.slotRegistrations[slot];
|
|
713
655
|
if (existing) return existing;
|
|
714
656
|
let registration = this.sourcesList.ensure(this.sources[slot].url).then(index => {
|
|
@@ -718,8 +660,13 @@ export class BlobStore implements IBucketStore {
|
|
|
718
660
|
return registration;
|
|
719
661
|
}
|
|
720
662
|
|
|
721
|
-
|
|
722
|
-
|
|
663
|
+
/** The persistent sourcesListIndex of a slot, or undefined when the slot never got that far (a source removed before its registration resolved). */
|
|
664
|
+
public slotSourcesListIndex(slot: number): number | undefined {
|
|
665
|
+
return this.slotSourcesListIndexes[slot];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// The persistent sourcesListIndex of a slot - only valid once the slot's registration resolved (init and the source's sync loop guarantee that before any indexing happens)
|
|
669
|
+
public sourcesListIndexOfSlot(slot: number): number {
|
|
723
670
|
let index = this.slotSourcesListIndexes[slot];
|
|
724
671
|
if (index === undefined) {
|
|
725
672
|
throw new Error(`Source slot ${slot} (${this.sources[slot]?.url}) has no registered sourcesListIndex yet (store ${this.folder})`);
|
|
@@ -728,7 +675,7 @@ export class BlobStore implements IBucketStore {
|
|
|
728
675
|
}
|
|
729
676
|
|
|
730
677
|
// The live slot currently serving a persistent sourcesListIndex, or undefined when no configured source has that URL anymore. Linear, but the sources list is tiny and this is always current (slots dying, or several slots sharing one URL across valid windows, need no bookkeeping).
|
|
731
|
-
|
|
678
|
+
public slotForSourcesListIndex(sourcesListIndex: number): number | undefined {
|
|
732
679
|
for (let i = 0; i < this.slotSourcesListIndexes.length; i++) {
|
|
733
680
|
if (this.slotSourcesListIndexes[i] === sourcesListIndex && this.isLive(i)) return i;
|
|
734
681
|
}
|
|
@@ -736,7 +683,7 @@ export class BlobStore implements IBucketStore {
|
|
|
736
683
|
}
|
|
737
684
|
|
|
738
685
|
// The IArchives currently holding an entry's bytes: the live slot when the holder is still configured, otherwise resolved (cached) straight from its persisted URL - windows/routes decide when a source is scanned or written, but for reading bytes we know it holds, the URL alone is enough
|
|
739
|
-
|
|
686
|
+
public async getEntryHolder(entry: IndexEntry): Promise<IArchives | undefined> {
|
|
740
687
|
let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
741
688
|
if (slot !== undefined) return this.sources[slot].source;
|
|
742
689
|
let url = this.sourcesList.getUrl(entry.sourcesListIndex) || await this.sourcesList.getUrlReloading(entry.sourcesListIndex);
|
|
@@ -745,465 +692,170 @@ export class BlobStore implements IBucketStore {
|
|
|
745
692
|
}
|
|
746
693
|
|
|
747
694
|
private async loadIndex(): Promise<void> {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
let sourcesListIndexMap = new Map(sourcesListIndexes.map(x => [x.key, x.value]));
|
|
755
|
-
for (let entry of writeTimes) {
|
|
756
|
-
let size = sizeMap.get(entry.key);
|
|
757
|
-
let sourcesListIndex = sourcesListIndexMap.get(entry.key);
|
|
758
|
-
// Explicit checks, as 0 is a valid size and a valid sourcesListIndex
|
|
759
|
-
if (size === undefined || sourcesListIndex === undefined) continue;
|
|
760
|
-
// The routing config is only ever read off our own disk (see updateScanIndex), and a loaded bucket always has it there - a persisted entry pointing elsewhere is stale
|
|
761
|
-
if (entry.key === ROUTING_FILE) {
|
|
762
|
-
sourcesListIndex = this.sourcesListIndexOfSlot(0);
|
|
763
|
-
}
|
|
764
|
-
let full: IndexEntry = { writeTime: entry.value, size, sourcesListIndex, changedAt: entry.time, lastAccess: entry.time };
|
|
765
|
-
this.mem.set(entry.key, full);
|
|
766
|
-
this.countEntry(full, 1);
|
|
695
|
+
await this.index.load();
|
|
696
|
+
// The routing config is only ever read off our own disk (see StoreSync's scan handling), and a loaded bucket always has it there - a persisted entry pointing elsewhere is stale, so it is corrected once, here
|
|
697
|
+
let routing = this.getIndexEntry(ROUTING_FILE);
|
|
698
|
+
let baseSourcesListIndex = this.sourcesListIndexOfSlot(0);
|
|
699
|
+
if (routing && routing.sourcesListIndex !== baseSourcesListIndex) {
|
|
700
|
+
this.setIndexEntry(ROUTING_FILE, { writeTime: routing.writeTime, size: routing.size, sourcesListIndex: baseSourcesListIndex });
|
|
767
701
|
}
|
|
768
702
|
}
|
|
769
703
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
this.
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
776
|
-
if (slot !== undefined) {
|
|
777
|
-
this.sourceFileCounts[slot] += direction;
|
|
778
|
-
this.sourceByteCounts[slot] += entry.size * direction;
|
|
779
|
-
}
|
|
704
|
+
/** A file we hold. A deleted one is not one: it is a tombstone, and only getDeletedEntry knows about it. */
|
|
705
|
+
public getIndexEntry(key: string): IndexEntry | undefined {
|
|
706
|
+
let entry = this.index.get(key);
|
|
707
|
+
if (!entry) return undefined;
|
|
708
|
+
return { ...entry.value, writeTime: entry.time, changedAt: entry.changedAt };
|
|
780
709
|
}
|
|
781
710
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
this.
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
this.dirty.set(key, full);
|
|
788
|
-
this.config?.onIndexChanged?.(key);
|
|
789
|
-
}
|
|
790
|
-
private deleteIndexEntry(key: string): void {
|
|
791
|
-
let existing = this.mem.get(key);
|
|
792
|
-
if (!existing) return;
|
|
793
|
-
this.countEntry(existing, -1);
|
|
794
|
-
this.mem.delete(key);
|
|
795
|
-
this.dirty.set(key, undefined);
|
|
711
|
+
/** When a key was deleted, if it was. A deletion is an absence with a time attached - that time is what makes it propagate and what expires it. */
|
|
712
|
+
public getDeletedEntry(key: string): { writeTime: number; changedAt: number } | undefined {
|
|
713
|
+
let tombstone = this.index.getDeleted(key);
|
|
714
|
+
if (!tombstone) return undefined;
|
|
715
|
+
return { writeTime: tombstone.time, changedAt: tombstone.changedAt };
|
|
796
716
|
}
|
|
797
717
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
let
|
|
801
|
-
|
|
802
|
-
state.dead = true;
|
|
803
|
-
state.stopped.stop = true;
|
|
804
|
-
state.scanComplete = true;
|
|
805
|
-
state.initialScan.resolve(undefined);
|
|
806
|
-
let sourcesListIndex = this.slotSourcesListIndexes[sourceIndex];
|
|
807
|
-
// The same URL can be another live slot (one entry per valid window) - the endpoint is still configured, so its entries stay
|
|
808
|
-
if (sourcesListIndex !== undefined && this.slotForSourcesListIndex(sourcesListIndex) === undefined) {
|
|
809
|
-
let dropped = 0;
|
|
810
|
-
for (let [key, entry] of this.mem) {
|
|
811
|
-
if (entry.sourcesListIndex !== sourcesListIndex) continue;
|
|
812
|
-
this.deleteIndexEntry(key);
|
|
813
|
-
dropped++;
|
|
814
|
-
}
|
|
815
|
-
console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
|
|
816
|
-
return;
|
|
718
|
+
/** Every file we hold, for the passes that walk them all (listings, scans, reconciliation, eviction). Deletions are not in here - see deletedEntries. Live: deleting entries while iterating is expected here, and safe. */
|
|
719
|
+
public *indexEntries(): IterableIterator<[string, IndexEntry]> {
|
|
720
|
+
for (let [key, entry] of this.index.entries()) {
|
|
721
|
+
yield [key, { ...entry.value, writeTime: entry.time, changedAt: entry.changedAt }];
|
|
817
722
|
}
|
|
818
|
-
console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped (its URL is still served by another slot, so its index entries stay)`);
|
|
819
723
|
}
|
|
820
724
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
let
|
|
824
|
-
|
|
825
|
-
let writes: BlobIndexEntry[] = [];
|
|
826
|
-
let deletes: string[] = [];
|
|
827
|
-
for (let [key, entry] of dirty) {
|
|
828
|
-
if (entry) {
|
|
829
|
-
writes.push({ key, writeTime: entry.writeTime, size: entry.size, sourcesListIndex: entry.sourcesListIndex });
|
|
830
|
-
} else {
|
|
831
|
-
deletes.push(key);
|
|
832
|
-
}
|
|
725
|
+
/** Every deletion we know of. A much smaller walk than the files, which is what makes expiring them cheap. */
|
|
726
|
+
public *deletedEntries(): IterableIterator<[string, { writeTime: number; changedAt: number }]> {
|
|
727
|
+
for (let [key, tombstone] of this.index.deletedEntries()) {
|
|
728
|
+
yield [key, { writeTime: tombstone.time, changedAt: tombstone.changedAt }];
|
|
833
729
|
}
|
|
834
|
-
if (writes.length) await this.index.writeBatch(writes);
|
|
835
|
-
if (deletes.length) await this.index.deleteBatch(deletes);
|
|
836
730
|
}
|
|
837
731
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
if (!this.entries.length) return;
|
|
842
|
-
let self = selectEntryAt(this.entries, writeTime, getRoute(key));
|
|
843
|
-
if (!self?.immutable) return;
|
|
844
|
-
if (await this.getInfo({ path: key })) {
|
|
845
|
-
throw new Error(`This store is immutable (at write time ${writeTime}) and ${JSON.stringify(key)} already exists, so it cannot be written to (store ${this.folder})`);
|
|
846
|
-
}
|
|
732
|
+
/** How many files we hold, deletions excluded. */
|
|
733
|
+
public indexSize(): number {
|
|
734
|
+
return this.index.size;
|
|
847
735
|
}
|
|
848
736
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
let
|
|
853
|
-
|
|
854
|
-
|
|
737
|
+
/** Totals over the files we hold, broken down by the slot holding each (entries can name a source that is no longer configured, which counts towards the total but no slot). */
|
|
738
|
+
public indexTotals(): { fileCount: number; byteCount: number; slots: { fileCount: number; byteCount: number }[] } {
|
|
739
|
+
let fileCount = 0;
|
|
740
|
+
let byteCount = 0;
|
|
741
|
+
let slots = this.sources.map(() => ({ fileCount: 0, byteCount: 0 }));
|
|
742
|
+
for (let [, entry] of this.indexEntries()) {
|
|
743
|
+
fileCount++;
|
|
744
|
+
byteCount += entry.size;
|
|
745
|
+
let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
746
|
+
if (slot === undefined) continue;
|
|
747
|
+
slots[slot].fileCount++;
|
|
748
|
+
slots[slot].byteCount += entry.size;
|
|
855
749
|
}
|
|
750
|
+
return { fileCount, byteCount, slots };
|
|
856
751
|
}
|
|
857
752
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
// Read live for every pass, not captured - updateSources can change it while loops run
|
|
865
|
-
let noFullSync = () => this.sources[sourceIndex].noFullSync;
|
|
866
|
-
let intermediate = this.sources[sourceIndex].intermediate;
|
|
867
|
-
let listing: Map<string, number> | undefined;
|
|
868
|
-
while (!this.stopped.stop && !state.stopped.stop) {
|
|
869
|
-
if (this.isDeadIntermediate(sourceIndex)) {
|
|
870
|
-
console.log(`Not scanning sync source ${source.getDebugName()} (store ${this.folder}): it is an intermediate whose window already ended`);
|
|
871
|
-
break;
|
|
872
|
-
}
|
|
873
|
-
try {
|
|
874
|
-
let config = await source.getConfig();
|
|
875
|
-
state.supportsChangesAfter = !!config.supportsChangesAfter;
|
|
876
|
-
listing = await this.scanSource(sourceIndex);
|
|
877
|
-
break;
|
|
878
|
-
} catch (e) {
|
|
879
|
-
if (intermediate) {
|
|
880
|
-
console.error(`Initial scan of intermediate sync source ${source.getDebugName()} (store ${this.folder}) failed; not retrying (intermediates are temporary switchover ports): ${(e as Error).stack ?? e}`);
|
|
881
|
-
break;
|
|
882
|
-
}
|
|
883
|
-
console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
|
|
884
|
-
await delay(SCAN_RETRY_DELAY);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
state.scanComplete = true;
|
|
888
|
-
state.initialScan.resolve(undefined);
|
|
889
|
-
if (this.stopped.stop || state.stopped.stop) return;
|
|
890
|
-
// An intermediate that never produced a listing is dead or dying - polling and copying from it would only log errors
|
|
891
|
-
if (intermediate && !listing) return;
|
|
892
|
-
if (listing) {
|
|
893
|
-
await this.reconcileSource(sourceIndex, listing);
|
|
894
|
-
}
|
|
895
|
-
if (!noFullSync()) {
|
|
896
|
-
try {
|
|
897
|
-
await this.copySourceFiles(sourceIndex);
|
|
898
|
-
} catch (e) {
|
|
899
|
-
console.error(`Copying files from sync source ${source.getDebugName()} failed:`, e);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
if (state.supportsChangesAfter) {
|
|
903
|
-
runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
|
|
904
|
-
if (this.isDeadIntermediate(sourceIndex)) return;
|
|
905
|
-
await this.pollChanges(sourceIndex);
|
|
906
|
-
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
907
|
-
}, state.stopped);
|
|
908
|
-
// Change polls only show what the source HAS, never what it's missing, so pushes run on the full-rescan cadence (findInfo on an index-backed source is cheap)
|
|
909
|
-
runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
|
|
910
|
-
if (this.isDeadIntermediate(sourceIndex)) return;
|
|
911
|
-
let files = await source.findInfo("");
|
|
912
|
-
await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
|
|
913
|
-
}, state.stopped);
|
|
914
|
-
} else {
|
|
915
|
-
runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
|
|
916
|
-
if (this.isDeadIntermediate(sourceIndex)) return;
|
|
917
|
-
let rescan = await this.scanSource(sourceIndex);
|
|
918
|
-
await this.reconcileSource(sourceIndex, rescan);
|
|
919
|
-
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
920
|
-
}, state.stopped);
|
|
753
|
+
/** Records a file, as of its write time. Returns false, having changed nothing, when we already know something at least as new - the index cannot be made to go backwards, whichever path the write came in by. */
|
|
754
|
+
public setIndexEntry(key: string, entry: { writeTime: number; size: number; sourcesListIndex: number }): boolean {
|
|
755
|
+
if (!this.index.set(key, { size: entry.size, sourcesListIndex: entry.sourcesListIndex }, entry.writeTime)) return false;
|
|
756
|
+
// The routing config landing here - written by an operator, or pulled off a peer by synchronization - is how this store learns what it is meant to be
|
|
757
|
+
if (key === ROUTING_FILE && this.syncStarted) {
|
|
758
|
+
this.reapplyRoutingConfig();
|
|
921
759
|
}
|
|
760
|
+
this.config?.onIndexChanged?.(key);
|
|
761
|
+
return true;
|
|
922
762
|
}
|
|
923
763
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
764
|
+
/** Records a DELETION, as of its time: the key stops existing here, and the tombstone is what makes that fact propagate and reconcile like any other write. Same ordering rule as setIndexEntry. */
|
|
765
|
+
public setIndexDeleted(key: string, writeTime: number): boolean {
|
|
766
|
+
if (!this.index.delete(key, writeTime)) return false;
|
|
767
|
+
this.config?.onIndexChanged?.(key);
|
|
768
|
+
return true;
|
|
928
769
|
}
|
|
929
770
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
let state = this.sourceStates[sourceIndex];
|
|
934
|
-
let scanStart = Date.now();
|
|
935
|
-
let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
|
|
936
|
-
this.syncActivities.add(activity);
|
|
937
|
-
console.log(`Metadata scan of ${source.getDebugName()} starting (store ${this.folder})`);
|
|
938
|
-
let progressTimer = setInterval(() => {
|
|
939
|
-
console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.folder})`);
|
|
940
|
-
}, SYNC_PROGRESS_LOG_INTERVAL);
|
|
941
|
-
(progressTimer as { unref?: () => void }).unref?.();
|
|
942
|
-
// The listing request deliberately takes no time or route filters: our slowest sources (backblaze) support neither, so filtering would happen after the full fetch anyway - little benefit, more room for desynchronization. And if a full listing ever becomes too big to send over the network, it is also too big for the receiving process to hold in memory - the fix is more routing shards (each storing and sending less), not filtering.
|
|
943
|
-
let files: ArchiveFileInfo[];
|
|
944
|
-
try {
|
|
945
|
-
files = await source.findInfo("");
|
|
946
|
-
} finally {
|
|
947
|
-
clearInterval(progressTimer);
|
|
948
|
-
this.syncActivities.delete(activity);
|
|
949
|
-
}
|
|
950
|
-
// The source may have been removed while the listing was in flight; its results are dead
|
|
951
|
-
if (state.stopped.stop) return new Map();
|
|
952
|
-
let indexSizeBefore = this.mem.size;
|
|
953
|
-
let seen = new Map<string, number>();
|
|
954
|
-
let tally = newScanTally();
|
|
955
|
-
let newPaths = 0;
|
|
956
|
-
for (let file of files) {
|
|
957
|
-
seen.set(file.path, file.createTime);
|
|
958
|
-
if (!this.mem.has(file.path)) {
|
|
959
|
-
newPaths++;
|
|
960
|
-
}
|
|
961
|
-
tally[this.updateScanIndex(sourceIndex, file)]++;
|
|
962
|
-
}
|
|
963
|
-
state.scannedCount = files.length;
|
|
964
|
-
// Index entries this source was the holder of, but that vanished from it (e.g. deleted while we were offline), come out of the index. Entries changed after the scan started are kept — the scan listing may simply predate them. Tombstones have no physical file for a listing to vouch for, so they're exempt (cleanupTombstones expires them instead).
|
|
965
|
-
let removedFromIndex = 0;
|
|
966
|
-
let missingOnSource = 0;
|
|
967
|
-
let scannedSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
|
|
968
|
-
for (let [key, entry] of this.mem) {
|
|
969
|
-
if (seen.has(key)) continue;
|
|
970
|
-
if (entry.sourcesListIndex === scannedSourcesListIndex && entry.size !== 0 && entry.changedAt < scanStart) {
|
|
971
|
-
this.deleteIndexEntry(key);
|
|
972
|
-
removedFromIndex++;
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
975
|
-
// Counted only when the source SHOULD hold the entry (its route matches) - these are what the reconcile pass pushes to it (which also ignores the valid window: synchronization moves existing values, the window only routes fresh writes)
|
|
976
|
-
if (entry.size === 0 || key === ROUTING_FILE) continue;
|
|
977
|
-
if (!routeContains(route, getRoute(key))) continue;
|
|
978
|
-
missingOnSource++;
|
|
979
|
-
}
|
|
980
|
-
// Percentages are of the union of both sides (our index + their listing), so every count has a stable denominator
|
|
981
|
-
let union = indexSizeBefore + newPaths;
|
|
982
|
-
let pct = (n: number) => `${Math.round(n / Math.max(union, 1) * 1000) / 10}%`;
|
|
983
|
-
console.log(`Metadata scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder}): ${files.length} listed vs ${indexSizeBefore} indexed (union ${union}): ${formatScanTally(tally, union)}, ${missingOnSource} in index but missing on source (${pct(missingOnSource)}), ${removedFromIndex} removed from index (${pct(removedFromIndex)})`);
|
|
984
|
-
state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
|
|
985
|
-
return seen;
|
|
771
|
+
/** Forgets a key entirely, tombstone included. NOT a deletion: it says nothing happened to the file, only that we no longer know anything about it - for an entry whose holder turned out not to have it, and for a tombstone old enough that everyone has heard. */
|
|
772
|
+
public purgeIndexEntry(key: string): void {
|
|
773
|
+
this.index.purge(key);
|
|
986
774
|
}
|
|
987
775
|
|
|
988
|
-
//
|
|
989
|
-
private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
|
|
990
|
-
let { source, validWindows, route } = this.sources[sourceIndex];
|
|
991
|
-
let state = this.sourceStates[sourceIndex];
|
|
992
|
-
let acceptsWrites = windowsAcceptWrites(validWindows);
|
|
993
|
-
let targetSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
|
|
994
|
-
let pushed = 0;
|
|
995
|
-
let failed = 0;
|
|
996
|
-
let consecutiveFailures = 0;
|
|
997
|
-
let errors: string[] = [];
|
|
998
|
-
let aborted = false;
|
|
999
|
-
for (let [key, entry] of this.mem) {
|
|
1000
|
-
if (this.stopped.stop || state.stopped.stop) return;
|
|
1001
|
-
if (entry.sourcesListIndex === targetSourcesListIndex) continue;
|
|
1002
|
-
// The routing file is NEVER synchronized between storage nodes - it is only ever written directly to each node, and only ever read off our own disk
|
|
1003
|
-
if (key === ROUTING_FILE) continue;
|
|
1004
|
-
if (!acceptsWrites) continue;
|
|
1005
|
-
if (!routeContains(route, getRoute(key))) continue;
|
|
1006
|
-
let theirTime = listing.get(key);
|
|
1007
|
-
if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
|
|
1008
|
-
try {
|
|
1009
|
-
if (entry.size === 0) {
|
|
1010
|
-
// A deletion only needs pushing while the source still holds an older copy. It travels as del (never as an empty set - set rejects empty buffers), with the ORIGINAL deletion time so ordering survives.
|
|
1011
|
-
if (theirTime === undefined) continue;
|
|
1012
|
-
await source.del(key, { lastModified: entry.writeTime, noChecks: true, internal: true });
|
|
1013
|
-
pushed++;
|
|
1014
|
-
consecutiveFailures = 0;
|
|
1015
|
-
continue;
|
|
1016
|
-
}
|
|
1017
|
-
let holder = await this.getEntryHolder(entry);
|
|
1018
|
-
if (!holder) continue;
|
|
1019
|
-
let copied = await copyArchiveFile({ from: holder, to: source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
|
|
1020
|
-
if (!copied) continue;
|
|
1021
|
-
pushed++;
|
|
1022
|
-
consecutiveFailures = 0;
|
|
1023
|
-
} catch (e) {
|
|
1024
|
-
failed++;
|
|
1025
|
-
consecutiveFailures++;
|
|
1026
|
-
if (errors.length < RECONCILE_ERROR_LOG_LIMIT) {
|
|
1027
|
-
errors.push(`${key}: ${(e as Error).stack ?? e}`);
|
|
1028
|
-
}
|
|
1029
|
-
if (consecutiveFailures >= RECONCILE_MAX_CONSECUTIVE_FAILURES) {
|
|
1030
|
-
aborted = true;
|
|
1031
|
-
break;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
if (failed) {
|
|
1036
|
-
console.error(`Reconciling sync source ${source.getDebugName()} (store ${this.folder}): pushed ${pushed} files, ${failed} failed${aborted && ` before aborting the pass (${consecutiveFailures} consecutive failures - the source looks down; the next scan cycle retries)` || ""}. First errors: ${errors.join(" | ")}`);
|
|
1037
|
-
} else if (pushed) {
|
|
1038
|
-
console.log(`Reconciled sync source ${source.getDebugName()} (store ${this.folder}): pushed ${pushed} files it was missing or held older copies of`);
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
776
|
+
// ── validation (from this store's own routing entries) ──
|
|
1041
777
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
778
|
+
/**
|
|
779
|
+
* Every write, however it is stamped, has to be one we are actually meant to hold - because the
|
|
780
|
+
* alternative is not a smaller problem, it is a silent one. A write that lands on a store that
|
|
781
|
+
* does not serve its route (or on a server that is not in the bucket's config at all) goes into a
|
|
782
|
+
* folder nothing scans and no peer reconciles: it succeeds, and then it is gone. The markers make
|
|
783
|
+
* the client re-read the routing config and retry, which is exactly the right outcome when the
|
|
784
|
+
* reason it aimed here is that its config was stale.
|
|
785
|
+
*/
|
|
786
|
+
private assertWriteTarget(key: string, route: number, lastModified: number | undefined): void {
|
|
787
|
+
if (!this.storeConfig.all().length) {
|
|
788
|
+
logWrongTargetRejection(`Rejecting write of ${JSON.stringify(key)} (store ${this.folder}): this server has no entry in the bucket's routing config, so nothing would ever scan or reconcile what it stored`);
|
|
789
|
+
throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This server is not in the routing config for this bucket, so it cannot accept writes (store ${this.folder}). Re-resolve the currently valid source and retry.`);
|
|
790
|
+
}
|
|
791
|
+
if (!lastModified) {
|
|
792
|
+
this.assertFreshWriteTarget(key, Date.now(), route);
|
|
793
|
+
return;
|
|
1052
794
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
if (file.size === 0) return "tombstone";
|
|
1058
|
-
if (existing) return "updated";
|
|
1059
|
-
return "new";
|
|
795
|
+
// A stamped write picked its own time, so no window can judge it (that is the whole point of a synchronized write) - but the ROUTE is not a matter of timing, and a stamped write to the wrong shard is exactly as invisible as a fresh one
|
|
796
|
+
if (this.storeConfig.all().some(x => routeContains(x.route, route))) return;
|
|
797
|
+
logWrongTargetRejection(`Rejecting stamped write of ${JSON.stringify(key)} (store ${this.folder}): route ${route} is outside every route we serve ${JSON.stringify(this.storeConfig.all().map(x => x.route || FULL_ROUTE))}`);
|
|
798
|
+
throw new Error(`${STORAGE_WRONG_ROUTE} This store does not handle route ${route} (key ${JSON.stringify(key)}, our routes: ${JSON.stringify(this.storeConfig.all().map(x => x.route || FULL_ROUTE))}, store ${this.folder}). Re-resolve the source for this key and retry.`);
|
|
1060
799
|
}
|
|
1061
800
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
801
|
+
/**
|
|
802
|
+
* Whether a routing config may be written here. Two rules, and this is the one place either is
|
|
803
|
+
* applied - a config only ever enters the system through a write, so a config that got in is a
|
|
804
|
+
* config that passed, and reading one back never judges it again.
|
|
805
|
+
*
|
|
806
|
+
* The config has to be valid as a whole (see assertValidRemoteConfig), and it has to outrank what
|
|
807
|
+
* we are running: the same version means the same config, so re-writing it is harmless, but a
|
|
808
|
+
* lower one is an older config arriving late and must never undo a newer one.
|
|
809
|
+
*/
|
|
810
|
+
private assertRoutingConfigWritable(data: Buffer): void {
|
|
811
|
+
let routing = parseRoutingData(data);
|
|
812
|
+
assertValidRemoteConfig(routing);
|
|
813
|
+
let incoming = getConfigVersion(routing);
|
|
814
|
+
let current = this.routingVersion();
|
|
815
|
+
if (incoming >= current) return;
|
|
816
|
+
throw new Error(`Refusing to write ${ROUTING_FILE} to store ${this.folder}: its version (${incoming}) is older than the one this store is running (${current}). Increment the version to update it.`);
|
|
1077
817
|
}
|
|
1078
818
|
|
|
1079
|
-
//
|
|
1080
|
-
private
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
let totalBytes = 0;
|
|
1086
|
-
let copiedSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
|
|
1087
|
-
for (let [key, entry] of this.mem) {
|
|
1088
|
-
if (entry.sourcesListIndex !== copiedSourcesListIndex) continue;
|
|
1089
|
-
if (entry.size === 0) continue;
|
|
1090
|
-
pending.push({ key, entry });
|
|
1091
|
-
totalBytes += entry.size;
|
|
819
|
+
// A fresh (unstamped) write must land on the node the config currently points at: the markers tell the client its config is stale, so it re-resolves and retries against the right node instead of us silently accepting data we were never meant to hold.
|
|
820
|
+
private assertFreshWriteTarget(key: string, writeTime: number, route: number): void {
|
|
821
|
+
let timeValid = this.storeConfig.all().filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
|
|
822
|
+
if (!timeValid.length) {
|
|
823
|
+
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): writeTime ${writeTime} (${new Date(writeTime).toISOString()}) is outside all our valid windows ${JSON.stringify(this.storeConfig.all().map(x => x.validWindow))} (a switchover moved the write target)`);
|
|
824
|
+
throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This store is not a valid write target at ${writeTime} (our valid windows: ${JSON.stringify(this.storeConfig.all().map(x => x.validWindow))}, store ${this.folder}). Re-resolve the currently valid source and retry.`);
|
|
1092
825
|
}
|
|
1093
|
-
if (!
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
sourceDebugName: source.getDebugName(),
|
|
1097
|
-
startTime: Date.now(),
|
|
1098
|
-
doneFiles: 0,
|
|
1099
|
-
totalFiles: pending.length,
|
|
1100
|
-
doneBytes: 0,
|
|
1101
|
-
totalBytes,
|
|
1102
|
-
};
|
|
1103
|
-
this.syncActivities.add(activity);
|
|
1104
|
-
let progressLogged = false;
|
|
1105
|
-
let logProgress = () => {
|
|
1106
|
-
progressLogged = true;
|
|
1107
|
-
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)}%)`);
|
|
1108
|
-
};
|
|
1109
|
-
let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
|
|
1110
|
-
(progressTimer as { unref?: () => void }).unref?.();
|
|
1111
|
-
let slowErrorTimer = setInterval(() => {
|
|
1112
|
-
let elapsed = Date.now() - activity.startTime;
|
|
1113
|
-
let doneFiles = activity.doneFiles || 0;
|
|
1114
|
-
let doneBytes = activity.doneBytes || 0;
|
|
1115
|
-
let bytesPerSecond = doneBytes / (elapsed / 1000);
|
|
1116
|
-
let remainingBytes = totalBytes - doneBytes;
|
|
1117
|
-
let etaText = "unknown (no bytes transferred yet)";
|
|
1118
|
-
if (bytesPerSecond > 0) {
|
|
1119
|
-
let remainingMs = remainingBytes / bytesPerSecond * 1000;
|
|
1120
|
-
etaText = `${formatTime(remainingMs)} remaining, completing around ${new Date(Date.now() + remainingMs).toISOString()}`;
|
|
1121
|
-
}
|
|
1122
|
-
console.error(`Full sync from ${source.getDebugName()} (store ${this.folder}) has been running for ${formatTime(elapsed)}: ${doneFiles}/${pending.length} files (${(doneFiles / pending.length * 100).toFixed(1)}%), ${formatNumber(doneBytes)}B/${formatNumber(totalBytes)}B (${(totalBytes && doneBytes / totalBytes * 100 || 100).toFixed(1)}%), ${formatNumber(bytesPerSecond)}B/s. Estimated ${etaText}.`);
|
|
1123
|
-
}, FULL_SYNC_SLOW_ERROR_INTERVAL);
|
|
1124
|
-
(slowErrorTimer as { unref?: () => void }).unref?.();
|
|
1125
|
-
try {
|
|
1126
|
-
let nextIndex = 0;
|
|
1127
|
-
let failed = false;
|
|
1128
|
-
let copyWorker = async () => {
|
|
1129
|
-
while (!failed && !this.stopped.stop && !state.stopped.stop) {
|
|
1130
|
-
let index = nextIndex++;
|
|
1131
|
-
if (index >= pending.length) return;
|
|
1132
|
-
let { key, entry } = pending[index];
|
|
1133
|
-
let copied = await copyArchiveFile({ from: source, to: this.sources[0].source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
|
|
1134
|
-
if (copied) {
|
|
1135
|
-
// Only move the entry's source if it wasn't changed while we copied
|
|
1136
|
-
if (this.mem.get(key) === entry) {
|
|
1137
|
-
this.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
activity.doneFiles = (activity.doneFiles || 0) + 1;
|
|
1141
|
-
activity.doneBytes = (activity.doneBytes || 0) + entry.size;
|
|
1142
|
-
}
|
|
1143
|
-
};
|
|
1144
|
-
let workers: Promise<void>[] = [];
|
|
1145
|
-
for (let i = 0; i < Math.min(FULL_SYNC_PARALLEL, pending.length); i++) {
|
|
1146
|
-
workers.push(copyWorker().catch((e: Error) => {
|
|
1147
|
-
// Stop the other workers pulling new files, then surface the error
|
|
1148
|
-
failed = true;
|
|
1149
|
-
throw e;
|
|
1150
|
-
}));
|
|
1151
|
-
}
|
|
1152
|
-
await Promise.all(workers);
|
|
1153
|
-
} finally {
|
|
1154
|
-
clearInterval(progressTimer);
|
|
1155
|
-
clearInterval(slowErrorTimer);
|
|
1156
|
-
this.syncActivities.delete(activity);
|
|
1157
|
-
// A sync slow enough to have logged progress also logs its completion
|
|
1158
|
-
if (progressLogged) {
|
|
1159
|
-
logProgress();
|
|
1160
|
-
}
|
|
826
|
+
if (!timeValid.some(x => routeContains(x.route, route))) {
|
|
827
|
+
logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): route ${route} is outside our routes ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))} at writeTime ${writeTime} (the client's shard config is stale)`);
|
|
828
|
+
throw new Error(`${STORAGE_WRONG_ROUTE} This store does not handle route ${route} (key ${JSON.stringify(key)}, our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}, store ${this.folder}). Re-resolve the source for this key and retry.`);
|
|
1161
829
|
}
|
|
1162
830
|
}
|
|
1163
831
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
private async checkMissingKey(key: string): Promise<void> {
|
|
1171
|
-
for (let i = 0; i < this.sources.length; i++) {
|
|
1172
|
-
if (!this.isLive(i)) continue;
|
|
1173
|
-
let { source } = this.sources[i];
|
|
1174
|
-
let state = this.sourceStates[i];
|
|
1175
|
-
if (i === 0 && !state.scanComplete) {
|
|
1176
|
-
// includeTombstones: a deletion on disk (an empty file) must be ingested as a tombstone, write time included
|
|
1177
|
-
let info = await source.getInfo(key, { includeTombstones: true });
|
|
1178
|
-
if (info) {
|
|
1179
|
-
this.updateScanIndex(i, { path: key, createTime: info.writeTime, size: info.size });
|
|
1180
|
-
}
|
|
1181
|
-
continue;
|
|
1182
|
-
}
|
|
1183
|
-
if (state.supportsChangesAfter && Date.now() - state.lastMissCheck > MISS_CHECK_INTERVAL) {
|
|
1184
|
-
state.lastMissCheck = Date.now();
|
|
1185
|
-
await this.pollChanges(i);
|
|
1186
|
-
}
|
|
832
|
+
private async assertMutable(key: string, writeTime: number): Promise<void> {
|
|
833
|
+
if (!this.storeConfig.all().length) return;
|
|
834
|
+
let self = selectEntryAt(this.storeConfig.all(), writeTime, getRoute(key));
|
|
835
|
+
if (!self?.immutable) return;
|
|
836
|
+
if (await this.getInfo({ path: key })) {
|
|
837
|
+
throw new Error(`This store is immutable (at write time ${writeTime}) and ${JSON.stringify(key)} already exists, so it cannot be written to (store ${this.folder})`);
|
|
1187
838
|
}
|
|
1188
839
|
}
|
|
1189
840
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
841
|
+
// See SetConfig.internal: the stamp must land inside SOME window+route this store is configured for (any window, including past ones - synchronization moves old data), so a confused peer can't stuff data onto a store that was never meant to hold it
|
|
842
|
+
private assertInternalWriteAccepted(key: string, writeTime: number, route: number): void {
|
|
843
|
+
if (!this.storeConfig.all().length) return;
|
|
844
|
+
let covered = this.storeConfig.all().some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
|
|
845
|
+
if (!covered) {
|
|
846
|
+
throw new Error(`Internal write of ${JSON.stringify(key)} rejected: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) at route ${route} is outside every window/route this store is configured for: ${JSON.stringify(this.storeConfig.all().map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))} (store ${this.folder})`);
|
|
847
|
+
}
|
|
1196
848
|
}
|
|
1197
849
|
|
|
1198
|
-
/** Internal (store-to-store) read: purely the local disk, completely short-circuiting the index and holder resolution - the caller is another store, and chasing OUR remote holders while answering it is how infinite get loops between stores form. No window or route checks: if the bytes are on our disk, the caller may have them. Note fast
|
|
850
|
+
/** Internal (store-to-store) read: purely the local disk, completely short-circuiting the index and holder resolution - the caller is another store, and chasing OUR remote holders while answering it is how infinite get loops between stores form. No window or route checks: if the bytes are on our disk, the caller may have them. Note this reads the disk past any write delay, so a fast write still buffered in memory is invisible here; the caller re-finds it once it flushes. */
|
|
1199
851
|
private async getInternal2(config: { path: string; range?: { start: number; end: number }; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
1200
852
|
await this.init();
|
|
1201
853
|
// includeTombstones forwards to the disk: a flag-caller (a peer store's synchronization) needs to see our deletions, not just our content. A tombstone deleted from disk entirely only lives in our index, so fall back to that.
|
|
1202
854
|
let result = await this.getDiskSource().disk.get2(config.path, { range: config.range, includeTombstones: config.includeTombstones });
|
|
1203
855
|
if (!result || !result.data) {
|
|
1204
856
|
if (config.includeTombstones) {
|
|
1205
|
-
let
|
|
1206
|
-
if (
|
|
857
|
+
let deleted = this.getDeletedEntry(config.path);
|
|
858
|
+
if (deleted) return { data: Buffer.alloc(0), writeTime: deleted.writeTime, size: 0 };
|
|
1207
859
|
}
|
|
1208
860
|
return undefined;
|
|
1209
861
|
}
|
|
@@ -1214,16 +866,14 @@ export class BlobStore implements IBucketStore {
|
|
|
1214
866
|
private async setInternal(key: string, data: Buffer, config: { lastModified: number }): Promise<void> {
|
|
1215
867
|
await this.init();
|
|
1216
868
|
assertValidLastModified(config.lastModified);
|
|
1217
|
-
|
|
1218
|
-
let entry = this.mem.get(key);
|
|
1219
|
-
let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
|
|
1220
|
-
if (config.lastModified < currentTime) return;
|
|
869
|
+
if (config.lastModified < await this.currentWriteTime(key)) return;
|
|
1221
870
|
if (data.length === 0) {
|
|
1222
|
-
// A
|
|
871
|
+
// A deletion stores nothing on our own source - the tombstone is the whole of it
|
|
1223
872
|
await this.sources[0].source.del(key);
|
|
1224
|
-
|
|
1225
|
-
|
|
873
|
+
this.setIndexDeleted(key, config.lastModified);
|
|
874
|
+
return;
|
|
1226
875
|
}
|
|
876
|
+
await this.sources[0].source.set(key, data, { lastModified: config.lastModified, forceSetImmutable: true, noChecks: true });
|
|
1227
877
|
this.setIndexEntry(key, { writeTime: config.lastModified, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
|
|
1228
878
|
}
|
|
1229
879
|
|
|
@@ -1234,46 +884,29 @@ export class BlobStore implements IBucketStore {
|
|
|
1234
884
|
}
|
|
1235
885
|
|
|
1236
886
|
// The shared engine of set and del: an empty buffer is exactly a deletion here, which is why the empty-buffer rejection lives in set (the public API), not in this machinery
|
|
1237
|
-
private async setOrDelete(key: string, data: Buffer, config: {
|
|
887
|
+
private async setOrDelete(key: string, data: Buffer, config: { lastModified?: number }): Promise<void> {
|
|
1238
888
|
this.config?.onWriteCounted?.("original", data.length);
|
|
1239
889
|
let lastModified = config.lastModified;
|
|
1240
890
|
if (lastModified) {
|
|
1241
891
|
assertValidLastModified(lastModified);
|
|
1242
|
-
let overlayEntry = this.overlay.get(key);
|
|
1243
|
-
let entry = this.mem.get(key);
|
|
1244
|
-
let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
|
|
1245
892
|
// An older write never overwrites a newer one (see IArchives.set)
|
|
1246
|
-
if (lastModified <
|
|
1247
|
-
}
|
|
1248
|
-
let writeTime = lastModified || Date.now();
|
|
1249
|
-
if (config.fast) {
|
|
1250
|
-
// A writeDelay of zero is a real choice (no delay at all), so only an omitted delay gets the default
|
|
1251
|
-
let writeDelay = config.writeDelay;
|
|
1252
|
-
if (writeDelay === undefined) {
|
|
1253
|
-
writeDelay = DEFAULT_FAST_WRITE_DELAY;
|
|
1254
|
-
}
|
|
1255
|
-
// The delay never extends past our own valid window's end (minus the margin, so the writes are on disk before the next window's source takes over - a deploy switchover is just this too, since its remap ends our window). Past that point fast writes write through immediately.
|
|
1256
|
-
let deadline = this.baseWriteWindowEnd() - WINDOW_END_FLUSH_MARGIN;
|
|
1257
|
-
if (writeDelay > 0 && Date.now() < deadline) {
|
|
1258
|
-
// Own disk + our own storage servers (type-"remote") flush within MAX_REMOTE_FAST_BUFFER; the non-"remote" sources (backblaze) wait the full writeDelay
|
|
1259
|
-
let fastFlushAt = Math.min(Date.now() + Math.min(writeDelay, MAX_REMOTE_FAST_BUFFER), deadline);
|
|
1260
|
-
let slowFlushAt = Math.min(Date.now() + writeDelay, deadline);
|
|
1261
|
-
this.overlay.set(key, { data, t: writeTime, fastFlushAt, slowFlushAt });
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
893
|
+
if (lastModified < await this.currentWriteTime(key)) return;
|
|
1264
894
|
}
|
|
1265
|
-
this.
|
|
1266
|
-
await this.writeToSources(key, data,
|
|
895
|
+
// Fast writes are not handled here: a source that was configured with a write delay buffers them itself (see ArchivesDelayed), so this write lands in memory and returns, exactly as it would have. The index is updated either way, so the write is immediately visible to readers of this store.
|
|
896
|
+
await this.writeToSources(key, data, lastModified || Date.now());
|
|
1267
897
|
}
|
|
1268
898
|
|
|
1269
|
-
|
|
1270
|
-
|
|
899
|
+
/** The instant every delayed write must be on its source: the end of our own write window that contains now, minus the flush margin (so the next window's source finds the data on handoff). The LATEST end among covering windows - overlapping windows hand off at the last one. No window contains now (an inert store, or a moment between our windows) -> 0, i.e. nothing may be delayed at all. */
|
|
900
|
+
public writeFlushDeadline(): number {
|
|
901
|
+
// Before updateSources has run there is nothing to hold a write anyway, and nothing may be delayed
|
|
902
|
+
if (!this.sources.length) return 0;
|
|
1271
903
|
let now = Date.now();
|
|
1272
904
|
let end = 0;
|
|
1273
905
|
for (let w of this.sources[0].validWindows) {
|
|
1274
906
|
if (w[0] <= now && now < w[1]) end = Math.max(end, w[1]);
|
|
1275
907
|
}
|
|
1276
|
-
return
|
|
908
|
+
if (!end) return 0;
|
|
909
|
+
return end - WINDOW_END_FLUSH_MARGIN;
|
|
1277
910
|
}
|
|
1278
911
|
|
|
1279
912
|
private getWritableSources(config?: { ignoreWindow?: boolean }): number[] {
|
|
@@ -1286,8 +919,8 @@ export class BlobStore implements IBucketStore {
|
|
|
1286
919
|
return writable;
|
|
1287
920
|
}
|
|
1288
921
|
|
|
1289
|
-
//
|
|
1290
|
-
private async writeToSources(key: string, data: Buffer, writeTime: number
|
|
922
|
+
// Our own source (the first writable one) plus every route-matching peer. Each source decides for itself how long it holds the write before it hits storage (see ArchivesDelayed) - here they all just get written.
|
|
923
|
+
private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
|
|
1291
924
|
// The routing file is NEVER synchronized between storage nodes: the writer writes it directly to each node, so we store it on our own disk only (no valid-window filter - routing/valid windows can't possibly apply to the file defining them) and never forward it to other sources.
|
|
1292
925
|
this.config?.onWriteCounted?.("flushed", data.length);
|
|
1293
926
|
let isRouting = key === ROUTING_FILE;
|
|
@@ -1296,21 +929,19 @@ export class BlobStore implements IBucketStore {
|
|
|
1296
929
|
if (first === undefined) {
|
|
1297
930
|
throw new Error(`No source accepts writes (every source's valid window is in the past), so writes cannot be stored (store ${this.folder})`);
|
|
1298
931
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
}
|
|
932
|
+
// Only our own (first) source blocks the write. Downstream sources are written in the background: a down downstream source must not fail or stall writes, and reconciliation re-sends anything they missed once they come back.
|
|
933
|
+
if (data.length === 0) {
|
|
934
|
+
// A deletion stores nothing on our own source - the tombstone is the whole of it
|
|
935
|
+
await this.sources[first].source.del(key);
|
|
936
|
+
this.setIndexDeleted(key, writeTime);
|
|
937
|
+
} else {
|
|
938
|
+
await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
|
|
1307
939
|
this.setIndexEntry(key, { writeTime, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(first) });
|
|
1308
940
|
}
|
|
1309
941
|
if (isRouting) return;
|
|
1310
942
|
let route = getRoute(key);
|
|
1311
943
|
for (let i of writable) {
|
|
1312
944
|
if (!routeContains(this.sources[i].route, route)) continue;
|
|
1313
|
-
if (config?.downstream && !config.downstream(this.sources[i])) continue;
|
|
1314
945
|
// Deletions travel as del carrying the original write time (never as empty sets - set rejects empty buffers). Backblaze materializes such dels as real empty files, so its listings still show the deletion for other stores to scan in as a tombstone.
|
|
1315
946
|
let push: Promise<unknown>;
|
|
1316
947
|
if (data.length === 0) {
|
|
@@ -1324,105 +955,17 @@ export class BlobStore implements IBucketStore {
|
|
|
1324
955
|
}
|
|
1325
956
|
}
|
|
1326
957
|
|
|
958
|
+
// Slot 0 is always this store's own folder (see planSources), so the disk is known without searching for it - and it is the unwrapped one, past any write delay
|
|
1327
959
|
private getDiskSource(): { disk: ArchivesDisk; sourceIndex: number } {
|
|
1328
|
-
|
|
1329
|
-
let source = this.sources[i].source;
|
|
1330
|
-
if (source instanceof ArchivesDisk) return { disk: source, sourceIndex: i };
|
|
1331
|
-
}
|
|
1332
|
-
throw new Error(`Large uploads require an ArchivesDisk source, and this store has none (store ${this.folder})`);
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
private isRemoteSource(source: ArchivesSource): boolean {
|
|
1336
|
-
return source.sourceConfig?.type === "remote";
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
private async flushOverlay(force?: boolean): Promise<void> {
|
|
1340
|
-
let now = Date.now();
|
|
1341
|
-
for (let [key, entry] of this.overlay) {
|
|
1342
|
-
let doFast = !entry.fastFlushed && (force || entry.fastFlushAt <= now);
|
|
1343
|
-
let doSlow = force || entry.slowFlushAt <= now;
|
|
1344
|
-
if (!doFast && !doSlow) continue;
|
|
1345
|
-
if (doSlow) {
|
|
1346
|
-
// The remaining sources: if the fast flush already ran, only the non-"remote" ones (own disk + remote peers are done); otherwise everything at once (writeDelay was <= MAX_REMOTE_FAST_BUFFER, or a forced/window-boundary flush)
|
|
1347
|
-
await this.writeToSources(key, entry.data, entry.t, entry.fastFlushed ? { writeBase: false, downstream: s => !this.isRemoteSource(s) } : undefined);
|
|
1348
|
-
// Only remove if it wasn't overwritten while we were flushing
|
|
1349
|
-
if (this.overlay.get(key) === entry) {
|
|
1350
|
-
this.overlay.delete(key);
|
|
1351
|
-
}
|
|
1352
|
-
continue;
|
|
1353
|
-
}
|
|
1354
|
-
// Fast stage: own disk + our own storage servers only; the entry stays in the overlay for its slow flush
|
|
1355
|
-
await this.writeToSources(key, entry.data, entry.t, { downstream: s => this.isRemoteSource(s) });
|
|
1356
|
-
entry.fastFlushed = true;
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
// readerDiskLimit: the disk is only a bounded read cache, so once it exceeds the limit, the least recently used files are deleted from it - but ONLY when another source verifiably holds a same-or-newer copy (the only copy of a file is never deleted), and the index entry repoints to that source so reads keep working (re-caching on the next read).
|
|
1361
|
-
private evicting = false;
|
|
1362
|
-
private async enforceDiskLimit(): Promise<void> {
|
|
1363
|
-
let limit = this.config?.readerDiskLimit;
|
|
1364
|
-
if (!limit || this.evicting) return;
|
|
1365
|
-
if (this.sourceByteCounts[0] <= limit) return;
|
|
1366
|
-
this.evicting = true;
|
|
1367
|
-
let evictedFiles = 0;
|
|
1368
|
-
let evictedBytes = 0;
|
|
1369
|
-
try {
|
|
1370
|
-
let baseSourcesListIndex = this.sourcesListIndexOfSlot(0);
|
|
1371
|
-
let candidates: { key: string; entry: IndexEntry }[] = [];
|
|
1372
|
-
for (let [key, entry] of this.mem) {
|
|
1373
|
-
if (entry.sourcesListIndex !== baseSourcesListIndex || entry.size === 0 || key === ROUTING_FILE) continue;
|
|
1374
|
-
candidates.push({ key, entry });
|
|
1375
|
-
}
|
|
1376
|
-
sort(candidates, x => x.entry.lastAccess);
|
|
1377
|
-
for (let { key, entry } of candidates) {
|
|
1378
|
-
if (this.stopped.stop) return;
|
|
1379
|
-
if (this.sourceByteCounts[0] <= limit) break;
|
|
1380
|
-
if (this.mem.get(key) !== entry) continue;
|
|
1381
|
-
let holder: number | undefined;
|
|
1382
|
-
for (let i = 1; i < this.sources.length; i++) {
|
|
1383
|
-
if (!this.isLive(i)) continue;
|
|
1384
|
-
try {
|
|
1385
|
-
let info = await this.sources[i].source.getInfo(key);
|
|
1386
|
-
if (info && info.writeTime >= entry.writeTime) {
|
|
1387
|
-
holder = i;
|
|
1388
|
-
break;
|
|
1389
|
-
}
|
|
1390
|
-
} catch {
|
|
1391
|
-
// A down source just can't vouch for this file right now
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
if (holder === undefined) continue;
|
|
1395
|
-
await this.sources[0].source.del(key);
|
|
1396
|
-
this.setIndexEntry(key, { writeTime: entry.writeTime, size: entry.size, sourcesListIndex: this.sourcesListIndexOfSlot(holder) });
|
|
1397
|
-
evictedFiles++;
|
|
1398
|
-
evictedBytes += entry.size;
|
|
1399
|
-
}
|
|
1400
|
-
} finally {
|
|
1401
|
-
this.evicting = false;
|
|
1402
|
-
if (evictedFiles) {
|
|
1403
|
-
console.log(`Disk cache over readerDiskLimit (store ${this.folder}): evicted ${evictedFiles} least-recently-used files (${formatNumber(evictedBytes)}B), now at ${formatNumber(this.sourceByteCounts[0])}B/${formatNumber(this.config?.readerDiskLimit || 0)}B`);
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
960
|
+
return { disk: this.ownDisk, sourceIndex: 0 };
|
|
1406
961
|
}
|
|
1407
962
|
|
|
1408
|
-
|
|
1409
|
-
private async
|
|
1410
|
-
let
|
|
1411
|
-
|
|
1412
|
-
if (
|
|
1413
|
-
|
|
1414
|
-
if (entry.writeTime > cutoff) continue;
|
|
1415
|
-
this.deleteIndexEntry(key);
|
|
1416
|
-
for (let i = 0; i < this.sources.length; i++) {
|
|
1417
|
-
if (!this.isLive(i)) continue;
|
|
1418
|
-
let sourceEntry = this.sources[i];
|
|
1419
|
-
if (!windowsAcceptWrites(sourceEntry.validWindows)) continue;
|
|
1420
|
-
let source = sourceEntry.source;
|
|
1421
|
-
if (!(source instanceof ArchivesBackblaze)) continue;
|
|
1422
|
-
void source.del(key).catch((e: Error) => {
|
|
1423
|
-
console.error(`Removing expired tombstone ${key} from ${source.getDebugName()} failed: ${e.stack ?? e}`);
|
|
1424
|
-
});
|
|
1425
|
-
}
|
|
963
|
+
/** Writes everything still held by a delayed source (see ArchivesDelayed). force also writes what isn't due yet - shutdown cannot leave writes in memory. */
|
|
964
|
+
private async flushDelayedWrites(force?: boolean): Promise<void> {
|
|
965
|
+
for (let { source } of this.sources) {
|
|
966
|
+
let delayed = asDelayed(source);
|
|
967
|
+
if (!delayed) continue;
|
|
968
|
+
await delayed.flush(force);
|
|
1426
969
|
}
|
|
1427
970
|
}
|
|
1428
971
|
|