sliftutils 1.7.21 → 1.7.23
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 +23 -12
- package/package.json +1 -1
- package/storage/IArchives.d.ts +1 -0
- package/storage/IArchives.ts +4 -0
- package/storage/dist/IArchives.ts.cache +3 -3
- package/storage/remoteStorage/ArchivesRemote.ts +5 -5
- package/storage/remoteStorage/blobStore.d.ts +11 -5
- package/storage/remoteStorage/blobStore.ts +160 -53
- package/storage/remoteStorage/createArchives.ts +12 -2
- package/storage/remoteStorage/deployTakeover.d.ts +5 -5
- package/storage/remoteStorage/deployTakeover.ts +38 -31
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +8 -8
- package/storage/remoteStorage/dist/blobStore.ts.cache +6 -12
- package/storage/remoteStorage/dist/createArchives.ts.cache +40 -9
- package/storage/remoteStorage/dist/deployTakeover.ts.cache +64 -15
- package/storage/remoteStorage/dist/sourceWrapper.ts.cache +11 -4
- package/storage/remoteStorage/dist/storageController.ts.cache +25 -11
- package/storage/remoteStorage/dist/storageServerState.ts.cache +11 -5
- package/storage/remoteStorage/sourceWrapper.ts +2 -2
- package/storage/remoteStorage/spec.md +20 -2
- package/storage/remoteStorage/storageController.d.ts +6 -1
- package/storage/remoteStorage/storageController.ts +24 -8
- package/storage/remoteStorage/storageServerState.d.ts +0 -1
- package/storage/remoteStorage/storageServerState.ts +160 -28
|
@@ -5,7 +5,7 @@ import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
|
|
|
5
5
|
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
6
6
|
import {
|
|
7
7
|
IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, assertValidLastModified,
|
|
8
|
-
windowAcceptsWrites, SyncActivity,
|
|
8
|
+
windowAcceptsWrites, SyncActivity, FULL_ROUTE,
|
|
9
9
|
} from "../IArchives";
|
|
10
10
|
import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
|
|
11
11
|
import { ArchivesBackblaze } from "../backblaze";
|
|
@@ -22,6 +22,10 @@ import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../F
|
|
|
22
22
|
|
|
23
23
|
export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
|
|
24
24
|
const FAST_FLUSH_POLL = 1000 * 15;
|
|
25
|
+
// Fast writes are never delayed past our own valid window, and within this margin of the window's
|
|
26
|
+
// end they write through immediately - so when the next window's source takes over, the writes are
|
|
27
|
+
// already on disk
|
|
28
|
+
export const WINDOW_END_FLUSH_MARGIN = timeInMinute * 5;
|
|
25
29
|
// Index changes are buffered in memory and written to the BulkDatabase2 in batches
|
|
26
30
|
const INDEX_FLUSH_INTERVAL = 1000 * 30;
|
|
27
31
|
// Sources that support getChangesAfter are polled this often
|
|
@@ -120,6 +124,17 @@ type SourceState = {
|
|
|
120
124
|
lastMissCheck: number;
|
|
121
125
|
};
|
|
122
126
|
|
|
127
|
+
// What a scanned listing entry meant when compared against our index
|
|
128
|
+
type ScanOutcome = "filtered" | "new" | "updated" | "tombstone" | "unchanged";
|
|
129
|
+
type ScanTally = Record<ScanOutcome, number>;
|
|
130
|
+
function newScanTally(): ScanTally {
|
|
131
|
+
return { filtered: 0, new: 0, updated: 0, tombstone: 0, unchanged: 0 };
|
|
132
|
+
}
|
|
133
|
+
function formatScanTally(tally: ScanTally, total: number): string {
|
|
134
|
+
let pct = (n: number) => `${Math.round(n / Math.max(total, 1) * 1000) / 10}%`;
|
|
135
|
+
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 window/route (${pct(tally.filtered)})`;
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
export class BlobStore implements IBucketStore {
|
|
124
139
|
constructor(
|
|
125
140
|
private folder: string,
|
|
@@ -130,10 +145,6 @@ export class BlobStore implements IBucketStore {
|
|
|
130
145
|
onIndexChanged?: (key: string) => void;
|
|
131
146
|
// LRU-bound the disk (base source) to this many bytes; see CommonConfig.readerDiskLimit
|
|
132
147
|
readerDiskLimit?: number;
|
|
133
|
-
// Deploy takeover: fast-write flush delays never extend past this time, and after it
|
|
134
|
-
// fast writes flush immediately (nothing may sit in memory when the write window
|
|
135
|
-
// transfers to the successor process)
|
|
136
|
-
getFlushDeadline?: () => number | undefined;
|
|
137
148
|
}
|
|
138
149
|
) { }
|
|
139
150
|
|
|
@@ -205,6 +216,11 @@ export class BlobStore implements IBucketStore {
|
|
|
205
216
|
let source = sourceMap.get(entry.key);
|
|
206
217
|
// Explicit checks, as 0 is a valid size and a valid source number
|
|
207
218
|
if (size === undefined || source === undefined) continue;
|
|
219
|
+
// The routing config is only ever read off our own disk (see applyScanned), and a
|
|
220
|
+
// loaded bucket always has it there - a persisted entry pointing elsewhere is stale
|
|
221
|
+
if (entry.key === ROUTING_FILE) {
|
|
222
|
+
source = 0;
|
|
223
|
+
}
|
|
208
224
|
let full: IndexEntry = { writeTime: entry.value, size, source, changedAt: entry.time, lastAccess: entry.time };
|
|
209
225
|
this.mem.set(entry.key, full);
|
|
210
226
|
this.countEntry(full, 1);
|
|
@@ -238,13 +254,63 @@ export class BlobStore implements IBucketStore {
|
|
|
238
254
|
this.dirty.set(key, undefined);
|
|
239
255
|
}
|
|
240
256
|
|
|
241
|
-
/** Rescans our own disk's metadata into the index - used around
|
|
242
|
-
*
|
|
257
|
+
/** Rescans our own disk's metadata into the index - used around valid window handoffs, where
|
|
258
|
+
* another process wrote files to the shared folder that our index hasn't seen. */
|
|
243
259
|
public async rescanBase(): Promise<void> {
|
|
244
260
|
await this.init();
|
|
245
261
|
await this.scanSource(0);
|
|
246
262
|
}
|
|
247
263
|
|
|
264
|
+
/** A boundary scan of the node that owned (part of) our route in the valid window before ours,
|
|
265
|
+
* when that node is different storage (a disk rescan can't see its writes): just its changes
|
|
266
|
+
* since the boundary neighborhood, with matching values pulled onto our own disk. */
|
|
267
|
+
public async boundaryScanRemote(source: IArchives, config: { since: number; route?: [number, number] }): Promise<void> {
|
|
268
|
+
await this.init();
|
|
269
|
+
let scanStart = Date.now();
|
|
270
|
+
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})`);
|
|
271
|
+
let changes: ArchiveFileInfo[];
|
|
272
|
+
if (source.getChangesAfter) {
|
|
273
|
+
changes = await source.getChangesAfter(config.since);
|
|
274
|
+
} else {
|
|
275
|
+
changes = (await source.findInfo("")).filter(x => x.createTime > config.since);
|
|
276
|
+
}
|
|
277
|
+
let tally = newScanTally();
|
|
278
|
+
for (let file of changes) {
|
|
279
|
+
if (file.path === ROUTING_FILE || !routeContains(config.route, getRoute(file.path))) {
|
|
280
|
+
tally.filtered++;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
let overlayEntry = this.overlay.get(file.path);
|
|
284
|
+
let entry = this.mem.get(file.path);
|
|
285
|
+
let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
|
|
286
|
+
if (file.createTime <= currentTime) {
|
|
287
|
+
tally.unchanged++;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (file.size === 0) {
|
|
291
|
+
// A tombstone stores nothing on our own source - the index entry alone records it
|
|
292
|
+
this.setIndexEntry(file.path, { writeTime: file.createTime, size: 0, source: 0 });
|
|
293
|
+
tally.tombstone++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
let result = await source.get2(file.path);
|
|
297
|
+
if (!result) continue;
|
|
298
|
+
if (result.data.length === 0) {
|
|
299
|
+
this.setIndexEntry(file.path, { writeTime: result.writeTime, size: 0, source: 0 });
|
|
300
|
+
tally.tombstone++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
await this.sources[0].source.set(file.path, result.data, { lastModified: result.writeTime });
|
|
304
|
+
this.setIndexEntry(file.path, { writeTime: result.writeTime, size: result.data.length, source: 0 });
|
|
305
|
+
if (entry || overlayEntry) {
|
|
306
|
+
tally.updated++;
|
|
307
|
+
} else {
|
|
308
|
+
tally.new++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
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)}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
248
314
|
/** The cheap always-current totals plus any in-progress background synchronization. */
|
|
249
315
|
public getSyncProgress(): {
|
|
250
316
|
index: { fileCount: number; byteCount: number };
|
|
@@ -358,14 +424,13 @@ export class BlobStore implements IBucketStore {
|
|
|
358
424
|
// Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the
|
|
359
425
|
// source's listing (path -> write time), which reconcileSource uses for the push direction.
|
|
360
426
|
private async scanSource(sourceIndex: number): Promise<Map<string, number>> {
|
|
361
|
-
let { source } = this.sources[sourceIndex];
|
|
427
|
+
let { source, validWindow, route } = this.sources[sourceIndex];
|
|
362
428
|
let state = this.sourceStates[sourceIndex];
|
|
363
429
|
let scanStart = Date.now();
|
|
364
|
-
let scanLogged = false;
|
|
365
430
|
let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
|
|
366
431
|
this.syncActivities.add(activity);
|
|
432
|
+
console.log(`Metadata scan of ${source.getDebugName()} starting (store ${this.folder})`);
|
|
367
433
|
let progressTimer = setInterval(() => {
|
|
368
|
-
scanLogged = true;
|
|
369
434
|
console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.folder})`);
|
|
370
435
|
}, SYNC_PROGRESS_LOG_INTERVAL);
|
|
371
436
|
(progressTimer as { unref?: () => void }).unref?.();
|
|
@@ -376,26 +441,43 @@ export class BlobStore implements IBucketStore {
|
|
|
376
441
|
clearInterval(progressTimer);
|
|
377
442
|
this.syncActivities.delete(activity);
|
|
378
443
|
}
|
|
379
|
-
|
|
380
|
-
console.log(`Metadata scan of ${source.getDebugName()} finished: ${files.length} files in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder})`);
|
|
381
|
-
}
|
|
444
|
+
let indexSizeBefore = this.mem.size;
|
|
382
445
|
let seen = new Map<string, number>();
|
|
446
|
+
let tally = newScanTally();
|
|
447
|
+
let newPaths = 0;
|
|
383
448
|
for (let file of files) {
|
|
384
449
|
seen.set(file.path, file.createTime);
|
|
385
|
-
this.
|
|
450
|
+
if (!this.mem.has(file.path)) {
|
|
451
|
+
newPaths++;
|
|
452
|
+
}
|
|
453
|
+
tally[this.applyScanned(sourceIndex, file)]++;
|
|
386
454
|
}
|
|
387
455
|
state.scannedCount = files.length;
|
|
388
456
|
// Index entries this source was the holder of, but that vanished from it (e.g. deleted
|
|
389
457
|
// while we were offline), come out of the index. Entries changed after the scan started
|
|
390
458
|
// are kept — the scan listing may simply predate them. Tombstones have no physical file
|
|
391
459
|
// for a listing to vouch for, so they're exempt (cleanupTombstones expires them instead).
|
|
460
|
+
let removedFromIndex = 0;
|
|
461
|
+
let missingOnSource = 0;
|
|
392
462
|
for (let [key, entry] of this.mem) {
|
|
393
|
-
if (entry.source !== sourceIndex) continue;
|
|
394
|
-
if (entry.size === 0) continue;
|
|
395
463
|
if (seen.has(key)) continue;
|
|
396
|
-
if (entry.changedAt
|
|
397
|
-
|
|
464
|
+
if (entry.source === sourceIndex && entry.size !== 0 && entry.changedAt < scanStart) {
|
|
465
|
+
this.deleteIndexEntry(key);
|
|
466
|
+
removedFromIndex++;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
// Counted only when the source SHOULD hold the entry (its window/route match) - these
|
|
470
|
+
// are what the reconcile pass pushes to it
|
|
471
|
+
if (entry.size === 0 || key === ROUTING_FILE) continue;
|
|
472
|
+
if (entry.writeTime < validWindow[0] || entry.writeTime > validWindow[1]) continue;
|
|
473
|
+
if (!routeContains(route, getRoute(key))) continue;
|
|
474
|
+
missingOnSource++;
|
|
398
475
|
}
|
|
476
|
+
// Percentages are of the union of both sides (our index + their listing), so every count
|
|
477
|
+
// has a stable denominator
|
|
478
|
+
let union = indexSizeBefore + newPaths;
|
|
479
|
+
let pct = (n: number) => `${Math.round(n / Math.max(union, 1) * 1000) / 10}%`;
|
|
480
|
+
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)})`);
|
|
399
481
|
state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
|
|
400
482
|
return seen;
|
|
401
483
|
}
|
|
@@ -411,12 +493,11 @@ export class BlobStore implements IBucketStore {
|
|
|
411
493
|
for (let [key, entry] of this.mem) {
|
|
412
494
|
if (this.stopped.stop) return;
|
|
413
495
|
if (entry.source === sourceIndex) continue;
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
if (key
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
496
|
+
// The routing file is NEVER synchronized between storage nodes - it is only ever
|
|
497
|
+
// written directly to each node, and only ever read off our own disk
|
|
498
|
+
if (key === ROUTING_FILE) continue;
|
|
499
|
+
if (!acceptsWrites) continue;
|
|
500
|
+
if (!routeContains(route, getRoute(key))) continue;
|
|
420
501
|
let theirTime = listing.get(key);
|
|
421
502
|
if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
|
|
422
503
|
if (entry.size === 0) {
|
|
@@ -435,16 +516,28 @@ export class BlobStore implements IBucketStore {
|
|
|
435
516
|
}
|
|
436
517
|
}
|
|
437
518
|
|
|
438
|
-
private applyScanned(sourceIndex: number, file: ArchiveFileInfo):
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
519
|
+
private applyScanned(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
|
|
520
|
+
if (file.path === ROUTING_FILE) {
|
|
521
|
+
// The routing config is NEVER pulled from other sources - it only ever arrives as an
|
|
522
|
+
// explicit, version-validated write, and is only ever read off our own disk. Route and
|
|
523
|
+
// valid-window filters can't possibly apply to it either: it is the file DEFINING
|
|
524
|
+
// them, so filtering it would mean certain sources could never have their routing
|
|
525
|
+
// config updated, ever.
|
|
526
|
+
if (sourceIndex !== 0) return "filtered";
|
|
527
|
+
} else {
|
|
528
|
+
let { validWindow, route } = this.sources[sourceIndex];
|
|
529
|
+
let [windowStart, windowEnd] = validWindow;
|
|
530
|
+
if (file.createTime < windowStart || file.createTime > windowEnd) return "filtered";
|
|
531
|
+
// A partially-overlapping shard's listing includes keys outside our route; ignore them
|
|
532
|
+
if (!routeContains(route, getRoute(file.path))) return "filtered";
|
|
533
|
+
}
|
|
444
534
|
let existing = this.mem.get(file.path);
|
|
445
535
|
// The highest write time wins across all sources (ties keep the existing entry)
|
|
446
|
-
if (existing && file.createTime <= existing.writeTime) return;
|
|
536
|
+
if (existing && file.createTime <= existing.writeTime) return "unchanged";
|
|
447
537
|
this.setIndexEntry(file.path, { writeTime: file.createTime, size: file.size, source: sourceIndex });
|
|
538
|
+
if (file.size === 0) return "tombstone";
|
|
539
|
+
if (existing) return "updated";
|
|
540
|
+
return "new";
|
|
448
541
|
}
|
|
449
542
|
|
|
450
543
|
private async pollChanges(sourceIndex: number): Promise<void> {
|
|
@@ -453,8 +546,13 @@ export class BlobStore implements IBucketStore {
|
|
|
453
546
|
let state = this.sourceStates[sourceIndex];
|
|
454
547
|
let pollStart = Date.now();
|
|
455
548
|
let changes = await source.getChangesAfter(state.changesAfterTime);
|
|
549
|
+
let tally = newScanTally();
|
|
456
550
|
for (let file of changes) {
|
|
457
|
-
this.applyScanned(sourceIndex, file)
|
|
551
|
+
tally[this.applyScanned(sourceIndex, file)]++;
|
|
552
|
+
}
|
|
553
|
+
// Polls run constantly, so only the ones that actually changed the index get a line
|
|
554
|
+
if (tally.new || tally.updated || tally.tombstone) {
|
|
555
|
+
console.log(`Changes poll of ${source.getDebugName()} (store ${this.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
|
|
458
556
|
}
|
|
459
557
|
state.scannedCount += changes.length;
|
|
460
558
|
state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
|
|
@@ -625,6 +723,12 @@ export class BlobStore implements IBucketStore {
|
|
|
625
723
|
}
|
|
626
724
|
return result;
|
|
627
725
|
}
|
|
726
|
+
// The routing file is only ever read off our own disk - falling back to another source's
|
|
727
|
+
// copy would synchronize it between nodes through the read path, which it never is
|
|
728
|
+
if (key === ROUTING_FILE) {
|
|
729
|
+
if (holderError) throw holderError;
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
628
732
|
// The holder is down or lost the file. ANY other source's copy beats no value - even an
|
|
629
733
|
// OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
|
|
630
734
|
for (let i = 0; i < this.sources.length; i++) {
|
|
@@ -669,22 +773,24 @@ export class BlobStore implements IBucketStore {
|
|
|
669
773
|
}
|
|
670
774
|
let writeTime = lastModified || Date.now();
|
|
671
775
|
if (config?.fast) {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
let
|
|
675
|
-
if (
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
776
|
+
// A writeDelay of zero is a real choice (no delay at all), so only an omitted delay
|
|
777
|
+
// gets the default
|
|
778
|
+
let writeDelay = config.writeDelay;
|
|
779
|
+
if (writeDelay === undefined) {
|
|
780
|
+
writeDelay = DEFAULT_FAST_WRITE_DELAY;
|
|
781
|
+
}
|
|
782
|
+
// The delay never extends past our own valid window's end (minus the margin, so the
|
|
783
|
+
// writes are on disk before the next window's source takes over - a deploy switchover
|
|
784
|
+
// is just this too, since its remap ends our window). Past that point fast writes
|
|
785
|
+
// write through immediately.
|
|
786
|
+
let deadline = this.sources[0].validWindow[1] - WINDOW_END_FLUSH_MARGIN;
|
|
787
|
+
if (writeDelay > 0 && Date.now() < deadline) {
|
|
788
|
+
let flushAt = Math.min(Date.now() + writeDelay, deadline);
|
|
789
|
+
this.overlay.set(key, { data, t: writeTime, flushAt });
|
|
790
|
+
return key;
|
|
686
791
|
}
|
|
687
|
-
this.overlay.
|
|
792
|
+
this.overlay.delete(key);
|
|
793
|
+
await this.writeToSources(key, data, writeTime);
|
|
688
794
|
return key;
|
|
689
795
|
}
|
|
690
796
|
this.overlay.delete(key);
|
|
@@ -709,9 +815,10 @@ export class BlobStore implements IBucketStore {
|
|
|
709
815
|
}
|
|
710
816
|
|
|
711
817
|
private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
|
|
712
|
-
// The routing file is
|
|
713
|
-
//
|
|
714
|
-
//
|
|
818
|
+
// The routing file is NEVER synchronized between storage nodes: the writer writes it
|
|
819
|
+
// directly to each node, so we store it on our own disk only (no valid-window filter -
|
|
820
|
+
// routing/valid windows can't possibly apply to the file defining them) and never forward
|
|
821
|
+
// it to other sources.
|
|
715
822
|
let isRouting = key === ROUTING_FILE;
|
|
716
823
|
let writable = this.getWritableSources({ ignoreWindow: isRouting });
|
|
717
824
|
let first = writable.shift();
|
|
@@ -728,9 +835,10 @@ export class BlobStore implements IBucketStore {
|
|
|
728
835
|
await this.sources[first].source.set(key, data, { lastModified: writeTime });
|
|
729
836
|
}
|
|
730
837
|
this.setIndexEntry(key, { writeTime, size: data.length, source: first });
|
|
731
|
-
|
|
838
|
+
if (isRouting) return;
|
|
839
|
+
let route = getRoute(key);
|
|
732
840
|
for (let i of writable) {
|
|
733
|
-
if (!
|
|
841
|
+
if (!routeContains(this.sources[i].route, route)) continue;
|
|
734
842
|
// Downstream sources receive tombstones as actual empty writes, so their listings show
|
|
735
843
|
// the deletion (size 0) and other stores scan it in as a tombstone
|
|
736
844
|
void this.sources[i].source.set(key, data, { lastModified: writeTime }).catch((e: Error) => {
|
|
@@ -858,7 +966,6 @@ export class BlobStore implements IBucketStore {
|
|
|
858
966
|
// holds a same-or-newer copy (the only copy of a file is never deleted), and the index entry
|
|
859
967
|
// repoints to that source so reads keep working (re-caching on the next read).
|
|
860
968
|
private evicting = false;
|
|
861
|
-
private loggedFlushDeadline = false;
|
|
862
969
|
private async enforceDiskLimit(): Promise<void> {
|
|
863
970
|
let limit = this.config?.readerDiskLimit;
|
|
864
971
|
if (!limit || this.evicting) return;
|
|
@@ -46,7 +46,7 @@ const AVAILABILITY_RECHECK_THROTTLE = 5 * 1000;
|
|
|
46
46
|
// explicit writes surface the denial (with grant instructions) to the caller instead of hanging.
|
|
47
47
|
export function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives {
|
|
48
48
|
if (source.type === "backblaze") {
|
|
49
|
-
return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable });
|
|
49
|
+
return new ArchivesBackblaze({ bucketName: parseBackblazeUrl(source.url).bucketName, public: source.public, immutable: source.immutable, allowedOrigins: source.allowedOrigins });
|
|
50
50
|
}
|
|
51
51
|
let parsed = parseHostedUrl(source.url);
|
|
52
52
|
let server = isNode() && getStorageServerConfigOptional() || undefined;
|
|
@@ -214,7 +214,17 @@ export class ArchivesChain implements IArchives {
|
|
|
214
214
|
// A rejected write fails init, which retries from scratch - re-reading the
|
|
215
215
|
// routing, so losing a create race to another client just adopts their
|
|
216
216
|
// config on the next attempt.
|
|
217
|
-
|
|
217
|
+
// The routing file is NEVER synchronized between storage nodes, so it is
|
|
218
|
+
// written directly to EVERY node, with one shared write time (the latest
|
|
219
|
+
// write time wins on each node independently)
|
|
220
|
+
let routingData = serializeRemoteConfig(this.configured);
|
|
221
|
+
let routingWriteTime = Date.now();
|
|
222
|
+
let writtenUrls = new Set<string>();
|
|
223
|
+
for (let source of sources) {
|
|
224
|
+
if (writtenUrls.has(source.config.url)) continue;
|
|
225
|
+
writtenUrls.add(source.config.url);
|
|
226
|
+
await source.write(archives => archives.set(ROUTING_FILE, routingData, { lastModified: routingWriteTime }));
|
|
227
|
+
}
|
|
218
228
|
} catch (e) {
|
|
219
229
|
for (let source of sources) {
|
|
220
230
|
source.dispose();
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { RemoteConfig } from "../IArchives";
|
|
2
|
-
export type TakeoverEvent = "remapChanged"
|
|
2
|
+
export type TakeoverEvent = "remapChanged";
|
|
3
3
|
/** Starts the takeover machinery. Port fallback (alternate port + registry + acquisition polling)
|
|
4
4
|
* works regardless; without a deploy timeline folder the switchover-specific parts (the remap,
|
|
5
|
-
* the
|
|
5
|
+
* the tighter acquisition pacing) simply stay inert. */
|
|
6
6
|
export declare function initDeployTakeover(config: {
|
|
7
7
|
domain: string;
|
|
8
8
|
mainPort: number;
|
|
@@ -20,9 +20,9 @@ export declare function applyDeployRemap(routing: RemoteConfig): RemoteConfig;
|
|
|
20
20
|
* client learns of a takeover within one ping interval, instead of waiting for its config poll
|
|
21
21
|
* or a write rejection. */
|
|
22
22
|
export declare function getTakeoverStamp(): string | undefined;
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
export declare function
|
|
23
|
+
/** The middle-window alternate port of an active remap. The OTHER process of the takeover lives on
|
|
24
|
+
* this port on OUR machine (same disk!), so sources pointing at it are self, never sync targets. */
|
|
25
|
+
export declare function getTakeoverAltPort(): number | undefined;
|
|
26
26
|
/** How long to wait between main-port acquisition attempts: tight around the predecessor's
|
|
27
27
|
* scheduled death (when the port actually frees), relaxed otherwise. */
|
|
28
28
|
export declare function getMainPortAcquireDelay(): number;
|
|
@@ -29,12 +29,6 @@ const PORT_REGISTRY_FOLDER_NAME = "storagePortRegistry";
|
|
|
29
29
|
const ACQUIRE_SLOW_DELAY = 30 * 1000;
|
|
30
30
|
const ACQUIRE_FAST_DELAY = 5 * 1000;
|
|
31
31
|
const ACQUIRE_FAST_WINDOW = 60 * 1000;
|
|
32
|
-
// Disk rescans fire this far before and after the write handoff, catching files the other process
|
|
33
|
-
// wrote (the BulkDatabase2 index is atomic but its entries land at write time, not file time)
|
|
34
|
-
const SWITCH_SCAN_LEAD = 10 * 1000;
|
|
35
|
-
// The dying process must have flushed everything before the handoff: fast-write delays never
|
|
36
|
-
// extend past S + overlap * this fraction, and after that point fast writes flush immediately
|
|
37
|
-
const FLUSH_DEADLINE_FRACTION = 0.25;
|
|
38
32
|
const BOUNDARY_A_FRACTION = 0.5;
|
|
39
33
|
const BOUNDARY_B_FRACTION = 1.5;
|
|
40
34
|
const REMAP_EXPIRE_FRACTION = 2;
|
|
@@ -58,7 +52,7 @@ type TakeoverComputed = {
|
|
|
58
52
|
predecessorEnd?: number;
|
|
59
53
|
};
|
|
60
54
|
|
|
61
|
-
export type TakeoverEvent = "remapChanged"
|
|
55
|
+
export type TakeoverEvent = "remapChanged";
|
|
62
56
|
|
|
63
57
|
let initialized: { domain: string; mainPort: number; storageFolder: string } | undefined;
|
|
64
58
|
let ancestorPids = new Set<number>();
|
|
@@ -202,18 +196,32 @@ function scheduleEvents(): void {
|
|
|
202
196
|
(timer as { unref?: () => void }).unref?.();
|
|
203
197
|
eventTimers.push(timer);
|
|
204
198
|
};
|
|
205
|
-
|
|
199
|
+
const remap = current.remap;
|
|
206
200
|
if (remap) {
|
|
207
201
|
let altPort = remap.altPort;
|
|
208
|
-
schedule(remap.boundaryA
|
|
209
|
-
schedule(remap.
|
|
210
|
-
schedule(remap.boundaryA + SWITCH_SCAN_LEAD, () => emit("diskScan"));
|
|
211
|
-
schedule(remap.boundaryB, () => console.log(`Deploy switchover second boundary passed: fresh writes return to the main port`));
|
|
202
|
+
schedule(remap.boundaryA, () => console.log(`${logPrefix()} Deploy switchover write handoff boundary ${iso(remap.boundaryA)} passed: fresh writes now belong to the alternate-port side (port ${altPort})`));
|
|
203
|
+
schedule(remap.boundaryB, () => console.log(`${logPrefix()} Deploy switchover second boundary ${iso(remap.boundaryB)} passed: fresh writes return to the main port`));
|
|
212
204
|
schedule(remap.expire, () => void recompute().catch((e: Error) => console.error(`Deploy takeover recompute failed: ${e.stack ?? e}`)));
|
|
213
205
|
}
|
|
214
206
|
}
|
|
215
207
|
|
|
216
|
-
|
|
208
|
+
// Multiple processes (predecessor + successor) often share one journal during a switchover, so
|
|
209
|
+
// every lifecycle line identifies its process
|
|
210
|
+
function logPrefix(): string {
|
|
211
|
+
return `[pid ${process.pid}]`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Recompute is invoked from init, registerAltPort, the poll, and the expiry timer - possibly
|
|
215
|
+
// concurrently. Runs are serialized, or two in-flight runs would both pass the changed-state check
|
|
216
|
+
// and double-fire every transition (logs, rebuild events, timers).
|
|
217
|
+
let recomputeChain = Promise.resolve();
|
|
218
|
+
function recompute(): Promise<void> {
|
|
219
|
+
let next = recomputeChain.then(() => recomputeNow());
|
|
220
|
+
recomputeChain = next.then(() => { }, () => { });
|
|
221
|
+
return next;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function recomputeNow(): Promise<void> {
|
|
217
225
|
if (!initialized || !timelineFolder) return;
|
|
218
226
|
let now = Date.now();
|
|
219
227
|
let entries = await readTimelineEntries();
|
|
@@ -227,11 +235,11 @@ async function recompute(): Promise<void> {
|
|
|
227
235
|
// Matching failures must be loud, or takeover detection fails silently forever
|
|
228
236
|
if (!us && entries.length && !loggedNoMatch) {
|
|
229
237
|
loggedNoMatch = true;
|
|
230
|
-
console.warn(
|
|
238
|
+
console.warn(`${logPrefix()} No deploy timeline entry matches our pid chain (${[...ancestorPids].join(", ")}) - takeover detection cannot work. Entries: ${entries.map(x => `pid=${x.pid ?? "none"} releaseTime=${x.parameters.releaseTime}`).join(" | ")}`);
|
|
231
239
|
}
|
|
232
240
|
if (us && !loggedIdentity) {
|
|
233
241
|
loggedIdentity = true;
|
|
234
|
-
console.log(
|
|
242
|
+
console.log(`${logPrefix()} Deploy timeline entry matched: we are pid=${us.pid}, releaseTime=${us.parameters.releaseTime !== undefined && iso(us.parameters.releaseTime) || "unknown"}, ${entries.length} entries total`);
|
|
235
243
|
}
|
|
236
244
|
let computed: TakeoverComputed = {};
|
|
237
245
|
let usRelease = us?.parameters.releaseTime;
|
|
@@ -254,9 +262,10 @@ async function recompute(): Promise<void> {
|
|
|
254
262
|
if (next) {
|
|
255
263
|
let successorStart = next.release;
|
|
256
264
|
let overlap = next.entry.parameters.overlapTime || us.parameters.overlapTime || 0;
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
265
|
+
// Tracked even for a ZERO-overlap deploy (the scheduled-switchover log must still
|
|
266
|
+
// fire); only the remap (the alternate-port middle window) needs an actual overlap.
|
|
267
|
+
// Flushing before the handoff needs nothing special here: the remap ends our valid
|
|
268
|
+
// window, and fast writes always flush ahead of their window's end.
|
|
260
269
|
computed.dying = { successorStart, overlap };
|
|
261
270
|
if (overlap > 0 && now < successorStart + overlap * REMAP_EXPIRE_FRACTION) {
|
|
262
271
|
const successorPid = next.entry.pid;
|
|
@@ -294,31 +303,31 @@ function iso(time: number): string {
|
|
|
294
303
|
function logTransitions(prev: TakeoverComputed, next: TakeoverComputed): void {
|
|
295
304
|
if (!prev.dying && next.dying) {
|
|
296
305
|
let { successorStart, overlap } = next.dying;
|
|
297
|
-
console.log(
|
|
306
|
+
console.log(`${logPrefix()} Deploy switchover scheduled: our successor starts at ${iso(successorStart)}; fresh writes hand off at ${iso(successorStart + overlap * BOUNDARY_A_FRACTION)}, and we are killed at ${iso(successorStart + overlap)}`);
|
|
298
307
|
}
|
|
299
308
|
if (!prev.predecessorEnd && next.predecessorEnd) {
|
|
300
|
-
console.log(
|
|
309
|
+
console.log(`${logPrefix()} We are a deploy successor: our predecessor holds the main port until ${iso(next.predecessorEnd)}`);
|
|
301
310
|
}
|
|
302
311
|
if (!prev.remap && next.remap) {
|
|
303
|
-
console.log(
|
|
312
|
+
console.log(`${logPrefix()} Deploy switchover remap active: sources pointing at us are split so [${iso(next.remap.boundaryA)} .. ${iso(next.remap.boundaryB)}] routes to alternate port ${next.remap.altPort} (remap expires at ${iso(next.remap.expire)})`);
|
|
304
313
|
}
|
|
305
314
|
if (prev.remap && !next.remap) {
|
|
306
|
-
console.log(
|
|
315
|
+
console.log(`${logPrefix()} Deploy switchover remap ended; running on the plain stored routing config`);
|
|
307
316
|
}
|
|
308
317
|
}
|
|
309
318
|
|
|
310
319
|
/** Starts the takeover machinery. Port fallback (alternate port + registry + acquisition polling)
|
|
311
320
|
* works regardless; without a deploy timeline folder the switchover-specific parts (the remap,
|
|
312
|
-
* the
|
|
321
|
+
* the tighter acquisition pacing) simply stay inert. */
|
|
313
322
|
export async function initDeployTakeover(config: { domain: string; mainPort: number; storageFolder: string }): Promise<void> {
|
|
314
323
|
initialized = config;
|
|
315
324
|
ancestorPids = await getAncestorPids();
|
|
316
325
|
timelineFolder = getTimelineFolder();
|
|
317
326
|
let entries = await readTimelineEntries();
|
|
318
327
|
if (!entries.length) {
|
|
319
|
-
console.log(
|
|
328
|
+
console.log(`${logPrefix()} No deploy timeline entries in ${timelineFolder}; deploy takeover inactive until they appear (port fallback still works)`);
|
|
320
329
|
} else {
|
|
321
|
-
console.log(
|
|
330
|
+
console.log(`${logPrefix()} Deploy timeline found at ${timelineFolder} (${entries.length} entries); our pid chain: ${[...ancestorPids].join(" -> ")}`);
|
|
322
331
|
}
|
|
323
332
|
await recompute();
|
|
324
333
|
let poll = setInterval(() => {
|
|
@@ -401,12 +410,10 @@ export function getTakeoverStamp(): string | undefined {
|
|
|
401
410
|
return JSON.stringify(remap);
|
|
402
411
|
}
|
|
403
412
|
|
|
404
|
-
/**
|
|
405
|
-
*
|
|
406
|
-
export function
|
|
407
|
-
|
|
408
|
-
if (!dying) return undefined;
|
|
409
|
-
return dying.successorStart + dying.overlap * FLUSH_DEADLINE_FRACTION;
|
|
413
|
+
/** The middle-window alternate port of an active remap. The OTHER process of the takeover lives on
|
|
414
|
+
* this port on OUR machine (same disk!), so sources pointing at it are self, never sync targets. */
|
|
415
|
+
export function getTakeoverAltPort(): number | undefined {
|
|
416
|
+
return current.remap?.altPort;
|
|
410
417
|
}
|
|
411
418
|
|
|
412
419
|
/** How long to wait between main-port acquisition attempts: tight around the predecessor's
|