sliftutils 1.7.99 → 1.7.100
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 +74 -3
- package/package.json +1 -1
- package/storage/ArchivesDisk.d.ts +2 -1
- package/storage/ArchivesDisk.ts +9 -3
- package/storage/IArchives.d.ts +8 -0
- package/storage/IArchives.ts +8 -0
- package/storage/LogMap.d.ts +1 -1
- package/storage/TransactionFile.d.ts +56 -0
- package/storage/{LogMap.ts → TransactionFile.ts} +7 -4
- package/storage/backblaze.ts +4 -1
- package/storage/dist/ArchivesDisk.ts.cache +11 -5
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/TransactionFile.ts.cache +212 -0
- package/storage/dist/backblaze.ts.cache +6 -3
- package/storage/remoteStorage/ArchivesRemote.ts +21 -2
- package/storage/remoteStorage/blobStore.d.ts +2 -1
- package/storage/remoteStorage/blobStore.ts +7 -7
- package/storage/remoteStorage/createArchives.ts +25 -8
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +27 -5
- package/storage/remoteStorage/dist/blobStore.ts.cache +5 -5
- package/storage/remoteStorage/dist/createArchives.ts.cache +26 -9
- package/storage/remoteStorage/dist/storageController.ts.cache +4 -3
- package/storage/remoteStorage/dist/storeSync.ts.cache +71 -21
- package/storage/remoteStorage/spec.md +1 -1
- package/storage/remoteStorage/storageController.d.ts +1 -0
- package/storage/remoteStorage/storageController.ts +3 -2
- package/storage/remoteStorage/storeSync.ts +64 -19
|
@@ -391,12 +391,13 @@ class RemoteStorageControllerBase {
|
|
|
391
391
|
largeUploadInfo.set(id, { ...write, account: config.account, bucketName: config.bucketName, storeName: config.sourceConfig.name });
|
|
392
392
|
return id;
|
|
393
393
|
}
|
|
394
|
-
|
|
394
|
+
/** offset makes the part write positional (see ArchivesDisk.appendLargeUpload), which is what lets the client RETRY a failed part - a re-sent part lands on the same bytes instead of appending twice. */
|
|
395
|
+
async uploadPart(config: { uploadId: string; data: Buffer; offset?: number }): Promise<void> {
|
|
395
396
|
assertWritesAllowed();
|
|
396
397
|
let info = largeUploadInfo.get(config.uploadId);
|
|
397
398
|
if (!info) throw new Error(`Unknown large upload ${config.uploadId}`);
|
|
398
399
|
trackAccess({ account: info.account, operation: "uploadPart", path: `${info.bucketName}/${info.path}`, size: config.data.length });
|
|
399
|
-
await getStore(info.account, info.bucketName, info.storeName).appendLargeUpload({ id: config.uploadId, data: Buffer.from(config.data) });
|
|
400
|
+
await getStore(info.account, info.bucketName, info.storeName).appendLargeUpload({ id: config.uploadId, data: Buffer.from(config.data), offset: config.offset });
|
|
400
401
|
}
|
|
401
402
|
async finishLargeFile(config: { uploadId: string }): Promise<void> {
|
|
402
403
|
assertWritesAllowed();
|
|
@@ -9,6 +9,7 @@ import { copyArchiveFile } from "../archiveHelpers";
|
|
|
9
9
|
import { ArchivesBackblaze } from "../backblaze";
|
|
10
10
|
import { ROUTING_FILE, getRoute, routeContains, parseRoutingData, getConfigVersion } from "./remoteConfig";
|
|
11
11
|
import type { BlobStore, IndexEntry } from "./blobStore";
|
|
12
|
+
import { magenta } from "socket-function/src/formatting/logColors";
|
|
12
13
|
|
|
13
14
|
// Everything that keeps a store's index in agreement with its sources, plus the two maintenance loops that follow from holding an index (evicting a bounded disk cache, expiring tombstones). The store owns the sources and decides where writes go; this decides what is scanned, pulled, pushed, evicted, and forgotten - so the write path stays readable without the several hundred lines of synchronization machinery interleaved into it.
|
|
14
15
|
|
|
@@ -32,9 +33,14 @@ const DISK_LIMIT_CHECK_INTERVAL = 1000 * 60;
|
|
|
32
33
|
const FULL_SYNC_PARALLEL = 8;
|
|
33
34
|
// A full sync running longer than this is console.errored (and again every interval after), so a sync that will take days is loud instead of a quiet console.log every minute
|
|
34
35
|
const FULL_SYNC_SLOW_ERROR_INTERVAL = 1000 * 60 * 60;
|
|
35
|
-
//
|
|
36
|
-
const
|
|
37
|
-
const
|
|
36
|
+
// Reconcile passes AND full syncs skip failing files (one bad value must not stop the rest - a source failing one request in a thousand could otherwise never finish a sync), but this many failures in a row means the other side itself is down, so the pass aborts until the next cycle
|
|
37
|
+
const SYNC_MAX_CONSECUTIVE_FAILURES = 5;
|
|
38
|
+
const SYNC_ERROR_LOG_LIMIT = 3;
|
|
39
|
+
// Every failed file waits this long before the pass continues, so a down network burns through the consecutive-failure allowance over a minute-plus instead of in an instant
|
|
40
|
+
const SYNC_FAILURE_DELAY = 1000 * 15;
|
|
41
|
+
// A full listing that comes back EMPTY - or under half of what we know the source holds - is treated as the other end being briefly broken, not as the truth: it is retried this many times, this far apart, before being believed. Believing a wrongly-shrunken listing purges every index entry the source held, which is how sync progress "goes backwards".
|
|
42
|
+
const SUSPICIOUS_SCAN_RETRIES = 3;
|
|
43
|
+
const SUSPICIOUS_SCAN_RETRY_DELAY = 1000 * 60;
|
|
38
44
|
|
|
39
45
|
type SourceState = {
|
|
40
46
|
supportsChangesAfter: boolean;
|
|
@@ -326,7 +332,26 @@ export class StoreSync {
|
|
|
326
332
|
// The listing request deliberately takes no time or route filters: our slowest sources (backblaze) support neither, so filtering would happen after the full fetch anyway - little benefit, more room for desynchronization. And if a full listing ever becomes too big to send over the network, it is also too big for the receiving process to hold in memory - the fix is more routing shards (each storing and sending less), not filtering.
|
|
327
333
|
let files: ArchiveFileInfo[];
|
|
328
334
|
try {
|
|
329
|
-
|
|
335
|
+
let attempts = 0;
|
|
336
|
+
while (true) {
|
|
337
|
+
files = await source.findInfo("");
|
|
338
|
+
if (this.store.stopped.stop || state.stopped.stop) break;
|
|
339
|
+
// What we believe the source has: whichever is larger of its previous listing and the index entries naming it as holder. Files do not just vanish - a listing far below this means the other end is briefly broken, and believing it would purge everything it held.
|
|
340
|
+
let held = 0;
|
|
341
|
+
let scannedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
|
|
342
|
+
for (let [, entry] of this.store.indexEntries()) {
|
|
343
|
+
if (entry.sourcesListIndex === scannedSourcesListIndex) held++;
|
|
344
|
+
}
|
|
345
|
+
let expected = Math.max(state.scannedCount, held);
|
|
346
|
+
if (!(expected > 0 && files.length < expected / 2)) break;
|
|
347
|
+
if (attempts >= SUSPICIOUS_SCAN_RETRIES) {
|
|
348
|
+
console.warn(`Metadata scan of ${source.getDebugName()} (store ${this.store.folder}) STILL lists ${files.length} files where ~${expected} were expected, after ${attempts} retries - accepting the listing as the truth`);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
attempts++;
|
|
352
|
+
console.warn(`Metadata scan of ${source.getDebugName()} (store ${this.store.folder}) listed ${files.length} files, but ~${expected} were expected (previous listing ${state.scannedCount}, index entries it holds ${held}) - the other end is probably briefly broken; asking again in ${SUSPICIOUS_SCAN_RETRY_DELAY / 1000}s (retry ${attempts} of ${SUSPICIOUS_SCAN_RETRIES})`);
|
|
353
|
+
await delay(SUSPICIOUS_SCAN_RETRY_DELAY);
|
|
354
|
+
}
|
|
330
355
|
} finally {
|
|
331
356
|
clearInterval(progressTimer);
|
|
332
357
|
this.activities.delete(activity);
|
|
@@ -415,13 +440,15 @@ export class StoreSync {
|
|
|
415
440
|
} catch (e) {
|
|
416
441
|
failed++;
|
|
417
442
|
consecutiveFailures++;
|
|
418
|
-
if (errors.length <
|
|
443
|
+
if (errors.length < SYNC_ERROR_LOG_LIMIT) {
|
|
419
444
|
errors.push(`${key}: ${(e as Error).stack ?? e}`);
|
|
420
445
|
}
|
|
421
|
-
if (consecutiveFailures >=
|
|
446
|
+
if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
|
|
422
447
|
aborted = true;
|
|
423
448
|
break;
|
|
424
449
|
}
|
|
450
|
+
// A pause per failure, so a down network takes minutes to burn through the consecutive-failure allowance instead of an instant
|
|
451
|
+
await delay(SYNC_FAILURE_DELAY);
|
|
425
452
|
}
|
|
426
453
|
}
|
|
427
454
|
if (failed) {
|
|
@@ -499,7 +526,7 @@ export class StoreSync {
|
|
|
499
526
|
let progressLogged = false;
|
|
500
527
|
let logProgress = () => {
|
|
501
528
|
progressLogged = true;
|
|
502
|
-
console.log(`Full sync from ${source.getDebugName()} (store ${this.store.folder}): ${activity.doneFiles}/${pending.length} files (${((activity.doneFiles || 0) / pending.length * 100).toFixed(1)}%), ${formatNumber(activity.doneBytes || 0)}B/${formatNumber(totalBytes)}B (${(totalBytes && (activity.doneBytes || 0) / totalBytes * 100 || 100).toFixed(1)}%)`);
|
|
529
|
+
console.log(magenta(`Full sync from ${source.getDebugName()} (store ${this.store.folder}): ${activity.doneFiles}/${pending.length} files (${((activity.doneFiles || 0) / pending.length * 100).toFixed(1)}%), ${formatNumber(activity.doneBytes || 0)}B/${formatNumber(totalBytes)}B (${(totalBytes && (activity.doneBytes || 0) / totalBytes * 100 || 100).toFixed(1)}%)`));
|
|
503
530
|
};
|
|
504
531
|
let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
|
|
505
532
|
(progressTimer as { unref?: () => void }).unref?.();
|
|
@@ -519,18 +546,37 @@ export class StoreSync {
|
|
|
519
546
|
(slowErrorTimer as { unref?: () => void }).unref?.();
|
|
520
547
|
try {
|
|
521
548
|
let nextIndex = 0;
|
|
522
|
-
let failed =
|
|
549
|
+
let failed = 0;
|
|
550
|
+
let consecutiveFailures = 0;
|
|
551
|
+
let errors: string[] = [];
|
|
552
|
+
let aborted = false;
|
|
523
553
|
let copyWorker = async () => {
|
|
524
|
-
while (!
|
|
554
|
+
while (!aborted && !this.store.stopped.stop && !state.stopped.stop) {
|
|
525
555
|
let index = nextIndex++;
|
|
526
556
|
if (index >= pending.length) return;
|
|
527
557
|
let { key, entry } = pending[index];
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
this.
|
|
558
|
+
try {
|
|
559
|
+
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
|
|
560
|
+
if (copied) {
|
|
561
|
+
// Only move the entry's source if it wasn't changed while we copied
|
|
562
|
+
if (this.entryUnchanged(key, entry)) {
|
|
563
|
+
this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
consecutiveFailures = 0;
|
|
567
|
+
} catch (e) {
|
|
568
|
+
// One failing file must not abort the sync: it stays on its source (reads still reach it there), and the next sync pass retries it
|
|
569
|
+
failed++;
|
|
570
|
+
consecutiveFailures++;
|
|
571
|
+
if (errors.length < SYNC_ERROR_LOG_LIMIT) {
|
|
572
|
+
errors.push(`${key}: ${(e as Error).stack ?? e}`);
|
|
533
573
|
}
|
|
574
|
+
if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
|
|
575
|
+
aborted = true;
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// A pause per failure, so a down network takes minutes to burn through the consecutive-failure allowance instead of an instant
|
|
579
|
+
await delay(SYNC_FAILURE_DELAY);
|
|
534
580
|
}
|
|
535
581
|
activity.doneFiles = (activity.doneFiles || 0) + 1;
|
|
536
582
|
activity.doneBytes = (activity.doneBytes || 0) + entry.size;
|
|
@@ -538,13 +584,12 @@ export class StoreSync {
|
|
|
538
584
|
};
|
|
539
585
|
let workers: Promise<void>[] = [];
|
|
540
586
|
for (let i = 0; i < Math.min(FULL_SYNC_PARALLEL, pending.length); i++) {
|
|
541
|
-
workers.push(copyWorker()
|
|
542
|
-
// Stop the other workers pulling new files, then surface the error
|
|
543
|
-
failed = true;
|
|
544
|
-
throw e;
|
|
545
|
-
}));
|
|
587
|
+
workers.push(copyWorker());
|
|
546
588
|
}
|
|
547
589
|
await Promise.all(workers);
|
|
590
|
+
if (failed) {
|
|
591
|
+
console.error(`Full sync from ${source.getDebugName()} (store ${this.store.folder}): ${failed} of ${pending.length} files failed to copy${aborted && ` before aborting the pass (${SYNC_MAX_CONSECUTIVE_FAILURES} consecutive failures - the source looks down; the next sync pass retries)` || " (they stay on their source, and the next sync pass retries them)"}. First errors: ${errors.join(" | ")}`);
|
|
592
|
+
}
|
|
548
593
|
} finally {
|
|
549
594
|
clearInterval(progressTimer);
|
|
550
595
|
clearInterval(slowErrorTimer);
|