sliftutils 1.7.84 → 1.7.86
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 +11 -6
- package/misc/dist/types.ts.cache +31 -0
- package/package.json +1 -1
- package/storage/IArchives.d.ts +4 -2
- package/storage/IArchives.ts +9 -3
- package/storage/dist/IArchives.ts.cache +8 -3
- package/storage/remoteStorage/blobStore.d.ts +5 -2
- package/storage/remoteStorage/blobStore.ts +82 -41
- package/storage/remoteStorage/dist/blobStore.ts.cache +76 -41
- package/storage/remoteStorage/dist/storageServerState.ts.cache +14 -8
- package/storage/remoteStorage/dist/storePlan.ts.cache +14 -5
- package/storage/remoteStorage/storageServerState.d.ts +1 -1
- package/storage/remoteStorage/storageServerState.ts +13 -6
- package/storage/remoteStorage/storePlan.d.ts +1 -1
- package/storage/remoteStorage/storePlan.ts +13 -4
|
@@ -4,8 +4,8 @@ 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, FindConfig, HostedConfig, assertValidLastModified,
|
|
8
|
-
windowAcceptsWrites, SyncActivity, FULL_ROUTE, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, copyArchiveFile,
|
|
7
|
+
IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, SourceConfig, assertValidLastModified,
|
|
8
|
+
windowAcceptsWrites, windowsAcceptWrites, 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";
|
|
@@ -18,7 +18,10 @@ import { SourcesList } from "./sourcesList";
|
|
|
18
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.
|
|
19
19
|
|
|
20
20
|
export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
|
|
21
|
-
|
|
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;
|
|
22
25
|
// 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
|
|
23
26
|
export const WINDOW_END_FLUSH_MARGIN = timeInMinute * 5;
|
|
24
27
|
// Index changes are buffered in memory and written to the BulkDatabase2 in batches
|
|
@@ -116,7 +119,12 @@ type OverlayEntry = {
|
|
|
116
119
|
// A zero-length buffer is a pending delete (tombstone)
|
|
117
120
|
data: Buffer;
|
|
118
121
|
t: number;
|
|
119
|
-
|
|
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;
|
|
120
128
|
};
|
|
121
129
|
|
|
122
130
|
// One row of the BulkDatabase2 index (key is the file path)
|
|
@@ -158,11 +166,13 @@ export type BlobSourceSpec = {
|
|
|
158
166
|
identity: string;
|
|
159
167
|
// See ArchivesSource.url
|
|
160
168
|
url: string;
|
|
161
|
-
|
|
169
|
+
validWindows: [number, number][];
|
|
162
170
|
route?: [number, number];
|
|
163
171
|
noFullSync?: boolean;
|
|
164
172
|
// See ArchivesSource.intermediate
|
|
165
173
|
intermediate?: boolean;
|
|
174
|
+
// See ArchivesSource.sourceConfig (absent for the base disk source)
|
|
175
|
+
sourceConfig?: SourceConfig;
|
|
166
176
|
// Only called for sources that don't match an existing live slot
|
|
167
177
|
create: () => IArchives;
|
|
168
178
|
};
|
|
@@ -460,7 +470,7 @@ export class BlobStore implements IBucketStore {
|
|
|
460
470
|
indexSize: this.mem.size,
|
|
461
471
|
sources: this.sources.map((x, i) => ({
|
|
462
472
|
debugName: x.source.getDebugName(),
|
|
463
|
-
|
|
473
|
+
validWindows: x.validWindows,
|
|
464
474
|
route: x.route,
|
|
465
475
|
noFullSync: x.noFullSync,
|
|
466
476
|
supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
|
|
@@ -520,14 +530,14 @@ export class BlobStore implements IBucketStore {
|
|
|
520
530
|
if (entries) {
|
|
521
531
|
this.entries = entries;
|
|
522
532
|
}
|
|
523
|
-
let
|
|
524
|
-
let old = this.sources[i].
|
|
525
|
-
if (old
|
|
526
|
-
console.log(`Valid
|
|
527
|
-
this.sources[i].
|
|
533
|
+
let setWindows = (i: number, windows: [number, number][]) => {
|
|
534
|
+
let old = this.sources[i].validWindows;
|
|
535
|
+
if (JSON.stringify(old) === JSON.stringify(windows)) return;
|
|
536
|
+
console.log(`Valid windows changed for ${this.sources[i].source.getDebugName()} (store ${this.folder}): ${JSON.stringify(old)} -> ${JSON.stringify(windows)}`);
|
|
537
|
+
this.sources[i].validWindows = windows;
|
|
528
538
|
};
|
|
529
|
-
|
|
530
|
-
// Live slots pair with specs by identity,
|
|
539
|
+
setWindows(0, specs[0].validWindows);
|
|
540
|
+
// Live slots pair with specs by identity. Each endpoint is now ONE spec carrying all its windows, so this is a 1:1 pairing; any leftover duplicate slots from before (an endpoint that used to be split into several slots) go unmatched below and are collapsed away.
|
|
531
541
|
let liveByIdentity = new Map<string, number[]>();
|
|
532
542
|
let originalLength = this.sources.length;
|
|
533
543
|
for (let i = 1; i < originalLength; i++) {
|
|
@@ -546,17 +556,18 @@ export class BlobStore implements IBucketStore {
|
|
|
546
556
|
let slot = liveByIdentity.get(spec.identity)?.shift();
|
|
547
557
|
if (slot !== undefined) {
|
|
548
558
|
matched.add(slot);
|
|
549
|
-
|
|
559
|
+
setWindows(slot, spec.validWindows);
|
|
550
560
|
let existing = this.sources[slot];
|
|
551
561
|
if (JSON.stringify(existing.route) !== JSON.stringify(spec.route)) {
|
|
552
562
|
console.log(`Route changed for ${existing.source.getDebugName()} (store ${this.folder}): ${JSON.stringify(existing.route)} -> ${JSON.stringify(spec.route)}`);
|
|
553
563
|
existing.route = spec.route;
|
|
554
564
|
}
|
|
555
565
|
existing.noFullSync = spec.noFullSync;
|
|
566
|
+
existing.sourceConfig = spec.sourceConfig;
|
|
556
567
|
continue;
|
|
557
568
|
}
|
|
558
569
|
let source = spec.create();
|
|
559
|
-
this.sources.push({ source, url: spec.url,
|
|
570
|
+
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 });
|
|
560
571
|
this.sourceStates.push(newSourceState());
|
|
561
572
|
this.sourceFileCounts.push(0);
|
|
562
573
|
this.sourceByteCounts.push(0);
|
|
@@ -569,11 +580,12 @@ export class BlobStore implements IBucketStore {
|
|
|
569
580
|
if (!this.isLive(i) || matched.has(i)) continue;
|
|
570
581
|
this.removeSource(i);
|
|
571
582
|
}
|
|
572
|
-
let deadline = this.
|
|
583
|
+
let deadline = this.baseWriteWindowEnd() - WINDOW_END_FLUSH_MARGIN;
|
|
573
584
|
let recapped = 0;
|
|
574
585
|
for (let entry of this.overlay.values()) {
|
|
575
|
-
if (entry.
|
|
576
|
-
entry.
|
|
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);
|
|
577
589
|
recapped++;
|
|
578
590
|
}
|
|
579
591
|
if (recapped) {
|
|
@@ -909,10 +921,10 @@ export class BlobStore implements IBucketStore {
|
|
|
909
921
|
}
|
|
910
922
|
}
|
|
911
923
|
|
|
912
|
-
// An intermediate is a deploy switchover's temporary alternate port: once its window is past, the port is gone for good, so scanning it (or retrying a failed scan) can never succeed - it would just log errors forever
|
|
924
|
+
// An intermediate is a deploy switchover's temporary alternate port: once its window is past, the port is gone for good, so scanning it (or retrying a failed scan) can never succeed - it would just log errors forever. (Dead only once EVERY window has ended - an intermediate normally has just one.)
|
|
913
925
|
private isDeadIntermediate(sourceIndex: number): boolean {
|
|
914
|
-
let { intermediate,
|
|
915
|
-
return !!intermediate &&
|
|
926
|
+
let { intermediate, validWindows } = this.sources[sourceIndex];
|
|
927
|
+
return !!intermediate && validWindows.every(w => w[1] <= Date.now());
|
|
916
928
|
}
|
|
917
929
|
|
|
918
930
|
// Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the source's listing (path -> write time), which reconcileSource uses for the push direction.
|
|
@@ -975,9 +987,9 @@ export class BlobStore implements IBucketStore {
|
|
|
975
987
|
|
|
976
988
|
// The push direction of synchronization: everything we know that the source is missing (or holds an older copy of) is written to it — including deletions, as tombstone writes. This is what heals a source whose background writes failed (e.g. it was down): the next scan sees what's missing and re-sends it. A failing file is skipped, not fatal (immutable targets are handled by forceSetImmutable, and one unreadable value must not stop the rest of the pass) - only a run of consecutive failures (the source itself is down) aborts until the next scan cycle.
|
|
977
989
|
private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
|
|
978
|
-
let { source,
|
|
990
|
+
let { source, validWindows, route } = this.sources[sourceIndex];
|
|
979
991
|
let state = this.sourceStates[sourceIndex];
|
|
980
|
-
let acceptsWrites =
|
|
992
|
+
let acceptsWrites = windowsAcceptWrites(validWindows);
|
|
981
993
|
let targetSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
|
|
982
994
|
let pushed = 0;
|
|
983
995
|
let failed = 0;
|
|
@@ -1241,10 +1253,12 @@ export class BlobStore implements IBucketStore {
|
|
|
1241
1253
|
writeDelay = DEFAULT_FAST_WRITE_DELAY;
|
|
1242
1254
|
}
|
|
1243
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.
|
|
1244
|
-
let deadline = this.
|
|
1256
|
+
let deadline = this.baseWriteWindowEnd() - WINDOW_END_FLUSH_MARGIN;
|
|
1245
1257
|
if (writeDelay > 0 && Date.now() < deadline) {
|
|
1246
|
-
|
|
1247
|
-
|
|
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 });
|
|
1248
1262
|
return;
|
|
1249
1263
|
}
|
|
1250
1264
|
}
|
|
@@ -1252,17 +1266,28 @@ export class BlobStore implements IBucketStore {
|
|
|
1252
1266
|
await this.writeToSources(key, data, writeTime);
|
|
1253
1267
|
}
|
|
1254
1268
|
|
|
1269
|
+
// The end of our own (base disk) write window that CONTAINS now - the deadline fast writes must flush before, so the next window's source has 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. flush through immediately.
|
|
1270
|
+
private baseWriteWindowEnd(): number {
|
|
1271
|
+
let now = Date.now();
|
|
1272
|
+
let end = 0;
|
|
1273
|
+
for (let w of this.sources[0].validWindows) {
|
|
1274
|
+
if (w[0] <= now && now < w[1]) end = Math.max(end, w[1]);
|
|
1275
|
+
}
|
|
1276
|
+
return end;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1255
1279
|
private getWritableSources(config?: { ignoreWindow?: boolean }): number[] {
|
|
1256
1280
|
let writable: number[] = [];
|
|
1257
1281
|
for (let i = 0; i < this.sources.length; i++) {
|
|
1258
1282
|
if (!this.isLive(i)) continue;
|
|
1259
|
-
if (!config?.ignoreWindow && !
|
|
1283
|
+
if (!config?.ignoreWindow && !windowsAcceptWrites(this.sources[i].validWindows)) continue;
|
|
1260
1284
|
writable.push(i);
|
|
1261
1285
|
}
|
|
1262
1286
|
return writable;
|
|
1263
1287
|
}
|
|
1264
1288
|
|
|
1265
|
-
|
|
1289
|
+
// config lets a staged fast flush write only PART of the sources: writeBase (our own disk + index) defaults on, and downstream filters which non-base peers get the fan-out (the fast flush writes only type-"remote" peers, the slow flush only the rest). A full write (no config) does base + every route-matching peer, as before.
|
|
1290
|
+
private async writeToSources(key: string, data: Buffer, writeTime: number, config?: { writeBase?: boolean; downstream?: (source: ArchivesSource) => boolean }): Promise<void> {
|
|
1266
1291
|
// 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.
|
|
1267
1292
|
this.config?.onWriteCounted?.("flushed", data.length);
|
|
1268
1293
|
let isRouting = key === ROUTING_FILE;
|
|
@@ -1271,18 +1296,21 @@ export class BlobStore implements IBucketStore {
|
|
|
1271
1296
|
if (first === undefined) {
|
|
1272
1297
|
throw new Error(`No source accepts writes (every source's valid window is in the past), so writes cannot be stored (store ${this.folder})`);
|
|
1273
1298
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1299
|
+
if (config?.writeBase ?? true) {
|
|
1300
|
+
// 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 reconcileSource re-sends anything they missed once they come back.
|
|
1301
|
+
if (data.length === 0) {
|
|
1302
|
+
// A tombstone stores nothing on our own source - the index entry alone records it
|
|
1303
|
+
await this.sources[first].source.del(key);
|
|
1304
|
+
} else {
|
|
1305
|
+
await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
|
|
1306
|
+
}
|
|
1307
|
+
this.setIndexEntry(key, { writeTime, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(first) });
|
|
1280
1308
|
}
|
|
1281
|
-
this.setIndexEntry(key, { writeTime, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(first) });
|
|
1282
1309
|
if (isRouting) return;
|
|
1283
1310
|
let route = getRoute(key);
|
|
1284
1311
|
for (let i of writable) {
|
|
1285
1312
|
if (!routeContains(this.sources[i].route, route)) continue;
|
|
1313
|
+
if (config?.downstream && !config.downstream(this.sources[i])) continue;
|
|
1286
1314
|
// 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.
|
|
1287
1315
|
let push: Promise<unknown>;
|
|
1288
1316
|
if (data.length === 0) {
|
|
@@ -1304,15 +1332,28 @@ export class BlobStore implements IBucketStore {
|
|
|
1304
1332
|
throw new Error(`Large uploads require an ArchivesDisk source, and this store has none (store ${this.folder})`);
|
|
1305
1333
|
}
|
|
1306
1334
|
|
|
1335
|
+
private isRemoteSource(source: ArchivesSource): boolean {
|
|
1336
|
+
return source.sourceConfig?.type === "remote";
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1307
1339
|
private async flushOverlay(force?: boolean): Promise<void> {
|
|
1308
1340
|
let now = Date.now();
|
|
1309
1341
|
for (let [key, entry] of this.overlay) {
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
if (
|
|
1314
|
-
|
|
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;
|
|
1315
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;
|
|
1316
1357
|
}
|
|
1317
1358
|
}
|
|
1318
1359
|
|
|
@@ -1375,7 +1416,7 @@ export class BlobStore implements IBucketStore {
|
|
|
1375
1416
|
for (let i = 0; i < this.sources.length; i++) {
|
|
1376
1417
|
if (!this.isLive(i)) continue;
|
|
1377
1418
|
let sourceEntry = this.sources[i];
|
|
1378
|
-
if (!
|
|
1419
|
+
if (!windowsAcceptWrites(sourceEntry.validWindows)) continue;
|
|
1379
1420
|
let source = sourceEntry.source;
|
|
1380
1421
|
if (!(source instanceof ArchivesBackblaze)) continue;
|
|
1381
1422
|
void source.del(key).catch((e: Error) => {
|