sliftutils 1.7.71 → 1.7.73
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/index.d.ts +506 -257
- package/misc/https/dist/hostServer.ts.cache +5 -2
- package/package.json +1 -1
- package/spec.txt +10 -0
- package/storage/ArchivesDisk.d.ts +4 -13
- package/storage/ArchivesDisk.ts +6 -4
- package/storage/IArchives.d.ts +15 -10
- package/storage/IArchives.ts +19 -6
- package/storage/backblaze.d.ts +3 -9
- package/storage/backblaze.ts +22 -19
- package/storage/dist/ArchivesDisk.ts.cache +5 -2
- package/storage/dist/IArchives.ts.cache +17 -3
- package/storage/dist/backblaze.ts.cache +35 -26
- package/storage/remoteStorage/ArchivesRemote.d.ts +5 -9
- package/storage/remoteStorage/ArchivesRemote.ts +25 -20
- package/storage/remoteStorage/ArchivesUrl.d.ts +3 -9
- package/storage/remoteStorage/ArchivesUrl.ts +7 -5
- package/storage/remoteStorage/accessPage.tsx +4 -4
- package/storage/remoteStorage/accessStats.d.ts +2 -0
- package/storage/remoteStorage/accessStats.ts +32 -0
- package/storage/remoteStorage/blobStore.d.ts +193 -88
- package/storage/remoteStorage/blobStore.ts +535 -400
- package/storage/remoteStorage/bucketDisk.d.ts +18 -0
- package/storage/remoteStorage/bucketDisk.ts +66 -0
- package/storage/remoteStorage/createArchives.d.ts +4 -10
- package/storage/remoteStorage/createArchives.ts +103 -62
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +28 -19
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +7 -4
- package/storage/remoteStorage/dist/accessPage.tsx.cache +6 -6
- package/storage/remoteStorage/dist/accessStats.ts.cache +38 -3
- package/storage/remoteStorage/dist/blobStore.ts.cache +516 -374
- package/storage/remoteStorage/dist/bucketDisk.ts.cache +72 -0
- package/storage/remoteStorage/dist/createArchives.ts.cache +114 -57
- package/storage/remoteStorage/dist/intermediateSources.ts.cache +12 -12
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +18 -2
- package/storage/remoteStorage/dist/serverConfig.ts.cache +116 -0
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +6 -5
- package/storage/remoteStorage/dist/sourcesList.ts.cache +5 -2
- package/storage/remoteStorage/dist/storageController.ts.cache +223 -190
- package/storage/remoteStorage/dist/storageServer.ts.cache +9 -9
- package/storage/remoteStorage/dist/storageServerState.ts.cache +329 -566
- package/storage/remoteStorage/dist/storePlan.ts.cache +149 -0
- package/storage/remoteStorage/dist/validation.ts.cache +40 -0
- package/storage/remoteStorage/grantAccessCli.ts +1 -1
- package/storage/remoteStorage/intermediateSources.d.ts +3 -5
- package/storage/remoteStorage/intermediateSources.ts +14 -16
- package/storage/remoteStorage/remoteConfig.d.ts +2 -2
- package/storage/remoteStorage/remoteConfig.ts +19 -3
- package/storage/remoteStorage/serverConfig.d.ts +27 -0
- package/storage/remoteStorage/serverConfig.ts +109 -0
- package/storage/remoteStorage/sourceWrapper.d.ts +3 -3
- package/storage/remoteStorage/sourceWrapper.ts +7 -6
- package/storage/remoteStorage/sourcesList.ts +3 -0
- package/storage/remoteStorage/storageController.d.ts +118 -36
- package/storage/remoteStorage/storageController.ts +166 -168
- package/storage/remoteStorage/storageServer.ts +1 -1
- package/storage/remoteStorage/storageServerState.d.ts +52 -63
- package/storage/remoteStorage/storageServerState.ts +340 -584
- package/storage/remoteStorage/storePlan.d.ts +34 -0
- package/storage/remoteStorage/storePlan.ts +166 -0
- package/storage/remoteStorage/validation.d.ts +4 -0
- package/storage/remoteStorage/validation.ts +31 -0
|
@@ -4,17 +4,18 @@ import { runInfinitePoll, delay } from "socket-function/src/batching";
|
|
|
4
4
|
import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
|
|
5
5
|
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
6
6
|
import {
|
|
7
|
-
IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig,
|
|
8
|
-
windowAcceptsWrites, SyncActivity, FULL_ROUTE, copyArchiveFile,
|
|
7
|
+
IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, assertValidLastModified,
|
|
8
|
+
windowAcceptsWrites, SyncActivity, FULL_ROUTE, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, copyArchiveFile,
|
|
9
9
|
} from "../IArchives";
|
|
10
10
|
import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
|
|
11
11
|
import { ArchivesBackblaze } from "../backblaze";
|
|
12
12
|
import { ROUTING_FILE, getRoute, routeContains } from "./remoteConfig";
|
|
13
|
+
import { selectEntryAt } from "./storePlan";
|
|
13
14
|
import { BulkDatabaseBase, noopReactiveDeps } from "../BulkDatabase2/BulkDatabaseBase";
|
|
14
15
|
import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../FileFolderAPI";
|
|
15
16
|
import { SourcesList } from "./sourcesList";
|
|
16
17
|
|
|
17
|
-
// 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 synchronizes the index from all sources (see ArchivesSource in IArchives.ts). Every startup fully rescans each source's metadata, so the index self-heals; the file with the highest write time wins across all sources, so multiple sources need no stacking order.
|
|
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 synchronizes the index from all sources (see ArchivesSource in IArchives.ts). Every startup fully rescans each source's metadata, so the index self-heals; the file with the highest write time wins across all sources, so multiple sources need no stacking order. The store also holds its own routing entries (the self entries of the ONE route it serves), so it validates writes itself: valid windows, routes, immutability, and internal-push acceptance.
|
|
18
19
|
|
|
19
20
|
export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
|
|
20
21
|
const FAST_FLUSH_POLL = 1000 * 15;
|
|
@@ -45,26 +46,17 @@ const FULL_SYNC_SLOW_ERROR_INTERVAL = 1000 * 60 * 60;
|
|
|
45
46
|
// 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
|
|
46
47
|
const RECONCILE_MAX_CONSECUTIVE_FAILURES = 5;
|
|
47
48
|
const RECONCILE_ERROR_LOG_LIMIT = 3;
|
|
49
|
+
const WRONG_TARGET_LOG_THROTTLE = 60 * 1000;
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
// Resolve once the write is in memory; flush to the sources after writeDelay, coalescing writes to the same key (only the latest is written). Data is lost if the process crashes first.
|
|
51
|
-
fast?: boolean;
|
|
52
|
-
writeDelay?: number;
|
|
53
|
-
// Stamps the write with this last-write time instead of now. Older than the current file's time no-ops; more than 15 minutes in the future throws. See IArchives.set.
|
|
54
|
-
lastModified?: number;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// What the storage server needs from a bucket's store. BlobStore implements it fully; ArchivesDisk also satisfies it (used directly for rawDisk buckets), minus the optional index-backed methods.
|
|
51
|
+
// 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).
|
|
58
52
|
export type IBucketStore = {
|
|
59
|
-
|
|
60
|
-
get2(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number } | undefined>;
|
|
67
|
-
findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
|
|
53
|
+
/** internal (store-to-store) reads answer purely from the local disk; see GetConfig.internal */
|
|
54
|
+
get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
|
|
55
|
+
/** internal (store-to-store) writes go to the local disk + index with no fan-out; see SetConfig.internal */
|
|
56
|
+
set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void>;
|
|
57
|
+
del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void>;
|
|
58
|
+
getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined>;
|
|
59
|
+
findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]>;
|
|
68
60
|
getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
|
|
69
61
|
getSyncStatus?(): Promise<ArchivesSyncStatus>;
|
|
70
62
|
getSyncProgress?(): {
|
|
@@ -74,12 +66,52 @@ export type IBucketStore = {
|
|
|
74
66
|
syncing: SyncActivity[];
|
|
75
67
|
};
|
|
76
68
|
computeIndexTotals?(): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }>;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
/** path/lastModified let the store reject an upload into an immutable bucket before any bytes move */
|
|
70
|
+
startLargeUpload(config?: { path?: string; lastModified?: number }): Promise<string>;
|
|
71
|
+
appendLargeUpload(config: { id: string; data: Buffer }): Promise<void>;
|
|
72
|
+
finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void>;
|
|
73
|
+
cancelLargeUpload(config: { id: string }): Promise<void>;
|
|
81
74
|
};
|
|
82
75
|
|
|
76
|
+
/** rawDisk buckets: the disk IS the store. No index, no synchronization, no window/route/immutability validation. */
|
|
77
|
+
export class RawDiskStore implements IBucketStore {
|
|
78
|
+
constructor(private disk: ArchivesDisk) { }
|
|
79
|
+
|
|
80
|
+
public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
81
|
+
return await this.disk.get2(config.path, { range: config.range, includeTombstones: config.includeTombstones });
|
|
82
|
+
}
|
|
83
|
+
public async set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
|
|
84
|
+
await this.disk.set(config.path, config.data, { lastModified: config.lastModified });
|
|
85
|
+
}
|
|
86
|
+
public async del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void> {
|
|
87
|
+
if (config.path === ROUTING_FILE) {
|
|
88
|
+
throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
|
|
89
|
+
}
|
|
90
|
+
await this.disk.del(config.path, { lastModified: config.lastModified });
|
|
91
|
+
}
|
|
92
|
+
public async getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined> {
|
|
93
|
+
return await this.disk.getInfo(config.path, { includeTombstones: config.includeTombstones });
|
|
94
|
+
}
|
|
95
|
+
public async findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]> {
|
|
96
|
+
return await this.disk.findInfo(config.prefix, { shallow: config.shallow, type: config.type });
|
|
97
|
+
}
|
|
98
|
+
public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
99
|
+
return await this.disk.getChangesAfter2(config);
|
|
100
|
+
}
|
|
101
|
+
public async startLargeUpload(): Promise<string> {
|
|
102
|
+
return await this.disk.startLargeUpload();
|
|
103
|
+
}
|
|
104
|
+
public async appendLargeUpload(config: { id: string; data: Buffer }): Promise<void> {
|
|
105
|
+
await this.disk.appendLargeUpload(config.id, config.data);
|
|
106
|
+
}
|
|
107
|
+
public async finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void> {
|
|
108
|
+
await this.disk.finishLargeUpload(config.id, config.path, config.lastModified);
|
|
109
|
+
}
|
|
110
|
+
public async cancelLargeUpload(config: { id: string }): Promise<void> {
|
|
111
|
+
await this.disk.cancelLargeUpload(config.id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
type OverlayEntry = {
|
|
84
116
|
// A zero-length buffer is a pending delete (tombstone)
|
|
85
117
|
data: Buffer;
|
|
@@ -99,7 +131,7 @@ type IndexEntry = {
|
|
|
99
131
|
writeTime: number;
|
|
100
132
|
size: number;
|
|
101
133
|
sourcesListIndex: number;
|
|
102
|
-
// 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
|
|
134
|
+
// 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
|
|
103
135
|
changedAt: number;
|
|
104
136
|
// In-memory only (not persisted): when the file was last served, for readerDiskLimit's LRU eviction. Starts as changedAt on load.
|
|
105
137
|
lastAccess: number;
|
|
@@ -158,6 +190,13 @@ function newSourceState(): SourceState {
|
|
|
158
190
|
};
|
|
159
191
|
}
|
|
160
192
|
|
|
193
|
+
let lastWrongTargetLog = 0;
|
|
194
|
+
function logWrongTargetRejection(message: string): void {
|
|
195
|
+
if (Date.now() - lastWrongTargetLog < WRONG_TARGET_LOG_THROTTLE) return;
|
|
196
|
+
lastWrongTargetLog = Date.now();
|
|
197
|
+
console.log(message);
|
|
198
|
+
}
|
|
199
|
+
|
|
161
200
|
export class BlobStore implements IBucketStore {
|
|
162
201
|
constructor(
|
|
163
202
|
private folder: string,
|
|
@@ -171,78 +210,12 @@ export class BlobStore implements IBucketStore {
|
|
|
171
210
|
onWriteCounted?: (kind: "original" | "flushed", bytes: number) => void;
|
|
172
211
|
// Resolves a persisted source URL (see ArchivesSource.url) to a cached IArchives, so entries whose holder is no longer configured can still be read
|
|
173
212
|
resolveSourceUrl?: (url: string) => IArchives;
|
|
213
|
+
// 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).
|
|
214
|
+
entries?: HostedConfig[];
|
|
174
215
|
}
|
|
175
216
|
) { }
|
|
176
217
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// The index's BulkDatabase2 files live under <folder>/index. "blobIndex2": the "blobIndex" generation persisted sources-array positions as the holding source, which are not stable across runs - its entries are unusable, so it is simply never read again.
|
|
180
|
-
private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex2", noopReactiveDeps, async (p: string) => {
|
|
181
|
-
let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(path.join(this.folder, "index"));
|
|
182
|
-
for (let part of p.split("/")) {
|
|
183
|
-
if (part) base = await base.getDirectoryHandle(part, { create: true });
|
|
184
|
-
}
|
|
185
|
-
return wrapHandle(base);
|
|
186
|
-
});
|
|
187
|
-
// 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).
|
|
188
|
-
private mem = new Map<string, IndexEntry>();
|
|
189
|
-
// 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.
|
|
190
|
-
private indexFileCount = 0;
|
|
191
|
-
private indexByteCount = 0;
|
|
192
|
-
// The same totals per holding source (index 0 = our disk, which readerDiskLimit bounds)
|
|
193
|
-
private sourceFileCounts = this.sources.map(() => 0);
|
|
194
|
-
private sourceByteCounts = this.sources.map(() => 0);
|
|
195
|
-
// Background scans / full syncs currently in progress (a Set - one source can have a change poll's full sync and a rescan overlapping)
|
|
196
|
-
private syncActivities = new Set<SyncActivity>();
|
|
197
|
-
private dirty = new Map<string, IndexEntry | undefined>();
|
|
198
|
-
private overlay = new Map<string, OverlayEntry>();
|
|
199
|
-
private sourceStates = this.sources.map(() => newSourceState());
|
|
200
|
-
private syncStarted = false;
|
|
201
|
-
// The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
|
|
202
|
-
private sourcesList = new SourcesList(path.join(this.folder, "index", "sourcesList.txt"));
|
|
203
|
-
// Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
|
|
204
|
-
private slotSourcesListIndexes: number[] = [];
|
|
205
|
-
private slotRegistrations: Promise<void>[] = [];
|
|
206
|
-
|
|
207
|
-
private isLive(sourceIndex: number): boolean {
|
|
208
|
-
return !!this.sources[sourceIndex] && !this.sourceStates[sourceIndex].dead;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private registerSlot(slot: number): Promise<void> {
|
|
212
|
-
let existing = this.slotRegistrations[slot];
|
|
213
|
-
if (existing) return existing;
|
|
214
|
-
let registration = this.sourcesList.ensure(this.sources[slot].url).then(index => {
|
|
215
|
-
this.slotSourcesListIndexes[slot] = index;
|
|
216
|
-
});
|
|
217
|
-
this.slotRegistrations[slot] = registration;
|
|
218
|
-
return registration;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// The persistent sourcesListIndex of a slot - only valid once the slot's registration resolved (init and runSourceSync guarantee that before any indexing happens)
|
|
222
|
-
private sourcesListIndexOfSlot(slot: number): number {
|
|
223
|
-
let index = this.slotSourcesListIndexes[slot];
|
|
224
|
-
if (index === undefined) {
|
|
225
|
-
throw new Error(`Source slot ${slot} (${this.sources[slot]?.url}) has no registered sourcesListIndex yet (store ${this.folder})`);
|
|
226
|
-
}
|
|
227
|
-
return index;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// 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).
|
|
231
|
-
private slotForSourcesListIndex(sourcesListIndex: number): number | undefined {
|
|
232
|
-
for (let i = 0; i < this.slotSourcesListIndexes.length; i++) {
|
|
233
|
-
if (this.slotSourcesListIndexes[i] === sourcesListIndex && this.isLive(i)) return i;
|
|
234
|
-
}
|
|
235
|
-
return undefined;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 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
|
|
239
|
-
private async getEntryHolder(entry: IndexEntry): Promise<IArchives | undefined> {
|
|
240
|
-
let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
241
|
-
if (slot !== undefined) return this.sources[slot].source;
|
|
242
|
-
let url = this.sourcesList.getUrl(entry.sourcesListIndex) || await this.sourcesList.getUrlReloading(entry.sourcesListIndex);
|
|
243
|
-
if (!url) return undefined;
|
|
244
|
-
return this.config?.resolveSourceUrl?.(url);
|
|
245
|
-
}
|
|
218
|
+
// #region Main interface
|
|
246
219
|
|
|
247
220
|
public init = lazy(async () => {
|
|
248
221
|
for (let i = 0; i < this.sources.length; i++) {
|
|
@@ -272,62 +245,281 @@ export class BlobStore implements IBucketStore {
|
|
|
272
245
|
await this.flushIndex();
|
|
273
246
|
}
|
|
274
247
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
this.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
let
|
|
282
|
-
let
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
sourcesListIndex = this.sourcesListIndexOfSlot(0);
|
|
248
|
+
public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
249
|
+
if (config.internal) {
|
|
250
|
+
return await this.getInternal2(config);
|
|
251
|
+
}
|
|
252
|
+
await this.init();
|
|
253
|
+
let key = config.path;
|
|
254
|
+
let range = config.range;
|
|
255
|
+
let overlayEntry = this.overlay.get(key);
|
|
256
|
+
if (overlayEntry) {
|
|
257
|
+
// An empty file IS a missing file (tombstone)
|
|
258
|
+
if (overlayEntry.data.length === 0 && !config.includeTombstones) return undefined;
|
|
259
|
+
let data = overlayEntry.data;
|
|
260
|
+
let size = data.length;
|
|
261
|
+
if (range) {
|
|
262
|
+
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
291
263
|
}
|
|
292
|
-
|
|
293
|
-
this.mem.set(entry.key, full);
|
|
294
|
-
this.countEntry(full, 1);
|
|
264
|
+
return { data, writeTime: overlayEntry.t, size };
|
|
295
265
|
}
|
|
266
|
+
let entry = await this.getIndexEntry(key);
|
|
267
|
+
if (!entry) return undefined;
|
|
268
|
+
if (entry.size === 0) {
|
|
269
|
+
if (!config.includeTombstones) return undefined;
|
|
270
|
+
// A tombstone has no stored bytes - the index entry alone is the deletion, so the flag-caller gets its write time with empty data
|
|
271
|
+
return { data: Buffer.alloc(0), writeTime: entry.writeTime, size: 0 };
|
|
272
|
+
}
|
|
273
|
+
entry.lastAccess = Date.now();
|
|
274
|
+
let holderArchives = await this.getEntryHolder(entry);
|
|
275
|
+
let result: { data: Buffer; writeTime: number; size: number } | undefined;
|
|
276
|
+
let holderError: Error | undefined;
|
|
277
|
+
if (holderArchives) {
|
|
278
|
+
try {
|
|
279
|
+
// includeTombstones: the holder answering "deleted" (a tombstone, with its write time) must be distinguishable from "I lost the file" - the former is authoritative and must NOT fall back to older copies, which would resurrect the deletion for this read
|
|
280
|
+
let answer = await holderArchives.get2(key, { range, internal: true, includeTombstones: true });
|
|
281
|
+
if (answer && answer.data && !answer.data.length && !(range && answer.size) && answer.writeTime >= entry.writeTime) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
// NOTE: a ranged read of a real file can legitimately be empty (range past EOF), so only unranged emptiness means tombstone/absent
|
|
285
|
+
if (answer && answer.data && (answer.data.length || range && answer.size)) {
|
|
286
|
+
result = { data: answer.data, writeTime: answer.writeTime, size: answer.size };
|
|
287
|
+
}
|
|
288
|
+
} catch (e) {
|
|
289
|
+
holderError = e as Error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (result) {
|
|
293
|
+
// Ranged reads can't populate a cache (they're partial)
|
|
294
|
+
if (this.slotForSourcesListIndex(entry.sourcesListIndex) !== 0 && !range) {
|
|
295
|
+
await this.cacheRead(key, result);
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
// The routing file is only ever read off our own disk - falling back to another source's copy would synchronize it between nodes through the read path, which it never is
|
|
300
|
+
if (key === ROUTING_FILE) {
|
|
301
|
+
if (holderError) throw holderError;
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
// The holder is down or lost the file. ANY other source's copy beats no value - even an OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
|
|
305
|
+
let holderSlot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
306
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
307
|
+
if (i === holderSlot || !this.isLive(i)) continue;
|
|
308
|
+
let fallback: { data: Buffer; writeTime: number; size: number } | undefined;
|
|
309
|
+
try {
|
|
310
|
+
let answer = await this.sources[i].source.get2(key, { internal: true });
|
|
311
|
+
// Empty data counts as absent - a tombstone, not content (this read is unranged, so emptiness is unambiguous)
|
|
312
|
+
if (answer && answer.data && answer.data.length) {
|
|
313
|
+
fallback = { data: answer.data, writeTime: answer.writeTime, size: answer.size };
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!fallback) continue;
|
|
319
|
+
await this.cacheRead(key, fallback);
|
|
320
|
+
let data = fallback.data;
|
|
321
|
+
if (range) {
|
|
322
|
+
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
323
|
+
}
|
|
324
|
+
return { data, writeTime: fallback.writeTime, size: fallback.size };
|
|
325
|
+
}
|
|
326
|
+
if (holderError) throw holderError;
|
|
327
|
+
// The holder answered "not there" and no other source has it either: the entry was stale
|
|
328
|
+
this.deleteIndexEntry(key);
|
|
329
|
+
return undefined;
|
|
296
330
|
}
|
|
297
331
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
332
|
+
public async set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
|
|
333
|
+
let { path: key, data } = config;
|
|
334
|
+
if (!data.length) {
|
|
335
|
+
throw new Error(`set was called with an empty buffer for ${JSON.stringify(key)} (store ${this.folder}): an empty file IS a deletion in this system and would read back as missing - call del instead`);
|
|
336
|
+
}
|
|
337
|
+
await this.init();
|
|
338
|
+
let writeTime = config.lastModified || Date.now();
|
|
339
|
+
let route = getRoute(key);
|
|
340
|
+
// The routing file defines the windows/routes, so they can't possibly apply to it (and it never flows through validation)
|
|
341
|
+
if (key !== ROUTING_FILE && this.entries.length) {
|
|
342
|
+
if (!config.lastModified) {
|
|
343
|
+
let timeValid = this.entries.filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
|
|
344
|
+
if (!timeValid.length) {
|
|
345
|
+
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.entries.map(x => x.validWindow))} (a switchover moved the write target)`);
|
|
346
|
+
throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This store is not a valid write target at ${writeTime} (our valid windows: ${JSON.stringify(this.entries.map(x => x.validWindow))}, store ${this.folder}). Re-resolve the currently valid source and retry.`);
|
|
347
|
+
}
|
|
348
|
+
if (!timeValid.some(x => routeContains(x.route, route))) {
|
|
349
|
+
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)`);
|
|
350
|
+
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.`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (config.forceSetImmutable) {
|
|
354
|
+
if (!config.lastModified) {
|
|
355
|
+
throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${JSON.stringify(key)} (store ${this.folder})`);
|
|
356
|
+
}
|
|
357
|
+
// Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable)
|
|
358
|
+
let self = selectEntryAt(this.entries, writeTime, route);
|
|
359
|
+
if (self?.immutable && await this.getInfo({ path: key })) return;
|
|
360
|
+
} else {
|
|
361
|
+
await this.assertMutable(key, writeTime);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (config.internal) {
|
|
365
|
+
if (!config.lastModified) {
|
|
366
|
+
throw new Error(`Internal writes must carry lastModified (they are synchronization pushes, ordered by their write time), writing ${JSON.stringify(key)} (store ${this.folder})`);
|
|
367
|
+
}
|
|
368
|
+
this.assertInternalWriteAccepted(key, config.lastModified, route);
|
|
369
|
+
await this.setInternal(key, data, { lastModified: config.lastModified });
|
|
370
|
+
return;
|
|
307
371
|
}
|
|
372
|
+
let self = this.entries.length && selectEntryAt(this.entries, writeTime, route) || undefined;
|
|
373
|
+
await this.setOrDelete(key, data, { fast: self?.fast, writeDelay: self?.writeDelay, lastModified: config.lastModified });
|
|
308
374
|
}
|
|
309
375
|
|
|
310
|
-
|
|
311
|
-
let
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
this.
|
|
316
|
-
|
|
376
|
+
public async del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void> {
|
|
377
|
+
let key = config.path;
|
|
378
|
+
if (key === ROUTING_FILE) {
|
|
379
|
+
throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
|
|
380
|
+
}
|
|
381
|
+
await this.init();
|
|
382
|
+
if (config.internal) {
|
|
383
|
+
if (!config.lastModified) {
|
|
384
|
+
throw new Error(`Internal deletions must carry lastModified (they are synchronization pushes, ordered by their write time), deleting ${JSON.stringify(key)} (store ${this.folder})`);
|
|
385
|
+
}
|
|
386
|
+
this.assertInternalWriteAccepted(key, config.lastModified, getRoute(key));
|
|
387
|
+
// setInternal treats an empty buffer as exactly a deletion: disk removal plus a tombstone index entry, no fan-out
|
|
388
|
+
await this.setInternal(key, Buffer.alloc(0), { lastModified: config.lastModified });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Deletes are tombstone writes (an empty file IS a missing file): the size-0 index entry is ordered by write time like any other write, propagates through synchronization, and expires after TOMBSTONE_EXPIRY
|
|
392
|
+
let writeTime = config.lastModified || Date.now();
|
|
393
|
+
let self = this.entries.length && selectEntryAt(this.entries, writeTime, getRoute(key)) || undefined;
|
|
394
|
+
await this.setOrDelete(key, Buffer.alloc(0), { fast: self?.fast, writeDelay: self?.writeDelay, lastModified: config.lastModified });
|
|
317
395
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
this.
|
|
323
|
-
|
|
396
|
+
|
|
397
|
+
public async getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined> {
|
|
398
|
+
await this.init();
|
|
399
|
+
let key = config.path;
|
|
400
|
+
let overlayEntry = this.overlay.get(key);
|
|
401
|
+
if (overlayEntry) {
|
|
402
|
+
if (!overlayEntry.data.length && !config.includeTombstones) return undefined;
|
|
403
|
+
return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
|
|
404
|
+
}
|
|
405
|
+
let entry = await this.getIndexEntry(key);
|
|
406
|
+
if (!entry) return undefined;
|
|
407
|
+
if (!entry.size && !config.includeTombstones) return undefined;
|
|
408
|
+
return { writeTime: entry.writeTime, size: entry.size };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
public async findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]> {
|
|
412
|
+
await this.init();
|
|
413
|
+
await this.waitForRequiredScans();
|
|
414
|
+
let prefix = config.prefix;
|
|
415
|
+
let infos = new Map<string, ArchiveFileInfo>();
|
|
416
|
+
for (let [key, entry] of this.mem) {
|
|
417
|
+
if (!key.startsWith(prefix)) continue;
|
|
418
|
+
// Tombstones are missing files, so listings hide them
|
|
419
|
+
if (entry.size === 0) continue;
|
|
420
|
+
infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
|
|
421
|
+
}
|
|
422
|
+
for (let [key, overlayEntry] of this.overlay) {
|
|
423
|
+
if (!key.startsWith(prefix)) continue;
|
|
424
|
+
if (overlayEntry.data.length === 0) {
|
|
425
|
+
infos.delete(key);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
infos.set(key, { path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
|
|
429
|
+
}
|
|
430
|
+
let files = applyFindInfoShape(Array.from(infos.values()), prefix, { shallow: config.shallow, type: config.type });
|
|
431
|
+
sort(files, x => x.path);
|
|
432
|
+
return files;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// All files changed after config.time — fast, straight from the in-memory 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 tombstone entries — that's how they propagate to stores syncing from us. config.routes lets a store syncing a partial shard ask for just its slice.
|
|
436
|
+
public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
437
|
+
await this.init();
|
|
438
|
+
await this.waitForRequiredScans();
|
|
439
|
+
let inRoutes = (key: string) => !config.routes || config.routes.some(route => routeContains(route, getRoute(key)));
|
|
440
|
+
let files: ArchiveFileInfo[] = [];
|
|
441
|
+
for (let [key, entry] of this.mem) {
|
|
442
|
+
if (entry.changedAt <= config.time) continue;
|
|
443
|
+
if (this.overlay.has(key)) continue;
|
|
444
|
+
if (!inRoutes(key)) continue;
|
|
445
|
+
files.push({ path: key, createTime: entry.writeTime, size: entry.size });
|
|
446
|
+
}
|
|
447
|
+
for (let [key, overlayEntry] of this.overlay) {
|
|
448
|
+
if (overlayEntry.t <= config.time) continue;
|
|
449
|
+
if (!inRoutes(key)) continue;
|
|
450
|
+
files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
|
|
451
|
+
}
|
|
452
|
+
sort(files, x => x.path);
|
|
453
|
+
return files;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
457
|
+
await this.init();
|
|
458
|
+
return {
|
|
459
|
+
allScansComplete: this.sourceStates.every(x => x.scanComplete),
|
|
460
|
+
indexSize: this.mem.size,
|
|
461
|
+
sources: this.sources.map((x, i) => ({
|
|
462
|
+
debugName: x.source.getDebugName(),
|
|
463
|
+
validWindow: x.validWindow,
|
|
464
|
+
route: x.route,
|
|
465
|
+
noFullSync: x.noFullSync,
|
|
466
|
+
supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
|
|
467
|
+
initialScanComplete: this.sourceStates[i].scanComplete,
|
|
468
|
+
scannedCount: this.sourceStates[i].scannedCount,
|
|
469
|
+
})).filter((x, i) => this.isLive(i)),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The cheap always-current totals plus any in-progress background synchronization. */
|
|
474
|
+
public getSyncProgress(): {
|
|
475
|
+
index: { fileCount: number; byteCount: number };
|
|
476
|
+
sources: { debugName: string; fileCount: number; byteCount: number }[];
|
|
477
|
+
readerDiskLimit?: number;
|
|
478
|
+
syncing: SyncActivity[];
|
|
479
|
+
} {
|
|
480
|
+
return {
|
|
481
|
+
index: { fileCount: this.indexFileCount, byteCount: this.indexByteCount },
|
|
482
|
+
sources: this.sources.map((x, i) => ({
|
|
483
|
+
debugName: x.source.getDebugName(),
|
|
484
|
+
fileCount: this.sourceFileCounts[i],
|
|
485
|
+
byteCount: this.sourceByteCounts[i],
|
|
486
|
+
})).filter((x, i) => this.isLive(i)),
|
|
487
|
+
readerDiskLimit: this.config?.readerDiskLimit,
|
|
488
|
+
syncing: [...this.syncActivities],
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Walks the whole index for exact totals - more expensive than getSyncProgress, but immune to any drift in the maintained counters (and loads the index first, so it's never cold zeros). */
|
|
493
|
+
public async computeIndexTotals(): Promise<{
|
|
494
|
+
fileCount: number;
|
|
495
|
+
byteCount: number;
|
|
496
|
+
sources: { debugName: string; fileCount: number; byteCount: number }[];
|
|
497
|
+
}> {
|
|
498
|
+
await this.init();
|
|
499
|
+
let fileCount = 0;
|
|
500
|
+
let byteCount = 0;
|
|
501
|
+
let sources = this.sources.map(x => ({ debugName: x.source.getDebugName(), fileCount: 0, byteCount: 0 }));
|
|
502
|
+
for (let entry of this.mem.values()) {
|
|
503
|
+
if (entry.size === 0) continue;
|
|
504
|
+
fileCount++;
|
|
505
|
+
byteCount += entry.size;
|
|
506
|
+
let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
507
|
+
if (slot !== undefined) {
|
|
508
|
+
sources[slot].fileCount++;
|
|
509
|
+
sources[slot].byteCount += entry.size;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { fileCount, byteCount, sources: sources.filter((x, i) => this.isLive(i)) };
|
|
324
513
|
}
|
|
325
514
|
|
|
326
515
|
/** Applies a config change to the RUNNING store: windows/routes update in place, new sources are added (their sync starts immediately), and removed sources' slots go dead (their scans stop, their index entries drop). The store survives every routine config evolution - it is never destroyed for a source-list change, only for structural flips it cannot express (rawDisk). Pending fast writes are re-capped to the new flush deadline (flushing immediately when it has already passed). */
|
|
327
|
-
public updateSources(specs: BlobSourceSpec[]): void {
|
|
516
|
+
public updateSources(specs: BlobSourceSpec[], entries?: HostedConfig[]): void {
|
|
328
517
|
if (!specs.length || specs[0].identity !== "disk") {
|
|
329
518
|
throw new Error(`updateSources expects the disk source first (identity "disk"), got ${JSON.stringify(specs.map(x => x.identity))} (store ${this.folder})`);
|
|
330
519
|
}
|
|
520
|
+
if (entries) {
|
|
521
|
+
this.entries = entries;
|
|
522
|
+
}
|
|
331
523
|
let setWindow = (i: number, window: [number, number]) => {
|
|
332
524
|
let old = this.sources[i].validWindow;
|
|
333
525
|
if (old[0] === window[0] && old[1] === window[1]) return;
|
|
@@ -392,29 +584,6 @@ export class BlobStore implements IBucketStore {
|
|
|
392
584
|
}
|
|
393
585
|
}
|
|
394
586
|
|
|
395
|
-
// The slot stays in the arrays forever (index entries reference sources by slot number); it just goes dead - loops stop, and its index entries drop (other sources' scans re-find any copy that's still reachable through the new config)
|
|
396
|
-
private removeSource(sourceIndex: number): void {
|
|
397
|
-
let state = this.sourceStates[sourceIndex];
|
|
398
|
-
let source = this.sources[sourceIndex].source;
|
|
399
|
-
state.dead = true;
|
|
400
|
-
state.stopped.stop = true;
|
|
401
|
-
state.scanComplete = true;
|
|
402
|
-
state.initialScan.resolve(undefined);
|
|
403
|
-
let sourcesListIndex = this.slotSourcesListIndexes[sourceIndex];
|
|
404
|
-
// The same URL can be another live slot (one entry per valid window) - the endpoint is still configured, so its entries stay
|
|
405
|
-
if (sourcesListIndex !== undefined && this.slotForSourcesListIndex(sourcesListIndex) === undefined) {
|
|
406
|
-
let dropped = 0;
|
|
407
|
-
for (let [key, entry] of this.mem) {
|
|
408
|
-
if (entry.sourcesListIndex !== sourcesListIndex) continue;
|
|
409
|
-
this.deleteIndexEntry(key);
|
|
410
|
-
dropped++;
|
|
411
|
-
}
|
|
412
|
-
console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
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)`);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
587
|
/** 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. */
|
|
419
588
|
public async rescanBase(): Promise<void> {
|
|
420
589
|
await this.init();
|
|
@@ -460,49 +629,181 @@ export class BlobStore implements IBucketStore {
|
|
|
460
629
|
tally.new++;
|
|
461
630
|
}
|
|
462
631
|
}
|
|
463
|
-
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)}`);
|
|
632
|
+
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)}`);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Large uploads stream onto the local disk source directly (they may not fit in memory)
|
|
636
|
+
public async startLargeUpload(config?: { path?: string; lastModified?: number }): Promise<string> {
|
|
637
|
+
await this.init();
|
|
638
|
+
if (config?.path) {
|
|
639
|
+
await this.assertMutable(config.path, config.lastModified || Date.now());
|
|
640
|
+
}
|
|
641
|
+
return await this.getDiskSource().disk.startLargeUpload();
|
|
642
|
+
}
|
|
643
|
+
public async appendLargeUpload(config: { id: string; data: Buffer }): Promise<void> {
|
|
644
|
+
await this.getDiskSource().disk.appendLargeUpload(config.id, config.data);
|
|
645
|
+
}
|
|
646
|
+
public async finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void> {
|
|
647
|
+
let { disk, sourceIndex } = this.getDiskSource();
|
|
648
|
+
await disk.finishLargeUpload(config.id, config.path, config.lastModified);
|
|
649
|
+
this.overlay.delete(config.path);
|
|
650
|
+
// includeTombstones: a zero-byte upload is still a real file whose index entry must be written
|
|
651
|
+
let info = await disk.getInfo(config.path, { includeTombstones: true });
|
|
652
|
+
if (info) {
|
|
653
|
+
this.setIndexEntry(config.path, { writeTime: info.writeTime, size: info.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
public async cancelLargeUpload(config: { id: string }): Promise<void> {
|
|
657
|
+
await this.getDiskSource().disk.cancelLargeUpload(config.id);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// #endregion
|
|
661
|
+
|
|
662
|
+
// #region Internals
|
|
663
|
+
|
|
664
|
+
private stopped = { stop: false };
|
|
665
|
+
|
|
666
|
+
// The index's BulkDatabase2 files live under <folder>/index. "blobIndex2": the "blobIndex" generation persisted sources-array positions as the holding source, which are not stable across runs - its entries are unusable, so it is simply never read again.
|
|
667
|
+
private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex2", noopReactiveDeps, async (p: string) => {
|
|
668
|
+
let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(path.join(this.folder, "index"));
|
|
669
|
+
for (let part of p.split("/")) {
|
|
670
|
+
if (part) base = await base.getDirectoryHandle(part, { create: true });
|
|
671
|
+
}
|
|
672
|
+
return wrapHandle(base);
|
|
673
|
+
});
|
|
674
|
+
// 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).
|
|
675
|
+
private mem = new Map<string, IndexEntry>();
|
|
676
|
+
// 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.
|
|
677
|
+
private indexFileCount = 0;
|
|
678
|
+
private indexByteCount = 0;
|
|
679
|
+
// The same totals per holding source (index 0 = our disk, which readerDiskLimit bounds)
|
|
680
|
+
private sourceFileCounts = this.sources.map(() => 0);
|
|
681
|
+
private sourceByteCounts = this.sources.map(() => 0);
|
|
682
|
+
// Background scans / full syncs currently in progress (a Set - one source can have a change poll's full sync and a rescan overlapping)
|
|
683
|
+
private syncActivities = new Set<SyncActivity>();
|
|
684
|
+
private dirty = new Map<string, IndexEntry | undefined>();
|
|
685
|
+
private overlay = new Map<string, OverlayEntry>();
|
|
686
|
+
private sourceStates = this.sources.map(() => newSourceState());
|
|
687
|
+
private syncStarted = false;
|
|
688
|
+
private entries = this.config?.entries || [];
|
|
689
|
+
// The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
|
|
690
|
+
private sourcesList = new SourcesList(path.join(this.folder, "index", "sourcesList.txt"));
|
|
691
|
+
// Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
|
|
692
|
+
private slotSourcesListIndexes: number[] = [];
|
|
693
|
+
private slotRegistrations: Promise<void>[] = [];
|
|
694
|
+
|
|
695
|
+
private isLive(sourceIndex: number): boolean {
|
|
696
|
+
return !!this.sources[sourceIndex] && !this.sourceStates[sourceIndex].dead;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private registerSlot(slot: number): Promise<void> {
|
|
700
|
+
let existing = this.slotRegistrations[slot];
|
|
701
|
+
if (existing) return existing;
|
|
702
|
+
let registration = this.sourcesList.ensure(this.sources[slot].url).then(index => {
|
|
703
|
+
this.slotSourcesListIndexes[slot] = index;
|
|
704
|
+
});
|
|
705
|
+
this.slotRegistrations[slot] = registration;
|
|
706
|
+
return registration;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// The persistent sourcesListIndex of a slot - only valid once the slot's registration resolved (init and runSourceSync guarantee that before any indexing happens)
|
|
710
|
+
private sourcesListIndexOfSlot(slot: number): number {
|
|
711
|
+
let index = this.slotSourcesListIndexes[slot];
|
|
712
|
+
if (index === undefined) {
|
|
713
|
+
throw new Error(`Source slot ${slot} (${this.sources[slot]?.url}) has no registered sourcesListIndex yet (store ${this.folder})`);
|
|
714
|
+
}
|
|
715
|
+
return index;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// 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).
|
|
719
|
+
private slotForSourcesListIndex(sourcesListIndex: number): number | undefined {
|
|
720
|
+
for (let i = 0; i < this.slotSourcesListIndexes.length; i++) {
|
|
721
|
+
if (this.slotSourcesListIndexes[i] === sourcesListIndex && this.isLive(i)) return i;
|
|
722
|
+
}
|
|
723
|
+
return undefined;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// 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
|
|
727
|
+
private async getEntryHolder(entry: IndexEntry): Promise<IArchives | undefined> {
|
|
728
|
+
let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
729
|
+
if (slot !== undefined) return this.sources[slot].source;
|
|
730
|
+
let url = this.sourcesList.getUrl(entry.sourcesListIndex) || await this.sourcesList.getUrlReloading(entry.sourcesListIndex);
|
|
731
|
+
if (!url) return undefined;
|
|
732
|
+
return this.config?.resolveSourceUrl?.(url);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private async loadIndex(): Promise<void> {
|
|
736
|
+
let [writeTimes, sizes, sourcesListIndexes] = await Promise.all([
|
|
737
|
+
this.index.getColumn("writeTime"),
|
|
738
|
+
this.index.getColumn("size"),
|
|
739
|
+
this.index.getColumn("sourcesListIndex"),
|
|
740
|
+
]);
|
|
741
|
+
let sizeMap = new Map(sizes.map(x => [x.key, x.value]));
|
|
742
|
+
let sourcesListIndexMap = new Map(sourcesListIndexes.map(x => [x.key, x.value]));
|
|
743
|
+
for (let entry of writeTimes) {
|
|
744
|
+
let size = sizeMap.get(entry.key);
|
|
745
|
+
let sourcesListIndex = sourcesListIndexMap.get(entry.key);
|
|
746
|
+
// Explicit checks, as 0 is a valid size and a valid sourcesListIndex
|
|
747
|
+
if (size === undefined || sourcesListIndex === undefined) continue;
|
|
748
|
+
// 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
|
|
749
|
+
if (entry.key === ROUTING_FILE) {
|
|
750
|
+
sourcesListIndex = this.sourcesListIndexOfSlot(0);
|
|
751
|
+
}
|
|
752
|
+
let full: IndexEntry = { writeTime: entry.value, size, sourcesListIndex, changedAt: entry.time, lastAccess: entry.time };
|
|
753
|
+
this.mem.set(entry.key, full);
|
|
754
|
+
this.countEntry(full, 1);
|
|
755
|
+
}
|
|
464
756
|
}
|
|
465
757
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
debugName: x.source.getDebugName(),
|
|
477
|
-
fileCount: this.sourceFileCounts[i],
|
|
478
|
-
byteCount: this.sourceByteCounts[i],
|
|
479
|
-
})).filter((x, i) => this.isLive(i)),
|
|
480
|
-
readerDiskLimit: this.config?.readerDiskLimit,
|
|
481
|
-
syncing: [...this.syncActivities],
|
|
482
|
-
};
|
|
758
|
+
private countEntry(entry: IndexEntry | undefined, direction: number): void {
|
|
759
|
+
if (!entry || entry.size === 0) return;
|
|
760
|
+
this.indexFileCount += direction;
|
|
761
|
+
this.indexByteCount += entry.size * direction;
|
|
762
|
+
// Entries can reference a source no longer configured (readable via getEntryHolder, but with no slot to count under)
|
|
763
|
+
let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
764
|
+
if (slot !== undefined) {
|
|
765
|
+
this.sourceFileCounts[slot] += direction;
|
|
766
|
+
this.sourceByteCounts[slot] += entry.size * direction;
|
|
767
|
+
}
|
|
483
768
|
}
|
|
484
769
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
let
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
770
|
+
private setIndexEntry(key: string, entry: { writeTime: number; size: number; sourcesListIndex: number }): void {
|
|
771
|
+
let full: IndexEntry = { ...entry, changedAt: Date.now(), lastAccess: Date.now() };
|
|
772
|
+
this.countEntry(this.mem.get(key), -1);
|
|
773
|
+
this.countEntry(full, 1);
|
|
774
|
+
this.mem.set(key, full);
|
|
775
|
+
this.dirty.set(key, full);
|
|
776
|
+
this.config?.onIndexChanged?.(key);
|
|
777
|
+
}
|
|
778
|
+
private deleteIndexEntry(key: string): void {
|
|
779
|
+
let existing = this.mem.get(key);
|
|
780
|
+
if (!existing) return;
|
|
781
|
+
this.countEntry(existing, -1);
|
|
782
|
+
this.mem.delete(key);
|
|
783
|
+
this.dirty.set(key, undefined);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// The slot stays in the arrays forever (index entries reference sources by slot number); it just goes dead - loops stop, and its index entries drop (other sources' scans re-find any copy that's still reachable through the new config)
|
|
787
|
+
private removeSource(sourceIndex: number): void {
|
|
788
|
+
let state = this.sourceStates[sourceIndex];
|
|
789
|
+
let source = this.sources[sourceIndex].source;
|
|
790
|
+
state.dead = true;
|
|
791
|
+
state.stopped.stop = true;
|
|
792
|
+
state.scanComplete = true;
|
|
793
|
+
state.initialScan.resolve(undefined);
|
|
794
|
+
let sourcesListIndex = this.slotSourcesListIndexes[sourceIndex];
|
|
795
|
+
// The same URL can be another live slot (one entry per valid window) - the endpoint is still configured, so its entries stay
|
|
796
|
+
if (sourcesListIndex !== undefined && this.slotForSourcesListIndex(sourcesListIndex) === undefined) {
|
|
797
|
+
let dropped = 0;
|
|
798
|
+
for (let [key, entry] of this.mem) {
|
|
799
|
+
if (entry.sourcesListIndex !== sourcesListIndex) continue;
|
|
800
|
+
this.deleteIndexEntry(key);
|
|
801
|
+
dropped++;
|
|
503
802
|
}
|
|
803
|
+
console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
|
|
804
|
+
return;
|
|
504
805
|
}
|
|
505
|
-
|
|
806
|
+
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)`);
|
|
506
807
|
}
|
|
507
808
|
|
|
508
809
|
private async flushIndex(): Promise<void> {
|
|
@@ -522,6 +823,26 @@ export class BlobStore implements IBucketStore {
|
|
|
522
823
|
if (deletes.length) await this.index.deleteBatch(deletes);
|
|
523
824
|
}
|
|
524
825
|
|
|
826
|
+
// ── validation (from this store's own routing entries) ──
|
|
827
|
+
|
|
828
|
+
private async assertMutable(key: string, writeTime: number): Promise<void> {
|
|
829
|
+
if (!this.entries.length) return;
|
|
830
|
+
let self = selectEntryAt(this.entries, writeTime, getRoute(key));
|
|
831
|
+
if (!self?.immutable) return;
|
|
832
|
+
if (await this.getInfo({ path: key })) {
|
|
833
|
+
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})`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// 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
|
|
838
|
+
private assertInternalWriteAccepted(key: string, writeTime: number, route: number): void {
|
|
839
|
+
if (!this.entries.length) return;
|
|
840
|
+
let covered = this.entries.some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
|
|
841
|
+
if (!covered) {
|
|
842
|
+
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.entries.map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))} (store ${this.folder})`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
525
846
|
// ── synchronization ──
|
|
526
847
|
|
|
527
848
|
private async runSourceSync(sourceIndex: number): Promise<void> {
|
|
@@ -862,97 +1183,23 @@ export class BlobStore implements IBucketStore {
|
|
|
862
1183
|
return this.mem.get(key);
|
|
863
1184
|
}
|
|
864
1185
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
public async get(key: string, config?: GetConfig): Promise<Buffer | undefined> {
|
|
868
|
-
let result = await this.get2(key, config);
|
|
869
|
-
return result && result.data || undefined;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
public async get2(key: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
1186
|
+
/** 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 writes still sitting in the overlay are invisible here; the caller re-finds them after our flush. */
|
|
1187
|
+
private async getInternal2(config: { path: string; range?: { start: number; end: number }; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
873
1188
|
await this.init();
|
|
874
|
-
|
|
875
|
-
let
|
|
876
|
-
if (
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
let size = data.length;
|
|
881
|
-
if (range) {
|
|
882
|
-
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
883
|
-
}
|
|
884
|
-
return { data, writeTime: overlayEntry.t, size };
|
|
885
|
-
}
|
|
886
|
-
let entry = await this.getIndexEntry(key);
|
|
887
|
-
if (!entry) return undefined;
|
|
888
|
-
if (entry.size === 0) return undefined;
|
|
889
|
-
entry.lastAccess = Date.now();
|
|
890
|
-
let holderArchives = await this.getEntryHolder(entry);
|
|
891
|
-
let result: { data: Buffer; writeTime: number; size: number } | undefined;
|
|
892
|
-
let holderError: Error | undefined;
|
|
893
|
-
if (holderArchives) {
|
|
894
|
-
try {
|
|
895
|
-
let answer = await holderArchives.get2(key, { range, internal: true });
|
|
896
|
-
// Empty data counts as absent: an empty file IS a deletion (a tombstone stored on b2/disk), never content. NOTE: a ranged read of a real file can legitimately be empty (range past EOF), so only unranged emptiness means absent.
|
|
897
|
-
if (answer && answer.data && (answer.data.length || range && answer.size)) {
|
|
898
|
-
result = { data: answer.data, writeTime: answer.writeTime, size: answer.size };
|
|
899
|
-
}
|
|
900
|
-
} catch (e) {
|
|
901
|
-
holderError = e as Error;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
if (result) {
|
|
905
|
-
// Ranged reads can't populate a cache (they're partial)
|
|
906
|
-
if (this.slotForSourcesListIndex(entry.sourcesListIndex) !== 0 && !range) {
|
|
907
|
-
await this.cacheRead(key, result);
|
|
1189
|
+
// 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.
|
|
1190
|
+
let result = await this.getDiskSource().disk.get2(config.path, { range: config.range, includeTombstones: config.includeTombstones });
|
|
1191
|
+
if (!result || !result.data) {
|
|
1192
|
+
if (config.includeTombstones) {
|
|
1193
|
+
let entry = this.mem.get(config.path);
|
|
1194
|
+
if (entry && !entry.size) return { data: Buffer.alloc(0), writeTime: entry.writeTime, size: 0 };
|
|
908
1195
|
}
|
|
909
|
-
return result;
|
|
910
|
-
}
|
|
911
|
-
// The routing file is only ever read off our own disk - falling back to another source's copy would synchronize it between nodes through the read path, which it never is
|
|
912
|
-
if (key === ROUTING_FILE) {
|
|
913
|
-
if (holderError) throw holderError;
|
|
914
1196
|
return undefined;
|
|
915
1197
|
}
|
|
916
|
-
// The holder is down or lost the file. ANY other source's copy beats no value - even an OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
|
|
917
|
-
let holderSlot = this.slotForSourcesListIndex(entry.sourcesListIndex);
|
|
918
|
-
for (let i = 0; i < this.sources.length; i++) {
|
|
919
|
-
if (i === holderSlot || !this.isLive(i)) continue;
|
|
920
|
-
let fallback: { data: Buffer; writeTime: number; size: number } | undefined;
|
|
921
|
-
try {
|
|
922
|
-
let answer = await this.sources[i].source.get2(key, { internal: true });
|
|
923
|
-
// Empty data counts as absent - a tombstone, not content (this read is unranged, so emptiness is unambiguous)
|
|
924
|
-
if (answer && answer.data && answer.data.length) {
|
|
925
|
-
fallback = { data: answer.data, writeTime: answer.writeTime, size: answer.size };
|
|
926
|
-
}
|
|
927
|
-
} catch {
|
|
928
|
-
continue;
|
|
929
|
-
}
|
|
930
|
-
if (!fallback) continue;
|
|
931
|
-
await this.cacheRead(key, fallback);
|
|
932
|
-
let data = fallback.data;
|
|
933
|
-
if (range) {
|
|
934
|
-
data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
|
|
935
|
-
}
|
|
936
|
-
return { data, writeTime: fallback.writeTime, size: fallback.size };
|
|
937
|
-
}
|
|
938
|
-
if (holderError) throw holderError;
|
|
939
|
-
// The holder answered "not there" and no other source has it either: the entry was stale
|
|
940
|
-
this.deleteIndexEntry(key);
|
|
941
|
-
return undefined;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
/** 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 writes still sitting in the overlay are invisible here; the caller re-finds them after our flush. */
|
|
945
|
-
public async getInternal2(key: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
946
|
-
await this.init();
|
|
947
|
-
let result = await this.getDiskSource().disk.get2(key, { range: config?.range });
|
|
948
|
-
if (!result || !result.data) return undefined;
|
|
949
|
-
// An empty file on disk is a tombstone, not content (a ranged read of a real file can be legitimately empty though - range past EOF)
|
|
950
|
-
if (!result.data.length && !(config?.range && result.size)) return undefined;
|
|
951
1198
|
return { data: result.data, writeTime: result.writeTime, size: result.size };
|
|
952
1199
|
}
|
|
953
1200
|
|
|
954
|
-
/** Internal (store-to-store) write: the local disk plus our index, with NO downstream fan-out - the pushing store owns propagation, and fanning its pushes back out is how write loops between stores form.
|
|
955
|
-
|
|
1201
|
+
/** Internal (store-to-store) write: the local disk plus our index, with NO downstream fan-out - the pushing store owns propagation, and fanning its pushes back out is how write loops between stores form. Only-take-latest still applies here. */
|
|
1202
|
+
private async setInternal(key: string, data: Buffer, config: { lastModified: number }): Promise<void> {
|
|
956
1203
|
await this.init();
|
|
957
1204
|
assertValidLastModified(config.lastModified);
|
|
958
1205
|
let overlayEntry = this.overlay.get(key);
|
|
@@ -974,33 +1221,20 @@ export class BlobStore implements IBucketStore {
|
|
|
974
1221
|
this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
|
|
975
1222
|
}
|
|
976
1223
|
|
|
977
|
-
public async set(key: string, data: Buffer, config?: WriteConfig): Promise<string> {
|
|
978
|
-
if (!data.length) {
|
|
979
|
-
throw new Error(`set was called with an empty buffer for ${JSON.stringify(key)} (store ${this.folder}): an empty file IS a deletion in this system and would read back as missing - call del instead`);
|
|
980
|
-
}
|
|
981
|
-
return await this.setOrDelete(key, data, config);
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
public async del(key: string, config?: WriteConfig): Promise<void> {
|
|
985
|
-
// Deletes are tombstone writes (an empty file IS a missing file): the size-0 index entry is ordered by write time like any other write, propagates through synchronization, and expires after TOMBSTONE_EXPIRY
|
|
986
|
-
await this.setOrDelete(key, Buffer.alloc(0), config);
|
|
987
|
-
}
|
|
988
|
-
|
|
989
1224
|
// 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
|
|
990
|
-
private async setOrDelete(key: string, data: Buffer, config?:
|
|
991
|
-
await this.init();
|
|
1225
|
+
private async setOrDelete(key: string, data: Buffer, config: { fast?: boolean; writeDelay?: number; lastModified?: number }): Promise<void> {
|
|
992
1226
|
this.config?.onWriteCounted?.("original", data.length);
|
|
993
|
-
let lastModified = config
|
|
1227
|
+
let lastModified = config.lastModified;
|
|
994
1228
|
if (lastModified) {
|
|
995
1229
|
assertValidLastModified(lastModified);
|
|
996
1230
|
let overlayEntry = this.overlay.get(key);
|
|
997
1231
|
let entry = this.mem.get(key);
|
|
998
1232
|
let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
|
|
999
1233
|
// An older write never overwrites a newer one (see IArchives.set)
|
|
1000
|
-
if (lastModified < currentTime) return
|
|
1234
|
+
if (lastModified < currentTime) return;
|
|
1001
1235
|
}
|
|
1002
1236
|
let writeTime = lastModified || Date.now();
|
|
1003
|
-
if (config
|
|
1237
|
+
if (config.fast) {
|
|
1004
1238
|
// A writeDelay of zero is a real choice (no delay at all), so only an omitted delay gets the default
|
|
1005
1239
|
let writeDelay = config.writeDelay;
|
|
1006
1240
|
if (writeDelay === undefined) {
|
|
@@ -1011,15 +1245,11 @@ export class BlobStore implements IBucketStore {
|
|
|
1011
1245
|
if (writeDelay > 0 && Date.now() < deadline) {
|
|
1012
1246
|
let flushAt = Math.min(Date.now() + writeDelay, deadline);
|
|
1013
1247
|
this.overlay.set(key, { data, t: writeTime, flushAt });
|
|
1014
|
-
return
|
|
1248
|
+
return;
|
|
1015
1249
|
}
|
|
1016
|
-
this.overlay.delete(key);
|
|
1017
|
-
await this.writeToSources(key, data, writeTime);
|
|
1018
|
-
return key;
|
|
1019
1250
|
}
|
|
1020
1251
|
this.overlay.delete(key);
|
|
1021
1252
|
await this.writeToSources(key, data, writeTime);
|
|
1022
|
-
return key;
|
|
1023
1253
|
}
|
|
1024
1254
|
|
|
1025
1255
|
private getWritableSources(config?: { ignoreWindow?: boolean }): number[] {
|
|
@@ -1066,83 +1296,6 @@ export class BlobStore implements IBucketStore {
|
|
|
1066
1296
|
}
|
|
1067
1297
|
}
|
|
1068
1298
|
|
|
1069
|
-
public async getInfo(key: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number } | undefined> {
|
|
1070
|
-
await this.init();
|
|
1071
|
-
let overlayEntry = this.overlay.get(key);
|
|
1072
|
-
if (overlayEntry) {
|
|
1073
|
-
if (!overlayEntry.data.length && !config?.includeTombstones) return undefined;
|
|
1074
|
-
return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
|
|
1075
|
-
}
|
|
1076
|
-
let entry = await this.getIndexEntry(key);
|
|
1077
|
-
if (!entry) return undefined;
|
|
1078
|
-
if (!entry.size && !config?.includeTombstones) return undefined;
|
|
1079
|
-
return { writeTime: entry.writeTime, size: entry.size };
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
public async findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
|
|
1083
|
-
await this.init();
|
|
1084
|
-
await this.waitForRequiredScans();
|
|
1085
|
-
let infos = new Map<string, ArchiveFileInfo>();
|
|
1086
|
-
for (let [key, entry] of this.mem) {
|
|
1087
|
-
if (!key.startsWith(prefix)) continue;
|
|
1088
|
-
// Tombstones are missing files, so listings hide them
|
|
1089
|
-
if (entry.size === 0) continue;
|
|
1090
|
-
infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
|
|
1091
|
-
}
|
|
1092
|
-
for (let [key, overlayEntry] of this.overlay) {
|
|
1093
|
-
if (!key.startsWith(prefix)) continue;
|
|
1094
|
-
if (overlayEntry.data.length === 0) {
|
|
1095
|
-
infos.delete(key);
|
|
1096
|
-
continue;
|
|
1097
|
-
}
|
|
1098
|
-
infos.set(key, { path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
|
|
1099
|
-
}
|
|
1100
|
-
let files = applyFindInfoShape(Array.from(infos.values()), prefix, config);
|
|
1101
|
-
sort(files, x => x.path);
|
|
1102
|
-
return files;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
// All files changed after config.time — fast, straight from the in-memory 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 tombstone entries — that's how they propagate to stores syncing from us. config.routes lets a store syncing a partial shard ask for just its slice.
|
|
1106
|
-
public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
|
|
1107
|
-
await this.init();
|
|
1108
|
-
await this.waitForRequiredScans();
|
|
1109
|
-
let inRoutes = (key: string) => !config.routes || config.routes.some(route => routeContains(route, getRoute(key)));
|
|
1110
|
-
let files: ArchiveFileInfo[] = [];
|
|
1111
|
-
for (let [key, entry] of this.mem) {
|
|
1112
|
-
if (entry.changedAt <= config.time) continue;
|
|
1113
|
-
if (this.overlay.has(key)) continue;
|
|
1114
|
-
if (!inRoutes(key)) continue;
|
|
1115
|
-
files.push({ path: key, createTime: entry.writeTime, size: entry.size });
|
|
1116
|
-
}
|
|
1117
|
-
for (let [key, overlayEntry] of this.overlay) {
|
|
1118
|
-
if (overlayEntry.t <= config.time) continue;
|
|
1119
|
-
if (!inRoutes(key)) continue;
|
|
1120
|
-
files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
|
|
1121
|
-
}
|
|
1122
|
-
sort(files, x => x.path);
|
|
1123
|
-
return files;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
1127
|
-
await this.init();
|
|
1128
|
-
return {
|
|
1129
|
-
allScansComplete: this.sourceStates.every(x => x.scanComplete),
|
|
1130
|
-
indexSize: this.mem.size,
|
|
1131
|
-
sources: this.sources.map((x, i) => ({
|
|
1132
|
-
debugName: x.source.getDebugName(),
|
|
1133
|
-
validWindow: x.validWindow,
|
|
1134
|
-
route: x.route,
|
|
1135
|
-
noFullSync: x.noFullSync,
|
|
1136
|
-
supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
|
|
1137
|
-
initialScanComplete: this.sourceStates[i].scanComplete,
|
|
1138
|
-
scannedCount: this.sourceStates[i].scannedCount,
|
|
1139
|
-
})).filter((x, i) => this.isLive(i)),
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
// ── large uploads ──
|
|
1144
|
-
// Large uploads stream onto the local disk source directly (they may not fit in memory)
|
|
1145
|
-
|
|
1146
1299
|
private getDiskSource(): { disk: ArchivesDisk; sourceIndex: number } {
|
|
1147
1300
|
for (let i = 0; i < this.sources.length; i++) {
|
|
1148
1301
|
let source = this.sources[i].source;
|
|
@@ -1150,26 +1303,6 @@ export class BlobStore implements IBucketStore {
|
|
|
1150
1303
|
}
|
|
1151
1304
|
throw new Error(`Large uploads require an ArchivesDisk source, and this store has none (store ${this.folder})`);
|
|
1152
1305
|
}
|
|
1153
|
-
public async startLargeUpload(): Promise<string> {
|
|
1154
|
-
await this.init();
|
|
1155
|
-
return await this.getDiskSource().disk.startLargeUpload();
|
|
1156
|
-
}
|
|
1157
|
-
public async appendLargeUpload(id: string, data: Buffer): Promise<void> {
|
|
1158
|
-
await this.getDiskSource().disk.appendLargeUpload(id, data);
|
|
1159
|
-
}
|
|
1160
|
-
public async finishLargeUpload(id: string, key: string, lastModified?: number): Promise<void> {
|
|
1161
|
-
let { disk, sourceIndex } = this.getDiskSource();
|
|
1162
|
-
await disk.finishLargeUpload(id, key, lastModified);
|
|
1163
|
-
this.overlay.delete(key);
|
|
1164
|
-
// includeTombstones: a zero-byte upload is still a real file whose index entry must be written
|
|
1165
|
-
let info = await disk.getInfo(key, { includeTombstones: true });
|
|
1166
|
-
if (info) {
|
|
1167
|
-
this.setIndexEntry(key, { writeTime: info.writeTime, size: info.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
public async cancelLargeUpload(id: string): Promise<void> {
|
|
1171
|
-
await this.getDiskSource().disk.cancelLargeUpload(id);
|
|
1172
|
-
}
|
|
1173
1306
|
|
|
1174
1307
|
private async flushOverlay(force?: boolean): Promise<void> {
|
|
1175
1308
|
let now = Date.now();
|
|
@@ -1251,4 +1384,6 @@ export class BlobStore implements IBucketStore {
|
|
|
1251
1384
|
}
|
|
1252
1385
|
}
|
|
1253
1386
|
}
|
|
1387
|
+
|
|
1388
|
+
// #endregion
|
|
1254
1389
|
}
|