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.
@@ -195,6 +195,7 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
195
195
  uploadPart: (config: {
196
196
  uploadId: string;
197
197
  data: Buffer;
198
+ offset?: number;
198
199
  }) => Promise<void>;
199
200
  finishLargeFile: (config: {
200
201
  uploadId: string;
@@ -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
- async uploadPart(config: { uploadId: string; data: Buffer }): Promise<void> {
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
- // 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
36
- const RECONCILE_MAX_CONSECUTIVE_FAILURES = 5;
37
- const RECONCILE_ERROR_LOG_LIMIT = 3;
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
- files = await source.findInfo("");
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 < RECONCILE_ERROR_LOG_LIMIT) {
443
+ if (errors.length < SYNC_ERROR_LOG_LIMIT) {
419
444
  errors.push(`${key}: ${(e as Error).stack ?? e}`);
420
445
  }
421
- if (consecutiveFailures >= RECONCILE_MAX_CONSECUTIVE_FAILURES) {
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 = false;
549
+ let failed = 0;
550
+ let consecutiveFailures = 0;
551
+ let errors: string[] = [];
552
+ let aborted = false;
523
553
  let copyWorker = async () => {
524
- while (!failed && !this.store.stopped.stop && !state.stopped.stop) {
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
- 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 });
529
- if (copied) {
530
- // Only move the entry's source if it wasn't changed while we copied
531
- if (this.entryUnchanged(key, entry)) {
532
- this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
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().catch((e: Error) => {
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);