sliftutils 1.7.48 → 1.7.50

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.
Files changed (35) hide show
  1. package/index.d.ts +99 -38
  2. package/package.json +1 -1
  3. package/storage/ArchivesDisk.d.ts +5 -5
  4. package/storage/ArchivesDisk.ts +19 -5
  5. package/storage/IArchives.d.ts +36 -7
  6. package/storage/IArchives.ts +76 -8
  7. package/storage/backblaze.d.ts +4 -4
  8. package/storage/backblaze.ts +89 -36
  9. package/storage/dist/ArchivesDisk.ts.cache +8 -2
  10. package/storage/dist/IArchives.ts.cache +2 -2
  11. package/storage/dist/backblaze.ts.cache +86 -36
  12. package/storage/remoteStorage/ArchivesRemote.d.ts +4 -5
  13. package/storage/remoteStorage/ArchivesRemote.ts +7 -7
  14. package/storage/remoteStorage/ArchivesUrl.d.ts +2 -1
  15. package/storage/remoteStorage/ArchivesUrl.ts +13 -3
  16. package/storage/remoteStorage/blobStore.d.ts +15 -6
  17. package/storage/remoteStorage/blobStore.ts +198 -115
  18. package/storage/remoteStorage/createArchives.d.ts +4 -5
  19. package/storage/remoteStorage/createArchives.ts +6 -11
  20. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
  21. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +15 -4
  22. package/storage/remoteStorage/dist/blobStore.ts.cache +194 -108
  23. package/storage/remoteStorage/dist/createArchives.ts.cache +4 -9
  24. package/storage/remoteStorage/dist/remoteConfig.ts.cache +15 -3
  25. package/storage/remoteStorage/dist/sourcesList.ts.cache +79 -0
  26. package/storage/remoteStorage/dist/storageController.ts.cache +7 -10
  27. package/storage/remoteStorage/dist/storageServerState.ts.cache +50 -18
  28. package/storage/remoteStorage/remoteConfig.d.ts +3 -1
  29. package/storage/remoteStorage/remoteConfig.ts +11 -1
  30. package/storage/remoteStorage/sourcesList.d.ts +15 -0
  31. package/storage/remoteStorage/sourcesList.ts +69 -0
  32. package/storage/remoteStorage/storageController.d.ts +4 -4
  33. package/storage/remoteStorage/storageController.ts +11 -14
  34. package/storage/remoteStorage/storageServerState.d.ts +3 -0
  35. package/storage/remoteStorage/storageServerState.ts +40 -15
@@ -4,14 +4,15 @@ 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, assertValidLastModified,
8
- windowAcceptsWrites, SyncActivity, FULL_ROUTE,
7
+ IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, assertValidLastModified,
8
+ windowAcceptsWrites, SyncActivity, FULL_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
13
  import { BulkDatabaseBase, noopReactiveDeps } from "../BulkDatabase2/BulkDatabaseBase";
14
14
  import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../FileFolderAPI";
15
+ import { SourcesList } from "./sourcesList";
15
16
 
16
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.
17
18
 
@@ -21,10 +22,11 @@ const FAST_FLUSH_POLL = 1000 * 15;
21
22
  export const WINDOW_END_FLUSH_MARGIN = timeInMinute * 5;
22
23
  // Index changes are buffered in memory and written to the BulkDatabase2 in batches
23
24
  const INDEX_FLUSH_INTERVAL = 1000 * 30;
24
- // Sources that support getChangesAfter are polled this often
25
+ // Sources with a native (index-backed) change feed are polled this often
25
26
  const CHANGES_POLL_INTERVAL = 1000 * 60;
26
- // Sources that don't support getChangesAfter get a full metadata rescan this often
27
+ // Full metadata rescans. supportsChangesAfter is the heuristic for "one of our own storage servers": their index-backed listings are cheap, so hourly is fine. Everything else (backblaze, plain disk) pays the full listing cost, so it rescans much less often.
27
28
  const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
29
+ const FULL_RESCAN_UNINDEXED_INTERVAL = 1000 * 60 * 60 * 6;
28
30
  // On a request for a file the index doesn't know, changes-after sources are re-polled, at most this often
29
31
  const MISS_CHECK_INTERVAL = 1000 * 5;
30
32
  // Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
@@ -40,6 +42,9 @@ const DISK_LIMIT_CHECK_INTERVAL = 1000 * 60;
40
42
  const FULL_SYNC_PARALLEL = 8;
41
43
  // 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
42
44
  const FULL_SYNC_SLOW_ERROR_INTERVAL = 1000 * 60 * 60;
45
+ // 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
+ const RECONCILE_MAX_CONSECUTIVE_FAILURES = 5;
47
+ const RECONCILE_ERROR_LOG_LIMIT = 3;
43
48
 
44
49
  export type WriteConfig = {
45
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.
@@ -57,7 +62,7 @@ export type IBucketStore = {
57
62
  del(fileName: string, config?: WriteConfig): Promise<void>;
58
63
  getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined>;
59
64
  findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
60
- getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
65
+ getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
61
66
  getSyncStatus?(): Promise<ArchivesSyncStatus>;
62
67
  getSyncProgress?(): {
63
68
  index: { fileCount: number; byteCount: number };
@@ -68,7 +73,7 @@ export type IBucketStore = {
68
73
  computeIndexTotals?(): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }>;
69
74
  startLargeUpload(): Promise<string>;
70
75
  appendLargeUpload(id: string, data: Buffer): Promise<void>;
71
- finishLargeUpload(id: string, key: string): Promise<void>;
76
+ finishLargeUpload(id: string, key: string, lastModified?: number): Promise<void>;
72
77
  cancelLargeUpload(id: string): Promise<void>;
73
78
  };
74
79
 
@@ -84,14 +89,14 @@ type BlobIndexEntry = {
84
89
  key: string;
85
90
  writeTime: number;
86
91
  size: number;
87
- // Which synchronization source currently holds the data (an index into the sources array)
88
- source: number;
92
+ // Which synchronization source currently holds the data: the line number of the source's URL in the store's append-only sources list (see SourcesList) - NOT a position in the in-memory sources array, which changes between runs
93
+ sourcesListIndex: number;
89
94
  };
90
95
  type IndexEntry = {
91
96
  writeTime: number;
92
97
  size: number;
93
- source: number;
94
- // When WE last changed this entry (not the file's write time) — what getChangesAfter filters on, so late-arriving files with old write times are still reported as changes
98
+ sourcesListIndex: number;
99
+ // 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 as changes
95
100
  changedAt: number;
96
101
  // In-memory only (not persisted): when the file was last served, for readerDiskLimit's LRU eviction. Starts as changedAt on load.
97
102
  lastAccess: number;
@@ -103,12 +108,12 @@ type SourceState = {
103
108
  scanComplete: boolean;
104
109
  // Files seen in this source's scans / change polls so far
105
110
  scannedCount: number;
106
- // Watermark for getChangesAfter polls
111
+ // Watermark for getChangesAfter2 polls
107
112
  changesAfterTime: number;
108
113
  lastMissCheck: number;
109
114
  // Per-slot stop token: a removed source's loops stop without touching the rest of the store
110
115
  stopped: { stop: boolean };
111
- // Source slots are stable forever (index entries reference sources by slot number), so a removed source's slot stays in the arrays, marked dead - never scanned, written, or read
116
+ // A removed source's slot stays in the arrays (they are never spliced, so slot numbers held by running loops stay meaningful), marked dead - never scanned, written, or read. Index entries don't reference slots at all; they persist the sources list's sourcesListIndex.
112
117
  dead?: boolean;
113
118
  };
114
119
 
@@ -116,6 +121,8 @@ type SourceState = {
116
121
  export type BlobSourceSpec = {
117
122
  // Matched against ArchivesSource.identity ("disk" for the base slot); equal identities pair off in order
118
123
  identity: string;
124
+ // See ArchivesSource.url
125
+ url: string;
119
126
  validWindow: [number, number];
120
127
  route?: [number, number];
121
128
  noFullSync?: boolean;
@@ -157,13 +164,15 @@ export class BlobStore implements IBucketStore {
157
164
  readerDiskLimit?: number;
158
165
  // Every accepted write ("original") and every write that actually reached the sources ("flushed"). Fast writes coalesce, so the two counts differ.
159
166
  onWriteCounted?: (kind: "original" | "flushed", bytes: number) => void;
167
+ // Resolves a persisted source URL (see ArchivesSource.url) to a cached IArchives, so entries whose holder is no longer configured can still be read
168
+ resolveSourceUrl?: (url: string) => IArchives;
160
169
  }
161
170
  ) { }
162
171
 
163
172
  private stopped = { stop: false };
164
173
 
165
- // The index's BulkDatabase2 files live under <folder>/index
166
- private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex", noopReactiveDeps, async (p: string) => {
174
+ // 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.
175
+ private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex2", noopReactiveDeps, async (p: string) => {
167
176
  let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(path.join(this.folder, "index"));
168
177
  for (let part of p.split("/")) {
169
178
  if (part) base = await base.getDirectoryHandle(part, { create: true });
@@ -184,12 +193,56 @@ export class BlobStore implements IBucketStore {
184
193
  private overlay = new Map<string, OverlayEntry>();
185
194
  private sourceStates = this.sources.map(() => newSourceState());
186
195
  private syncStarted = false;
196
+ // The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
197
+ private sourcesList = new SourcesList(path.join(this.folder, "index", "sourcesList.txt"));
198
+ // Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
199
+ private slotSourcesListIndexes: number[] = [];
200
+ private slotRegistrations: Promise<void>[] = [];
187
201
 
188
202
  private isLive(sourceIndex: number): boolean {
189
203
  return !!this.sources[sourceIndex] && !this.sourceStates[sourceIndex].dead;
190
204
  }
191
205
 
206
+ private registerSlot(slot: number): Promise<void> {
207
+ let existing = this.slotRegistrations[slot];
208
+ if (existing) return existing;
209
+ let registration = this.sourcesList.ensure(this.sources[slot].url).then(index => {
210
+ this.slotSourcesListIndexes[slot] = index;
211
+ });
212
+ this.slotRegistrations[slot] = registration;
213
+ return registration;
214
+ }
215
+
216
+ // The persistent sourcesListIndex of a slot - only valid once the slot's registration resolved (init and runSourceSync guarantee that before any indexing happens)
217
+ private sourcesListIndexOfSlot(slot: number): number {
218
+ let index = this.slotSourcesListIndexes[slot];
219
+ if (index === undefined) {
220
+ throw new Error(`Source slot ${slot} (${this.sources[slot]?.url}) has no registered sourcesListIndex yet (store ${this.folder})`);
221
+ }
222
+ return index;
223
+ }
224
+
225
+ // 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).
226
+ private slotForSourcesListIndex(sourcesListIndex: number): number | undefined {
227
+ for (let i = 0; i < this.slotSourcesListIndexes.length; i++) {
228
+ if (this.slotSourcesListIndexes[i] === sourcesListIndex && this.isLive(i)) return i;
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ // 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
234
+ private async getEntryHolder(entry: IndexEntry): Promise<IArchives | undefined> {
235
+ let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
236
+ if (slot !== undefined) return this.sources[slot].source;
237
+ let url = this.sourcesList.getUrl(entry.sourcesListIndex) || await this.sourcesList.getUrlReloading(entry.sourcesListIndex);
238
+ if (!url) return undefined;
239
+ return this.config?.resolveSourceUrl?.(url);
240
+ }
241
+
192
242
  public init = lazy(async () => {
243
+ for (let i = 0; i < this.sources.length; i++) {
244
+ await this.registerSlot(i);
245
+ }
193
246
  await this.loadIndex();
194
247
  this.syncStarted = true;
195
248
  for (let i = 0; i < this.sources.length; i++) {
@@ -215,23 +268,23 @@ export class BlobStore implements IBucketStore {
215
268
  }
216
269
 
217
270
  private async loadIndex(): Promise<void> {
218
- let [writeTimes, sizes, sources] = await Promise.all([
271
+ let [writeTimes, sizes, sourcesListIndexes] = await Promise.all([
219
272
  this.index.getColumn("writeTime"),
220
273
  this.index.getColumn("size"),
221
- this.index.getColumn("source"),
274
+ this.index.getColumn("sourcesListIndex"),
222
275
  ]);
223
276
  let sizeMap = new Map(sizes.map(x => [x.key, x.value]));
224
- let sourceMap = new Map(sources.map(x => [x.key, x.value]));
277
+ let sourcesListIndexMap = new Map(sourcesListIndexes.map(x => [x.key, x.value]));
225
278
  for (let entry of writeTimes) {
226
279
  let size = sizeMap.get(entry.key);
227
- let source = sourceMap.get(entry.key);
228
- // Explicit checks, as 0 is a valid size and a valid source number
229
- if (size === undefined || source === undefined) continue;
230
- // The routing config is only ever read off our own disk (see applyScanned), and a loaded bucket always has it there - a persisted entry pointing elsewhere is stale
280
+ let sourcesListIndex = sourcesListIndexMap.get(entry.key);
281
+ // Explicit checks, as 0 is a valid size and a valid sourcesListIndex
282
+ if (size === undefined || sourcesListIndex === undefined) continue;
283
+ // 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
231
284
  if (entry.key === ROUTING_FILE) {
232
- source = 0;
285
+ sourcesListIndex = this.sourcesListIndexOfSlot(0);
233
286
  }
234
- let full: IndexEntry = { writeTime: entry.value, size, source, changedAt: entry.time, lastAccess: entry.time };
287
+ let full: IndexEntry = { writeTime: entry.value, size, sourcesListIndex, changedAt: entry.time, lastAccess: entry.time };
235
288
  this.mem.set(entry.key, full);
236
289
  this.countEntry(full, 1);
237
290
  }
@@ -241,14 +294,15 @@ export class BlobStore implements IBucketStore {
241
294
  if (!entry || entry.size === 0) return;
242
295
  this.indexFileCount += direction;
243
296
  this.indexByteCount += entry.size * direction;
244
- // A stale entry can reference a source that no longer exists (getIndexEntry cleans those)
245
- if (this.sources[entry.source]) {
246
- this.sourceFileCounts[entry.source] += direction;
247
- this.sourceByteCounts[entry.source] += entry.size * direction;
297
+ // Entries can reference a source no longer configured (readable via getEntryHolder, but with no slot to count under)
298
+ let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
299
+ if (slot !== undefined) {
300
+ this.sourceFileCounts[slot] += direction;
301
+ this.sourceByteCounts[slot] += entry.size * direction;
248
302
  }
249
303
  }
250
304
 
251
- private setIndexEntry(key: string, entry: { writeTime: number; size: number; source: number }): void {
305
+ private setIndexEntry(key: string, entry: { writeTime: number; size: number; sourcesListIndex: number }): void {
252
306
  let full: IndexEntry = { ...entry, changedAt: Date.now(), lastAccess: Date.now() };
253
307
  this.countEntry(this.mem.get(key), -1);
254
308
  this.countEntry(full, 1);
@@ -305,7 +359,7 @@ export class BlobStore implements IBucketStore {
305
359
  continue;
306
360
  }
307
361
  let source = spec.create();
308
- this.sources.push({ source, validWindow: spec.validWindow, route: spec.route, noFullSync: spec.noFullSync, identity: spec.identity });
362
+ this.sources.push({ source, url: spec.url, validWindow: spec.validWindow, route: spec.route, noFullSync: spec.noFullSync, identity: spec.identity });
309
363
  this.sourceStates.push(newSourceState());
310
364
  this.sourceFileCounts.push(0);
311
365
  this.sourceByteCounts.push(0);
@@ -341,13 +395,19 @@ export class BlobStore implements IBucketStore {
341
395
  state.stopped.stop = true;
342
396
  state.scanComplete = true;
343
397
  state.initialScan.resolve(undefined);
344
- let dropped = 0;
345
- for (let [key, entry] of this.mem) {
346
- if (entry.source !== sourceIndex) continue;
347
- this.deleteIndexEntry(key);
348
- dropped++;
398
+ let sourcesListIndex = this.slotSourcesListIndexes[sourceIndex];
399
+ // The same URL can be another live slot (one entry per valid window) - the endpoint is still configured, so its entries stay
400
+ if (sourcesListIndex !== undefined && this.slotForSourcesListIndex(sourcesListIndex) === undefined) {
401
+ let dropped = 0;
402
+ for (let [key, entry] of this.mem) {
403
+ if (entry.sourcesListIndex !== sourcesListIndex) continue;
404
+ this.deleteIndexEntry(key);
405
+ dropped++;
406
+ }
407
+ console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
408
+ return;
349
409
  }
350
- console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
410
+ 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)`);
351
411
  }
352
412
 
353
413
  /** 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. */
@@ -361,15 +421,10 @@ export class BlobStore implements IBucketStore {
361
421
  await this.init();
362
422
  let scanStart = Date.now();
363
423
  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})`);
364
- let changes: ArchiveFileInfo[];
365
- if (source.getChangesAfter) {
366
- changes = await source.getChangesAfter(config.since);
367
- } else {
368
- changes = (await source.findInfo("")).filter(x => x.createTime > config.since);
369
- }
424
+ let changes = await source.getChangesAfter2({ time: config.since, routes: config.route && [config.route] || undefined });
370
425
  let tally = newScanTally();
371
426
  for (let file of changes) {
372
- if (file.path === ROUTING_FILE || !routeContains(config.route, getRoute(file.path))) {
427
+ if (file.path === ROUTING_FILE) {
373
428
  tally.filtered++;
374
429
  continue;
375
430
  }
@@ -382,19 +437,18 @@ export class BlobStore implements IBucketStore {
382
437
  }
383
438
  if (file.size === 0) {
384
439
  // A tombstone stores nothing on our own source - the index entry alone records it
385
- this.setIndexEntry(file.path, { writeTime: file.createTime, size: 0, source: 0 });
440
+ this.setIndexEntry(file.path, { writeTime: file.createTime, size: 0, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
386
441
  tally.tombstone++;
387
442
  continue;
388
443
  }
389
- let result = await source.get2(file.path);
390
- if (!result) continue;
391
- if (result.data.length === 0) {
392
- this.setIndexEntry(file.path, { writeTime: result.writeTime, size: 0, source: 0 });
444
+ let copied = await copyArchiveFile({ from: source, to: this.sources[0].source, path: file.path, size: file.size, writeTime: file.createTime, forceSetImmutable: true, noChecks: true });
445
+ if (!copied) continue;
446
+ if (copied.size === 0) {
447
+ this.setIndexEntry(file.path, { writeTime: copied.writeTime, size: 0, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
393
448
  tally.tombstone++;
394
449
  continue;
395
450
  }
396
- await this.sources[0].source.set(file.path, result.data, { lastModified: result.writeTime });
397
- this.setIndexEntry(file.path, { writeTime: result.writeTime, size: result.data.length, source: 0 });
451
+ this.setIndexEntry(file.path, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
398
452
  if (entry || overlayEntry) {
399
453
  tally.updated++;
400
454
  } else {
@@ -437,10 +491,10 @@ export class BlobStore implements IBucketStore {
437
491
  if (entry.size === 0) continue;
438
492
  fileCount++;
439
493
  byteCount += entry.size;
440
- let source = sources[entry.source];
441
- if (source) {
442
- source.fileCount++;
443
- source.byteCount += entry.size;
494
+ let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
495
+ if (slot !== undefined) {
496
+ sources[slot].fileCount++;
497
+ sources[slot].byteCount += entry.size;
444
498
  }
445
499
  }
446
500
  return { fileCount, byteCount, sources: sources.filter((x, i) => this.isLive(i)) };
@@ -454,7 +508,7 @@ export class BlobStore implements IBucketStore {
454
508
  let deletes: string[] = [];
455
509
  for (let [key, entry] of dirty) {
456
510
  if (entry) {
457
- writes.push({ key, writeTime: entry.writeTime, size: entry.size, source: entry.source });
511
+ writes.push({ key, writeTime: entry.writeTime, size: entry.size, sourcesListIndex: entry.sourcesListIndex });
458
512
  } else {
459
513
  deletes.push(key);
460
514
  }
@@ -466,6 +520,7 @@ export class BlobStore implements IBucketStore {
466
520
  // ── synchronization ──
467
521
 
468
522
  private async runSourceSync(sourceIndex: number): Promise<void> {
523
+ await this.registerSlot(sourceIndex);
469
524
  let { source } = this.sources[sourceIndex];
470
525
  let state = this.sourceStates[sourceIndex];
471
526
  // Read live for every pass, not captured - updateSources can change it while loops run
@@ -474,7 +529,7 @@ export class BlobStore implements IBucketStore {
474
529
  while (!this.stopped.stop && !state.stopped.stop) {
475
530
  try {
476
531
  let config = await source.getConfig();
477
- state.supportsChangesAfter = !!(config.supportsChangesAfter && source.getChangesAfter);
532
+ state.supportsChangesAfter = !!config.supportsChangesAfter;
478
533
  listing = await this.scanSource(sourceIndex);
479
534
  break;
480
535
  } catch (e) {
@@ -506,7 +561,7 @@ export class BlobStore implements IBucketStore {
506
561
  await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
507
562
  }, state.stopped);
508
563
  } else {
509
- runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
564
+ runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
510
565
  let rescan = await this.scanSource(sourceIndex);
511
566
  await this.reconcileSource(sourceIndex, rescan);
512
567
  if (!noFullSync()) await this.copySourceFiles(sourceIndex);
@@ -526,6 +581,7 @@ export class BlobStore implements IBucketStore {
526
581
  console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.folder})`);
527
582
  }, SYNC_PROGRESS_LOG_INTERVAL);
528
583
  (progressTimer as { unref?: () => void }).unref?.();
584
+ // 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.
529
585
  let files: ArchiveFileInfo[];
530
586
  try {
531
587
  files = await source.findInfo("");
@@ -544,15 +600,16 @@ export class BlobStore implements IBucketStore {
544
600
  if (!this.mem.has(file.path)) {
545
601
  newPaths++;
546
602
  }
547
- tally[this.applyScanned(sourceIndex, file)]++;
603
+ tally[this.updateScanIndex(sourceIndex, file)]++;
548
604
  }
549
605
  state.scannedCount = files.length;
550
606
  // Index entries this source was the holder of, but that vanished from it (e.g. deleted while we were offline), come out of the index. Entries changed after the scan started are kept — the scan listing may simply predate them. Tombstones have no physical file for a listing to vouch for, so they're exempt (cleanupTombstones expires them instead).
551
607
  let removedFromIndex = 0;
552
608
  let missingOnSource = 0;
609
+ let scannedSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
553
610
  for (let [key, entry] of this.mem) {
554
611
  if (seen.has(key)) continue;
555
- if (entry.source === sourceIndex && entry.size !== 0 && entry.changedAt < scanStart) {
612
+ if (entry.sourcesListIndex === scannedSourcesListIndex && entry.size !== 0 && entry.changedAt < scanStart) {
556
613
  this.deleteIndexEntry(key);
557
614
  removedFromIndex++;
558
615
  continue;
@@ -571,38 +628,61 @@ export class BlobStore implements IBucketStore {
571
628
  return seen;
572
629
  }
573
630
 
574
- // 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.
631
+ // 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.
575
632
  private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
576
633
  let { source, validWindow, route } = this.sources[sourceIndex];
577
634
  let state = this.sourceStates[sourceIndex];
578
635
  let acceptsWrites = windowAcceptsWrites(validWindow);
579
- try {
580
- for (let [key, entry] of this.mem) {
581
- if (this.stopped.stop || state.stopped.stop) return;
582
- if (entry.source === sourceIndex) continue;
583
- // The routing file is NEVER synchronized between storage nodes - it is only ever written directly to each node, and only ever read off our own disk
584
- if (key === ROUTING_FILE) continue;
585
- if (!acceptsWrites) continue;
586
- if (!routeContains(route, getRoute(key))) continue;
587
- let theirTime = listing.get(key);
588
- if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
636
+ let targetSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
637
+ let pushed = 0;
638
+ let failed = 0;
639
+ let consecutiveFailures = 0;
640
+ let errors: string[] = [];
641
+ let aborted = false;
642
+ for (let [key, entry] of this.mem) {
643
+ if (this.stopped.stop || state.stopped.stop) return;
644
+ if (entry.sourcesListIndex === targetSourcesListIndex) continue;
645
+ // The routing file is NEVER synchronized between storage nodes - it is only ever written directly to each node, and only ever read off our own disk
646
+ if (key === ROUTING_FILE) continue;
647
+ if (!acceptsWrites) continue;
648
+ if (!routeContains(route, getRoute(key))) continue;
649
+ let theirTime = listing.get(key);
650
+ if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
651
+ try {
589
652
  if (entry.size === 0) {
590
653
  // A deletion only needs pushing while the source still holds an older copy
591
654
  if (theirTime === undefined) continue;
592
- await source.set(key, Buffer.alloc(0), { lastModified: entry.writeTime });
655
+ await source.set(key, Buffer.alloc(0), { lastModified: entry.writeTime, forceSetImmutable: true, noChecks: true });
656
+ pushed++;
657
+ consecutiveFailures = 0;
593
658
  continue;
594
659
  }
595
- let result = await this.sources[entry.source].source.get2(key);
596
- if (!result) continue;
597
- await source.set(key, result.data, { lastModified: result.writeTime });
660
+ let holder = await this.getEntryHolder(entry);
661
+ if (!holder) continue;
662
+ let copied = await copyArchiveFile({ from: holder, to: source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true });
663
+ if (!copied) continue;
664
+ pushed++;
665
+ consecutiveFailures = 0;
666
+ } catch (e) {
667
+ failed++;
668
+ consecutiveFailures++;
669
+ if (errors.length < RECONCILE_ERROR_LOG_LIMIT) {
670
+ errors.push(`${key}: ${(e as Error).stack ?? e}`);
671
+ }
672
+ if (consecutiveFailures >= RECONCILE_MAX_CONSECUTIVE_FAILURES) {
673
+ aborted = true;
674
+ break;
675
+ }
598
676
  }
599
- } catch (e) {
600
- // Abort the pass instead of logging per file; the next scan cycle retries
601
- console.error(`Reconciling sync source ${source.getDebugName()} failed: ${(e as Error).stack ?? e}`);
677
+ }
678
+ if (failed) {
679
+ console.error(`Reconciling sync source ${source.getDebugName()} (store ${this.folder}): pushed ${pushed} files, ${failed} failed${aborted && ` before aborting the pass (${consecutiveFailures} consecutive failures - the source looks down; the next scan cycle retries)` || ""}. First errors: ${errors.join(" | ")}`);
680
+ } else if (pushed) {
681
+ console.log(`Reconciled sync source ${source.getDebugName()} (store ${this.folder}): pushed ${pushed} files it was missing or held older copies of`);
602
682
  }
603
683
  }
604
684
 
605
- private applyScanned(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
685
+ private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
606
686
  // An in-flight scan can outlive its source's removal; its results are dead
607
687
  if (!this.isLive(sourceIndex)) return "filtered";
608
688
  if (file.path === ROUTING_FILE) {
@@ -618,21 +698,20 @@ export class BlobStore implements IBucketStore {
618
698
  let existing = this.mem.get(file.path);
619
699
  // The highest write time wins across all sources (ties keep the existing entry)
620
700
  if (existing && file.createTime <= existing.writeTime) return "unchanged";
621
- this.setIndexEntry(file.path, { writeTime: file.createTime, size: file.size, source: sourceIndex });
701
+ this.setIndexEntry(file.path, { writeTime: file.createTime, size: file.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
622
702
  if (file.size === 0) return "tombstone";
623
703
  if (existing) return "updated";
624
704
  return "new";
625
705
  }
626
706
 
627
707
  private async pollChanges(sourceIndex: number): Promise<void> {
628
- let { source } = this.sources[sourceIndex];
629
- if (!source.getChangesAfter) return;
708
+ let { source, route } = this.sources[sourceIndex];
630
709
  let state = this.sourceStates[sourceIndex];
631
710
  let pollStart = Date.now();
632
- let changes = await source.getChangesAfter(state.changesAfterTime);
711
+ let changes = await source.getChangesAfter2({ time: state.changesAfterTime, routes: route && [route] || undefined });
633
712
  let tally = newScanTally();
634
713
  for (let file of changes) {
635
- tally[this.applyScanned(sourceIndex, file)]++;
714
+ tally[this.updateScanIndex(sourceIndex, file)]++;
636
715
  }
637
716
  // Polls run constantly, so only the ones that actually changed the index get a line
638
717
  if (tally.new || tally.updated || tally.tombstone) {
@@ -649,8 +728,9 @@ export class BlobStore implements IBucketStore {
649
728
  let state = this.sourceStates[sourceIndex];
650
729
  let pending: { key: string; entry: IndexEntry }[] = [];
651
730
  let totalBytes = 0;
731
+ let copiedSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
652
732
  for (let [key, entry] of this.mem) {
653
- if (entry.source !== sourceIndex) continue;
733
+ if (entry.sourcesListIndex !== copiedSourcesListIndex) continue;
654
734
  if (entry.size === 0) continue;
655
735
  pending.push({ key, entry });
656
736
  totalBytes += entry.size;
@@ -695,12 +775,11 @@ export class BlobStore implements IBucketStore {
695
775
  let index = nextIndex++;
696
776
  if (index >= pending.length) return;
697
777
  let { key, entry } = pending[index];
698
- let result = await source.get2(key);
699
- if (result) {
700
- await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime });
778
+ let copied = await copyArchiveFile({ from: source, to: this.sources[0].source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true });
779
+ if (copied) {
701
780
  // Only move the entry's source if it wasn't changed while we copied
702
781
  if (this.mem.get(key) === entry) {
703
- this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: 0 });
782
+ this.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
704
783
  }
705
784
  }
706
785
  activity.doneFiles = (activity.doneFiles || 0) + 1;
@@ -727,7 +806,7 @@ export class BlobStore implements IBucketStore {
727
806
  }
728
807
  }
729
808
 
730
- // findInfo and getChangesAfter list from the index, so they must wait for our own base source's initial scan (which might lag minutes) before the listing is trustworthy. The base (local disk) is implicitly required - remote sources are not, they come and go.
809
+ // findInfo and getChangesAfter2 list from the index, so they must wait for our own base source's initial scan (which might lag minutes) before the listing is trustworthy. The base (local disk) is implicitly required - remote sources are not, they come and go.
731
810
  private async waitForRequiredScans(): Promise<void> {
732
811
  await this.sourceStates[0].initialScan.promise;
733
812
  }
@@ -741,7 +820,7 @@ export class BlobStore implements IBucketStore {
741
820
  if (i === 0 && !state.scanComplete) {
742
821
  let info = await source.getInfo(key);
743
822
  if (info) {
744
- this.applyScanned(i, { path: key, createTime: info.writeTime, size: info.size });
823
+ this.updateScanIndex(i, { path: key, createTime: info.writeTime, size: info.size });
745
824
  }
746
825
  continue;
747
826
  }
@@ -753,12 +832,9 @@ export class BlobStore implements IBucketStore {
753
832
  }
754
833
 
755
834
  private async getIndexEntry(key: string): Promise<IndexEntry | undefined> {
835
+ // An entry whose holder is no longer in the source list is still valid - getEntryHolder resolves the persisted URL directly (and get2's fallback loop covers a holder that is gone entirely)
756
836
  let entry = this.mem.get(key);
757
- if (entry && this.isLive(entry.source)) return entry;
758
- if (entry) {
759
- // The source list changed and this entry's source no longer exists; treat as missing
760
- this.deleteIndexEntry(key);
761
- }
837
+ if (entry) return entry;
762
838
  await this.checkMissingKey(key);
763
839
  return this.mem.get(key);
764
840
  }
@@ -788,17 +864,19 @@ export class BlobStore implements IBucketStore {
788
864
  if (!entry) return undefined;
789
865
  if (entry.size === 0) return undefined;
790
866
  entry.lastAccess = Date.now();
791
- let holder = entry.source;
867
+ let holderArchives = await this.getEntryHolder(entry);
792
868
  let result: { data: Buffer; writeTime: number; size: number } | undefined;
793
869
  let holderError: Error | undefined;
794
- try {
795
- result = await this.sources[holder].source.get2(key, { range });
796
- } catch (e) {
797
- holderError = e as Error;
870
+ if (holderArchives) {
871
+ try {
872
+ result = await holderArchives.get2(key, { range });
873
+ } catch (e) {
874
+ holderError = e as Error;
875
+ }
798
876
  }
799
877
  if (result) {
800
878
  // Ranged reads can't populate a cache (they're partial)
801
- if (holder !== 0 && !range) {
879
+ if (this.slotForSourcesListIndex(entry.sourcesListIndex) !== 0 && !range) {
802
880
  await this.cacheRead(key, result);
803
881
  }
804
882
  return result;
@@ -809,8 +887,9 @@ export class BlobStore implements IBucketStore {
809
887
  return undefined;
810
888
  }
811
889
  // 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.
890
+ let holderSlot = this.slotForSourcesListIndex(entry.sourcesListIndex);
812
891
  for (let i = 0; i < this.sources.length; i++) {
813
- if (i === holder || !this.isLive(i)) continue;
892
+ if (i === holderSlot || !this.isLive(i)) continue;
814
893
  let fallback: { data: Buffer; writeTime: number; size: number } | undefined;
815
894
  try {
816
895
  fallback = await this.sources[i].source.get2(key);
@@ -833,8 +912,8 @@ export class BlobStore implements IBucketStore {
833
912
 
834
913
  // The read's bytes came from a remote source, so write them onto our own base source (the local disk), which becomes the entry's new holder - reads only pay the remote fetch once
835
914
  private async cacheRead(key: string, result: { data: Buffer; writeTime: number }): Promise<void> {
836
- await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime });
837
- this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: 0 });
915
+ await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime, forceSetImmutable: true, noChecks: true });
916
+ this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
838
917
  }
839
918
 
840
919
  public async set(key: string, data: Buffer, config?: WriteConfig): Promise<string> {
@@ -901,15 +980,15 @@ export class BlobStore implements IBucketStore {
901
980
  // A tombstone stores nothing on our own source - the index entry alone records it
902
981
  await this.sources[first].source.del(key);
903
982
  } else {
904
- await this.sources[first].source.set(key, data, { lastModified: writeTime });
983
+ await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
905
984
  }
906
- this.setIndexEntry(key, { writeTime, size: data.length, source: first });
985
+ this.setIndexEntry(key, { writeTime, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(first) });
907
986
  if (isRouting) return;
908
987
  let route = getRoute(key);
909
988
  for (let i of writable) {
910
989
  if (!routeContains(this.sources[i].route, route)) continue;
911
990
  // Downstream sources receive tombstones as actual empty writes, so their listings show the deletion (size 0) and other stores scan it in as a tombstone
912
- void this.sources[i].source.set(key, data, { lastModified: writeTime }).catch((e: Error) => {
991
+ void this.sources[i].source.set(key, data, { lastModified: writeTime, forceSetImmutable: true, noChecks: true }).catch((e: Error) => {
913
992
  console.error(`Background write of ${key} to sync source ${this.sources[i].source.getDebugName()} failed: ${e.stack ?? e}`);
914
993
  });
915
994
  }
@@ -949,18 +1028,21 @@ export class BlobStore implements IBucketStore {
949
1028
  return files;
950
1029
  }
951
1030
 
952
- // All files changed after the given 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.
953
- public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
1031
+ // 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.
1032
+ public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
954
1033
  await this.init();
955
1034
  await this.waitForRequiredScans();
1035
+ let inRoutes = (key: string) => !config.routes || config.routes.some(route => routeContains(route, getRoute(key)));
956
1036
  let files: ArchiveFileInfo[] = [];
957
1037
  for (let [key, entry] of this.mem) {
958
- if (entry.changedAt <= time) continue;
1038
+ if (entry.changedAt <= config.time) continue;
959
1039
  if (this.overlay.has(key)) continue;
1040
+ if (!inRoutes(key)) continue;
960
1041
  files.push({ path: key, createTime: entry.writeTime, size: entry.size });
961
1042
  }
962
1043
  for (let [key, overlayEntry] of this.overlay) {
963
- if (overlayEntry.t <= time) continue;
1044
+ if (overlayEntry.t <= config.time) continue;
1045
+ if (!inRoutes(key)) continue;
964
1046
  files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
965
1047
  }
966
1048
  sort(files, x => x.path);
@@ -1001,13 +1083,13 @@ export class BlobStore implements IBucketStore {
1001
1083
  public async appendLargeUpload(id: string, data: Buffer): Promise<void> {
1002
1084
  await this.getDiskSource().disk.appendLargeUpload(id, data);
1003
1085
  }
1004
- public async finishLargeUpload(id: string, key: string): Promise<void> {
1086
+ public async finishLargeUpload(id: string, key: string, lastModified?: number): Promise<void> {
1005
1087
  let { disk, sourceIndex } = this.getDiskSource();
1006
- await disk.finishLargeUpload(id, key);
1088
+ await disk.finishLargeUpload(id, key, lastModified);
1007
1089
  this.overlay.delete(key);
1008
1090
  let info = await disk.getInfo(key);
1009
1091
  if (info) {
1010
- this.setIndexEntry(key, { writeTime: info.writeTime, size: info.size, source: sourceIndex });
1092
+ this.setIndexEntry(key, { writeTime: info.writeTime, size: info.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
1011
1093
  }
1012
1094
  }
1013
1095
  public async cancelLargeUpload(id: string): Promise<void> {
@@ -1036,9 +1118,10 @@ export class BlobStore implements IBucketStore {
1036
1118
  let evictedFiles = 0;
1037
1119
  let evictedBytes = 0;
1038
1120
  try {
1121
+ let baseSourcesListIndex = this.sourcesListIndexOfSlot(0);
1039
1122
  let candidates: { key: string; entry: IndexEntry }[] = [];
1040
1123
  for (let [key, entry] of this.mem) {
1041
- if (entry.source !== 0 || entry.size === 0 || key === ROUTING_FILE) continue;
1124
+ if (entry.sourcesListIndex !== baseSourcesListIndex || entry.size === 0 || key === ROUTING_FILE) continue;
1042
1125
  candidates.push({ key, entry });
1043
1126
  }
1044
1127
  sort(candidates, x => x.entry.lastAccess);
@@ -1061,7 +1144,7 @@ export class BlobStore implements IBucketStore {
1061
1144
  }
1062
1145
  if (holder === undefined) continue;
1063
1146
  await this.sources[0].source.del(key);
1064
- this.setIndexEntry(key, { writeTime: entry.writeTime, size: entry.size, source: holder });
1147
+ this.setIndexEntry(key, { writeTime: entry.writeTime, size: entry.size, sourcesListIndex: this.sourcesListIndexOfSlot(holder) });
1065
1148
  evictedFiles++;
1066
1149
  evictedBytes += entry.size;
1067
1150
  }