sliftutils 1.7.10 → 1.7.11

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 (83) hide show
  1. package/examplestorage/exampleserver.ts +0 -2
  2. package/index.d.ts +290 -54
  3. package/misc/dist/environment.ts.cache +3 -3
  4. package/misc/dist/getSecret.ts.cache +139 -0
  5. package/misc/dist/strings.ts.cache +13 -0
  6. package/misc/dist/zip.ts.cache +9 -0
  7. package/misc/getSecret.ts +38 -16
  8. package/misc/https/cloudflareHelpers.d.ts +19 -0
  9. package/misc/https/cloudflareHelpers.ts +69 -0
  10. package/misc/https/dist/certs.ts.cache +115 -179
  11. package/misc/https/dist/cloudflareHelpers.ts.cache +68 -0
  12. package/misc/https/dist/dns.ts.cache +112 -100
  13. package/misc/https/dist/hostServer.ts.cache +130 -0
  14. package/misc/https/dist/httpsCerts.ts.cache +24 -5
  15. package/misc/https/dist/persistentLocalStorage.ts.cache +3 -2
  16. package/misc/https/dns.d.ts +41 -12
  17. package/misc/https/dns.ts +104 -101
  18. package/misc/https/hostServer.d.ts +0 -6
  19. package/misc/https/hostServer.ts +9 -5
  20. package/package.json +2 -2
  21. package/render-utils/dist/observer.tsx.cache +3 -2
  22. package/spec.txt +0 -90
  23. package/storage/ArchivesDisk.d.ts +2 -0
  24. package/storage/ArchivesDisk.ts +7 -3
  25. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +1242 -0
  26. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +513 -0
  27. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +352 -0
  28. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +306 -0
  29. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +435 -0
  30. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +58 -0
  31. package/storage/BulkDatabase2/dist/blockCache.ts.cache +179 -0
  32. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +149 -0
  33. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +101 -0
  34. package/storage/BulkDatabase2/dist/streamLog.ts.cache +227 -0
  35. package/storage/BulkDatabase2/dist/syncClient.ts.cache +104 -0
  36. package/storage/IArchives.d.ts +47 -15
  37. package/storage/IArchives.ts +86 -31
  38. package/storage/backblaze.d.ts +15 -0
  39. package/storage/backblaze.ts +199 -79
  40. package/storage/dist/ArchivesDisk.ts.cache +373 -0
  41. package/storage/dist/FileFolderAPI.tsx.cache +508 -65
  42. package/storage/dist/IArchives.ts.cache +44 -0
  43. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +8 -2
  44. package/storage/dist/JSONStorage.ts.cache +6 -2
  45. package/storage/dist/PendingManager.tsx.cache +1 -1
  46. package/storage/dist/TransactionStorage.ts.cache +231 -92
  47. package/storage/dist/backblaze.ts.cache +797 -0
  48. package/storage/dist/fileSystemPointer.ts.cache +36 -3
  49. package/storage/dist/remoteFileStorage.ts.cache +495 -0
  50. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -1
  51. package/storage/remoteStorage/ArchivesRemote.ts +28 -8
  52. package/storage/remoteStorage/ArchivesUrl.d.ts +2 -0
  53. package/storage/remoteStorage/ArchivesUrl.ts +16 -4
  54. package/storage/remoteStorage/blobStore.d.ts +52 -3
  55. package/storage/remoteStorage/blobStore.ts +414 -101
  56. package/storage/remoteStorage/createArchives.d.ts +31 -10
  57. package/storage/remoteStorage/createArchives.ts +470 -253
  58. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +197 -0
  59. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +85 -0
  60. package/storage/remoteStorage/dist/accessPage.tsx.cache +222 -0
  61. package/storage/remoteStorage/dist/blobStore.ts.cache +837 -0
  62. package/storage/remoteStorage/dist/cliArgs.ts.cache +16 -0
  63. package/storage/remoteStorage/dist/createArchives.ts.cache +610 -0
  64. package/storage/remoteStorage/dist/remoteConfig.ts.cache +206 -0
  65. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +223 -0
  66. package/storage/remoteStorage/dist/storageController.ts.cache +500 -0
  67. package/storage/remoteStorage/dist/storageServer.ts.cache +106 -0
  68. package/storage/remoteStorage/dist/storageServerCli.ts.cache +39 -0
  69. package/storage/remoteStorage/dist/storageServerState.ts.cache +518 -0
  70. package/storage/remoteStorage/remoteConfig.d.ts +8 -0
  71. package/storage/remoteStorage/remoteConfig.ts +105 -5
  72. package/storage/remoteStorage/sourceWrapper.d.ts +41 -0
  73. package/storage/remoteStorage/sourceWrapper.ts +223 -0
  74. package/storage/remoteStorage/spec.md +31 -0
  75. package/storage/remoteStorage/storageController.d.ts +11 -0
  76. package/storage/remoteStorage/storageController.ts +109 -40
  77. package/storage/remoteStorage/storageServer.d.ts +0 -5
  78. package/storage/remoteStorage/storageServer.ts +17 -10
  79. package/storage/remoteStorage/storageServerCli.ts +0 -3
  80. package/storage/remoteStorage/storageServerState.d.ts +7 -1
  81. package/storage/remoteStorage/storageServerState.ts +230 -48
  82. package/testsite/server.ts +0 -2
  83. package/yarn.lock +11 -6
@@ -2,17 +2,21 @@ import path from "path";
2
2
  import { lazy } from "socket-function/src/caching";
3
3
  import { runInfinitePoll, delay } from "socket-function/src/batching";
4
4
  import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
5
+ import { formatNumber } from "socket-function/src/formatting/format";
5
6
  import {
6
7
  IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, assertValidLastModified,
8
+ windowAcceptsWrites, SyncActivity,
7
9
  } from "../IArchives";
8
10
  import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
11
+ import { ArchivesBackblaze } from "../backblaze";
12
+ import { ROUTING_FILE, getRoute, routeContains } from "./remoteConfig";
9
13
  import { BulkDatabaseBase, noopReactiveDeps } from "../BulkDatabase2/BulkDatabaseBase";
10
14
  import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../FileFolderAPI";
11
15
 
12
16
  // The storage engine of the remote storage server. Data lives in synchronization sources (at
13
17
  // minimum an ArchivesDisk, the local disk); BlobStore keeps an index of every file (path, last
14
18
  // modified time, size, and which source currently holds the data) in a BulkDatabase2, and
15
- // synchronizes the index from all sources (see ArchivesSource / SyncOptions in IArchives.ts).
19
+ // synchronizes the index from all sources (see ArchivesSource in IArchives.ts).
16
20
  // Every startup fully rescans each source's metadata, so the index self-heals; the file with the
17
21
  // highest write time wins across all sources, so multiple sources need no stacking order.
18
22
 
@@ -30,6 +34,16 @@ const MISS_CHECK_INTERVAL = 1000 * 5;
30
34
  // Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
31
35
  const CHANGES_POLL_OVERLAP = timeInMinute;
32
36
  const SCAN_RETRY_DELAY = 1000 * 30;
37
+ // Deletes are tombstones (an empty file IS a missing file): the size-0 index entry is what lets a
38
+ // deletion propagate/reconcile like any other write, and it expires after this long
39
+ const TOMBSTONE_EXPIRY = 1000 * 60 * 60 * 24 * 7;
40
+ const TOMBSTONE_CLEANUP_INTERVAL = 1000 * 60 * 60;
41
+ // While a metadata scan or full sync is running, its progress is logged this often
42
+ const SYNC_PROGRESS_LOG_INTERVAL = 1000 * 60;
43
+ const DISK_LIMIT_CHECK_INTERVAL = 1000 * 60;
44
+ // Full syncs download this many files concurrently (high-latency sources like backblaze would
45
+ // otherwise crawl one round-trip at a time)
46
+ const FULL_SYNC_PARALLEL = 8;
33
47
 
34
48
  export type WriteConfig = {
35
49
  // Resolve once the write is in memory; flush to the sources after writeDelay, coalescing
@@ -45,13 +59,15 @@ export type WriteConfig = {
45
59
  // also satisfies it (used directly for rawDisk buckets), minus the optional index-backed methods.
46
60
  export type IBucketStore = {
47
61
  get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined>;
48
- get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined>;
62
+ get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
49
63
  set(fileName: string, data: Buffer, config?: WriteConfig): Promise<void>;
50
64
  del(fileName: string, config?: WriteConfig): Promise<void>;
51
65
  getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined>;
52
66
  findInfo(prefix: string, config?: { shallow?: boolean; type?: "files" | "folders" }): Promise<ArchiveFileInfo[]>;
53
67
  getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
54
68
  getSyncStatus?(): Promise<ArchivesSyncStatus>;
69
+ getSyncProgress?(): { index: { fileCount: number; byteCount: number }; syncing: SyncActivity[] };
70
+ computeIndexTotals?(): Promise<{ fileCount: number; byteCount: number }>;
55
71
  startLargeUpload(): Promise<string>;
56
72
  appendLargeUpload(id: string, data: Buffer): Promise<void>;
57
73
  finishLargeUpload(id: string, key: string): Promise<void>;
@@ -59,8 +75,8 @@ export type IBucketStore = {
59
75
  };
60
76
 
61
77
  type OverlayEntry = {
62
- // undefined data means a pending delete
63
- data: Buffer | undefined;
78
+ // A zero-length buffer is a pending delete (tombstone)
79
+ data: Buffer;
64
80
  t: number;
65
81
  flushAt: number;
66
82
  };
@@ -80,6 +96,9 @@ type IndexEntry = {
80
96
  // When WE last changed this entry (not the file's write time) — what getChangesAfter filters
81
97
  // on, so late-arriving files with old write times are still reported as changes
82
98
  changedAt: number;
99
+ // In-memory only (not persisted): when the file was last served, for readerDiskLimit's LRU
100
+ // eviction. Starts as changedAt on load.
101
+ lastAccess: number;
83
102
  };
84
103
 
85
104
  type SourceState = {
@@ -101,6 +120,8 @@ export class BlobStore implements IBucketStore {
101
120
  // Called whenever a key's index entry changes (our own writes AND files pulled in via
102
121
  // synchronization) — how the storage server notices routing config updates.
103
122
  onIndexChanged?: (key: string) => void;
123
+ // LRU-bound the disk (base source) to this many bytes; see CommonConfig.readerDiskLimit
124
+ readerDiskLimit?: number;
104
125
  }
105
126
  ) { }
106
127
 
@@ -117,6 +138,16 @@ export class BlobStore implements IBucketStore {
117
138
  // The in-memory copy of the index. All reads are served from it; changes are buffered in
118
139
  // dirty and flushed to the BulkDatabase2 in batches (undefined = delete).
119
140
  private mem = new Map<string, IndexEntry>();
141
+ // Live totals over mem (tombstones excluded), adjusted on every mutation and recomputed on
142
+ // load - so any drift heals on restart. computeIndexTotals gives the walk-the-index truth.
143
+ private indexFileCount = 0;
144
+ private indexByteCount = 0;
145
+ // The same totals per holding source (index 0 = our disk, which readerDiskLimit bounds)
146
+ private sourceFileCounts = this.sources.map(() => 0);
147
+ private sourceByteCounts = this.sources.map(() => 0);
148
+ // Background scans / full syncs currently in progress (a Set - one source can have a change
149
+ // poll's full sync and a rescan overlapping)
150
+ private syncActivities = new Set<SyncActivity>();
120
151
  private dirty = new Map<string, IndexEntry | undefined>();
121
152
  private overlay = new Map<string, OverlayEntry>();
122
153
  private sourceStates = this.sources.map((): SourceState => ({
@@ -135,6 +166,10 @@ export class BlobStore implements IBucketStore {
135
166
  }
136
167
  runInfinitePoll(FAST_FLUSH_POLL, () => this.flushOverlay(), this.stopped);
137
168
  runInfinitePoll(INDEX_FLUSH_INTERVAL, () => this.flushIndex(), this.stopped);
169
+ runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.stopped);
170
+ if (this.config?.readerDiskLimit) {
171
+ runInfinitePoll(DISK_LIMIT_CHECK_INTERVAL, () => this.enforceDiskLimit(), this.stopped);
172
+ }
138
173
  });
139
174
 
140
175
  // Stops all synchronization scans/polls and flushes pending writes. Used when a bucket's
@@ -158,22 +193,82 @@ export class BlobStore implements IBucketStore {
158
193
  let source = sourceMap.get(entry.key);
159
194
  // Explicit checks, as 0 is a valid size and a valid source number
160
195
  if (size === undefined || source === undefined) continue;
161
- this.mem.set(entry.key, { writeTime: entry.value, size, source, changedAt: entry.time });
196
+ let full: IndexEntry = { writeTime: entry.value, size, source, changedAt: entry.time, lastAccess: entry.time };
197
+ this.mem.set(entry.key, full);
198
+ this.countEntry(full, 1);
199
+ }
200
+ }
201
+
202
+ private countEntry(entry: IndexEntry | undefined, direction: number): void {
203
+ if (!entry || entry.size === 0) return;
204
+ this.indexFileCount += direction;
205
+ this.indexByteCount += entry.size * direction;
206
+ // A stale entry can reference a source that no longer exists (getIndexEntry cleans those)
207
+ if (this.sources[entry.source]) {
208
+ this.sourceFileCounts[entry.source] += direction;
209
+ this.sourceByteCounts[entry.source] += entry.size * direction;
162
210
  }
163
211
  }
164
212
 
165
213
  private setIndexEntry(key: string, entry: { writeTime: number; size: number; source: number }): void {
166
- let full: IndexEntry = { ...entry, changedAt: Date.now() };
214
+ let full: IndexEntry = { ...entry, changedAt: Date.now(), lastAccess: Date.now() };
215
+ this.countEntry(this.mem.get(key), -1);
216
+ this.countEntry(full, 1);
167
217
  this.mem.set(key, full);
168
218
  this.dirty.set(key, full);
169
219
  this.config?.onIndexChanged?.(key);
170
220
  }
171
221
  private deleteIndexEntry(key: string): void {
172
- if (!this.mem.has(key)) return;
222
+ let existing = this.mem.get(key);
223
+ if (!existing) return;
224
+ this.countEntry(existing, -1);
173
225
  this.mem.delete(key);
174
226
  this.dirty.set(key, undefined);
175
227
  }
176
228
 
229
+ /** The cheap always-current totals plus any in-progress background synchronization. */
230
+ public getSyncProgress(): {
231
+ index: { fileCount: number; byteCount: number };
232
+ sources: { debugName: string; fileCount: number; byteCount: number }[];
233
+ readerDiskLimit?: number;
234
+ syncing: SyncActivity[];
235
+ } {
236
+ return {
237
+ index: { fileCount: this.indexFileCount, byteCount: this.indexByteCount },
238
+ sources: this.sources.map((x, i) => ({
239
+ debugName: x.source.getDebugName(),
240
+ fileCount: this.sourceFileCounts[i],
241
+ byteCount: this.sourceByteCounts[i],
242
+ })),
243
+ readerDiskLimit: this.config?.readerDiskLimit,
244
+ syncing: [...this.syncActivities],
245
+ };
246
+ }
247
+
248
+ /** Walks the whole index for exact totals - more expensive than getSyncProgress, but immune to
249
+ * any drift in the maintained counters (and loads the index first, so it's never cold zeros). */
250
+ public async computeIndexTotals(): Promise<{
251
+ fileCount: number;
252
+ byteCount: number;
253
+ sources: { debugName: string; fileCount: number; byteCount: number }[];
254
+ }> {
255
+ await this.init();
256
+ let fileCount = 0;
257
+ let byteCount = 0;
258
+ let sources = this.sources.map(x => ({ debugName: x.source.getDebugName(), fileCount: 0, byteCount: 0 }));
259
+ for (let entry of this.mem.values()) {
260
+ if (entry.size === 0) continue;
261
+ fileCount++;
262
+ byteCount += entry.size;
263
+ let source = sources[entry.source];
264
+ if (source) {
265
+ source.fileCount++;
266
+ source.byteCount += entry.size;
267
+ }
268
+ }
269
+ return { fileCount, byteCount, sources };
270
+ }
271
+
177
272
  private async flushIndex(): Promise<void> {
178
273
  if (!this.dirty.size) return;
179
274
  let dirty = this.dirty;
@@ -194,13 +289,14 @@ export class BlobStore implements IBucketStore {
194
289
  // ── synchronization ──
195
290
 
196
291
  private async runSourceSync(sourceIndex: number): Promise<void> {
197
- let { source, options } = this.sources[sourceIndex];
292
+ let { source, noFullSync } = this.sources[sourceIndex];
198
293
  let state = this.sourceStates[sourceIndex];
294
+ let listing: Map<string, number> | undefined;
199
295
  while (!this.stopped.stop) {
200
296
  try {
201
297
  let config = await source.getConfig();
202
298
  state.supportsChangesAfter = !!(config.supportsChangesAfter && source.getChangesAfter);
203
- await this.scanSource(sourceIndex);
299
+ listing = await this.scanSource(sourceIndex);
204
300
  break;
205
301
  } catch (e) {
206
302
  console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
@@ -210,7 +306,10 @@ export class BlobStore implements IBucketStore {
210
306
  state.scanComplete = true;
211
307
  state.initialScan.resolve(undefined);
212
308
  if (this.stopped.stop) return;
213
- if (options.copyFiles) {
309
+ if (listing) {
310
+ await this.reconcileSource(sourceIndex, listing);
311
+ }
312
+ if (!noFullSync) {
214
313
  try {
215
314
  await this.copySourceFiles(sourceIndex);
216
315
  } catch (e) {
@@ -220,43 +319,109 @@ export class BlobStore implements IBucketStore {
220
319
  if (state.supportsChangesAfter) {
221
320
  runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
222
321
  await this.pollChanges(sourceIndex);
223
- if (options.copyFiles) await this.copySourceFiles(sourceIndex);
322
+ if (!noFullSync) await this.copySourceFiles(sourceIndex);
323
+ }, this.stopped);
324
+ // Change polls only show what the source HAS, never what it's missing, so pushes run on
325
+ // the full-rescan cadence (findInfo on an index-backed source is cheap)
326
+ runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
327
+ let files = await source.findInfo("");
328
+ await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
224
329
  }, this.stopped);
225
330
  } else {
226
331
  runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
227
- await this.scanSource(sourceIndex);
228
- if (options.copyFiles) await this.copySourceFiles(sourceIndex);
332
+ let rescan = await this.scanSource(sourceIndex);
333
+ await this.reconcileSource(sourceIndex, rescan);
334
+ if (!noFullSync) await this.copySourceFiles(sourceIndex);
229
335
  }, this.stopped);
230
336
  }
231
337
  }
232
338
 
233
- // Full metadata scan (size, writeTime, path) of one source, applied to the index
234
- private async scanSource(sourceIndex: number): Promise<void> {
339
+ // Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the
340
+ // source's listing (path -> write time), which reconcileSource uses for the push direction.
341
+ private async scanSource(sourceIndex: number): Promise<Map<string, number>> {
235
342
  let { source } = this.sources[sourceIndex];
236
343
  let state = this.sourceStates[sourceIndex];
237
344
  let scanStart = Date.now();
238
- let files = await source.findInfo("");
239
- let seen = new Set<string>();
345
+ let scanLogged = false;
346
+ let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
347
+ this.syncActivities.add(activity);
348
+ let progressTimer = setInterval(() => {
349
+ scanLogged = true;
350
+ console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.folder})`);
351
+ }, SYNC_PROGRESS_LOG_INTERVAL);
352
+ (progressTimer as { unref?: () => void }).unref?.();
353
+ let files: ArchiveFileInfo[];
354
+ try {
355
+ files = await source.findInfo("");
356
+ } finally {
357
+ clearInterval(progressTimer);
358
+ this.syncActivities.delete(activity);
359
+ }
360
+ if (scanLogged) {
361
+ console.log(`Metadata scan of ${source.getDebugName()} finished: ${files.length} files in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder})`);
362
+ }
363
+ let seen = new Map<string, number>();
240
364
  for (let file of files) {
241
- seen.add(file.path);
365
+ seen.set(file.path, file.createTime);
242
366
  this.applyScanned(sourceIndex, file);
243
367
  }
244
368
  state.scannedCount = files.length;
245
369
  // Index entries this source was the holder of, but that vanished from it (e.g. deleted
246
370
  // while we were offline), come out of the index. Entries changed after the scan started
247
- // are kept — the scan listing may simply predate them.
371
+ // are kept — the scan listing may simply predate them. Tombstones have no physical file
372
+ // for a listing to vouch for, so they're exempt (cleanupTombstones expires them instead).
248
373
  for (let [key, entry] of this.mem) {
249
374
  if (entry.source !== sourceIndex) continue;
375
+ if (entry.size === 0) continue;
250
376
  if (seen.has(key)) continue;
251
377
  if (entry.changedAt >= scanStart) continue;
252
378
  this.deleteIndexEntry(key);
253
379
  }
254
380
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
381
+ return seen;
382
+ }
383
+
384
+ // The push direction of synchronization: everything we know that the source is missing (or
385
+ // holds an older copy of) is written to it — including deletions, as tombstone writes. This is
386
+ // what heals a source whose background writes failed (e.g. it was down): the next scan sees
387
+ // what's missing and re-sends it.
388
+ private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
389
+ let { source, validWindow, route } = this.sources[sourceIndex];
390
+ let acceptsWrites = windowAcceptsWrites(validWindow);
391
+ try {
392
+ for (let [key, entry] of this.mem) {
393
+ if (this.stopped.stop) return;
394
+ if (entry.source === sourceIndex) continue;
395
+ // Past-window sources only receive the routing file (see writeToSources), and
396
+ // sharded sources only the keys routing into them
397
+ if (key !== ROUTING_FILE) {
398
+ if (!acceptsWrites) continue;
399
+ if (!routeContains(route, getRoute(key))) continue;
400
+ }
401
+ let theirTime = listing.get(key);
402
+ if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
403
+ if (entry.size === 0) {
404
+ // A deletion only needs pushing while the source still holds an older copy
405
+ if (theirTime === undefined) continue;
406
+ await source.set(key, Buffer.alloc(0), { lastModified: entry.writeTime });
407
+ continue;
408
+ }
409
+ let result = await this.sources[entry.source].source.get2(key);
410
+ if (!result) continue;
411
+ await source.set(key, result.data, { lastModified: result.writeTime });
412
+ }
413
+ } catch (e) {
414
+ // Abort the pass instead of logging per file; the next scan cycle retries
415
+ console.error(`Reconciling sync source ${source.getDebugName()} failed: ${(e as Error).stack ?? e}`);
416
+ }
255
417
  }
256
418
 
257
419
  private applyScanned(sourceIndex: number, file: ArchiveFileInfo): void {
258
- let [windowStart, windowEnd] = this.sources[sourceIndex].options.validWindow;
420
+ let { validWindow, route } = this.sources[sourceIndex];
421
+ let [windowStart, windowEnd] = validWindow;
259
422
  if (file.createTime < windowStart || file.createTime > windowEnd) return;
423
+ // A partially-overlapping shard's listing includes keys outside our route; ignore them
424
+ if (file.path !== ROUTING_FILE && !routeContains(route, getRoute(file.path))) return;
260
425
  let existing = this.mem.get(file.path);
261
426
  // The highest write time wins across all sources (ties keep the existing entry)
262
427
  if (existing && file.createTime <= existing.writeTime) return;
@@ -276,45 +441,92 @@ export class BlobStore implements IBucketStore {
276
441
  state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
277
442
  }
278
443
 
279
- // Downloads the files a copyFiles source currently holds into the cacheReads sources (the
280
- // local cache), preserving their modified times — so a newer local write always wins
444
+ // Downloads the files a source currently holds onto our own base source (the local disk),
445
+ // preserving their modified times — so a newer local write always wins. Skipped for noFullSync
446
+ // sources (fronting a large database without copying it); reads still down-cache lazily.
281
447
  private async copySourceFiles(sourceIndex: number): Promise<void> {
448
+ if (sourceIndex === 0) return;
282
449
  let { source } = this.sources[sourceIndex];
283
- let targets: number[] = [];
284
- for (let i = 0; i < this.sources.length; i++) {
285
- if (i !== sourceIndex && this.sources[i].options.cacheReads) targets.push(i);
286
- }
287
- if (!targets.length) return;
450
+ let pending: { key: string; entry: IndexEntry }[] = [];
451
+ let totalBytes = 0;
288
452
  for (let [key, entry] of this.mem) {
289
453
  if (entry.source !== sourceIndex) continue;
290
- let result = await source.get2(key);
291
- if (!result) continue;
292
- for (let target of targets) {
293
- await this.sources[target].source.set(key, result.data, { lastModified: result.writeTime });
454
+ if (entry.size === 0) continue;
455
+ pending.push({ key, entry });
456
+ totalBytes += entry.size;
457
+ }
458
+ if (!pending.length) return;
459
+ let activity: SyncActivity = {
460
+ type: "fullSync",
461
+ sourceDebugName: source.getDebugName(),
462
+ startTime: Date.now(),
463
+ doneFiles: 0,
464
+ totalFiles: pending.length,
465
+ doneBytes: 0,
466
+ totalBytes,
467
+ };
468
+ this.syncActivities.add(activity);
469
+ let progressLogged = false;
470
+ let logProgress = () => {
471
+ progressLogged = true;
472
+ console.log(`Full sync from ${source.getDebugName()} (store ${this.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)}%)`);
473
+ };
474
+ let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
475
+ (progressTimer as { unref?: () => void }).unref?.();
476
+ try {
477
+ let nextIndex = 0;
478
+ let failed = false;
479
+ let copyWorker = async () => {
480
+ while (!failed && !this.stopped.stop) {
481
+ let index = nextIndex++;
482
+ if (index >= pending.length) return;
483
+ let { key, entry } = pending[index];
484
+ let result = await source.get2(key);
485
+ if (result) {
486
+ await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime });
487
+ // Only move the entry's source if it wasn't changed while we copied
488
+ if (this.mem.get(key) === entry) {
489
+ this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: 0 });
490
+ }
491
+ }
492
+ activity.doneFiles = (activity.doneFiles || 0) + 1;
493
+ activity.doneBytes = (activity.doneBytes || 0) + entry.size;
494
+ }
495
+ };
496
+ let workers: Promise<void>[] = [];
497
+ for (let i = 0; i < Math.min(FULL_SYNC_PARALLEL, pending.length); i++) {
498
+ workers.push(copyWorker().catch((e: Error) => {
499
+ // Stop the other workers pulling new files, then surface the error
500
+ failed = true;
501
+ throw e;
502
+ }));
294
503
  }
295
- // Only move the entry's source if it wasn't changed while we copied
296
- if (this.mem.get(key) === entry) {
297
- this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: targets[0] });
504
+ await Promise.all(workers);
505
+ } finally {
506
+ clearInterval(progressTimer);
507
+ this.syncActivities.delete(activity);
508
+ // A sync slow enough to have logged progress also logs its completion
509
+ if (progressLogged) {
510
+ logProgress();
298
511
  }
299
512
  }
300
513
  }
301
514
 
302
- // findInfo and getChangesAfter list from the index, so they must wait for the required
303
- // sources' initial scans (which might lag minutes) before the listing is trustworthy
515
+ // findInfo and getChangesAfter list from the index, so they must wait for our own base
516
+ // source's initial scan (which might lag minutes) before the listing is trustworthy. The base
517
+ // (local disk) is implicitly required - remote sources are not, they come and go.
304
518
  private async waitForRequiredScans(): Promise<void> {
305
- for (let i = 0; i < this.sources.length; i++) {
306
- if (!this.sources[i].options.required) continue;
307
- await this.sourceStates[i].initialScan.promise;
308
- }
519
+ await this.sourceStates[0].initialScan.promise;
309
520
  }
310
521
 
311
- // A requested file isn't in the index: required sources that haven't finished their initial
312
- // scan are checked directly, and changes-after sources are re-polled (at most every 5 seconds)
522
+ // A requested file isn't in the index: our own base source (implicitly required) is checked
523
+ // directly if its initial scan hasn't finished, and changes-after sources are re-polled (at
524
+ // most every 5 seconds)
313
525
  private async checkMissingKey(key: string): Promise<void> {
314
526
  for (let i = 0; i < this.sources.length; i++) {
315
- let { source, options } = this.sources[i];
527
+ let { source } = this.sources[i];
316
528
  let state = this.sourceStates[i];
317
- if (options.required && !state.scanComplete) {
529
+ if (i === 0 && !state.scanComplete) {
318
530
  let info = await source.getInfo(key);
319
531
  if (info) {
320
532
  this.applyScanned(i, { path: key, createTime: info.writeTime, size: info.size });
@@ -346,45 +558,68 @@ export class BlobStore implements IBucketStore {
346
558
  return result && result.data || undefined;
347
559
  }
348
560
 
349
- public async get2(key: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined> {
561
+ public async get2(key: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
350
562
  await this.init();
351
563
  let range = config?.range;
352
564
  let overlayEntry = this.overlay.get(key);
353
565
  if (overlayEntry) {
354
- if (!overlayEntry.data) return undefined;
566
+ // An empty file IS a missing file (tombstone)
567
+ if (overlayEntry.data.length === 0) return undefined;
355
568
  let data = overlayEntry.data;
569
+ let size = data.length;
356
570
  if (range) {
357
571
  data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
358
572
  }
359
- return { data, writeTime: overlayEntry.t };
573
+ return { data, writeTime: overlayEntry.t, size };
360
574
  }
361
575
  let entry = await this.getIndexEntry(key);
362
576
  if (!entry) return undefined;
363
- let { source, options } = this.sources[entry.source];
364
- let result = await source.get2(key, { range });
365
- if (!result) {
366
- // The source no longer has it, so our index entry was stale
367
- this.deleteIndexEntry(key);
368
- return undefined;
577
+ if (entry.size === 0) return undefined;
578
+ entry.lastAccess = Date.now();
579
+ let holder = entry.source;
580
+ let result: { data: Buffer; writeTime: number; size: number } | undefined;
581
+ let holderError: Error | undefined;
582
+ try {
583
+ result = await this.sources[holder].source.get2(key, { range });
584
+ } catch (e) {
585
+ holderError = e as Error;
586
+ }
587
+ if (result) {
588
+ // Ranged reads can't populate a cache (they're partial)
589
+ if (holder !== 0 && !range) {
590
+ await this.cacheRead(key, result);
591
+ }
592
+ return result;
369
593
  }
370
- // Ranged reads can't populate a cache (they're partial)
371
- if (!options.cacheReads && !range) {
372
- await this.cacheRead(key, result);
594
+ // The holder is down or lost the file. ANY other source's copy beats no value - even an
595
+ // OLDER one - and it's copied onto our disk so the next read doesn't depend on luck.
596
+ for (let i = 0; i < this.sources.length; i++) {
597
+ if (i === holder) continue;
598
+ let fallback: typeof result;
599
+ try {
600
+ fallback = await this.sources[i].source.get2(key);
601
+ } catch {
602
+ continue;
603
+ }
604
+ if (!fallback) continue;
605
+ await this.cacheRead(key, fallback);
606
+ let data = fallback.data;
607
+ if (range) {
608
+ data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
609
+ }
610
+ return { data, writeTime: fallback.writeTime, size: fallback.size };
373
611
  }
374
- return result;
612
+ if (holderError) throw holderError;
613
+ // The holder answered "not there" and no other source has it either: the entry was stale
614
+ this.deleteIndexEntry(key);
615
+ return undefined;
375
616
  }
376
617
 
377
- // The read didn't come from a cacheReads source, so write it into all of them (using them as
378
- // caches), and the first one becomes the entry's new source
618
+ // The read's bytes came from a remote source, so write them onto our own base source (the
619
+ // local disk), which becomes the entry's new holder - reads only pay the remote fetch once
379
620
  private async cacheRead(key: string, result: { data: Buffer; writeTime: number }): Promise<void> {
380
- let first: number | undefined;
381
- for (let i = 0; i < this.sources.length; i++) {
382
- if (!this.sources[i].options.cacheReads) continue;
383
- await this.sources[i].source.set(key, result.data, { lastModified: result.writeTime });
384
- if (first === undefined) first = i;
385
- }
386
- if (first === undefined) return;
387
- this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: first });
621
+ await this.sources[0].source.set(key, result.data, { lastModified: result.writeTime });
622
+ this.setIndexEntry(key, { writeTime: result.writeTime, size: result.data.length, source: 0 });
388
623
  }
389
624
 
390
625
  public async set(key: string, data: Buffer, config?: WriteConfig): Promise<void> {
@@ -408,43 +643,57 @@ export class BlobStore implements IBucketStore {
408
643
  await this.writeToSources(key, data, writeTime);
409
644
  }
410
645
 
411
- private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
412
- let first: number | undefined;
413
- for (let i = 0; i < this.sources.length; i++) {
414
- if (this.sources[i].options.noWriteBack) continue;
415
- await this.sources[i].source.set(key, data, { lastModified: writeTime });
416
- if (first === undefined) first = i;
417
- }
418
- if (first === undefined) {
419
- throw new Error(`Every source is noWriteBack, so writes cannot be stored (store ${this.folder})`);
420
- }
421
- this.setIndexEntry(key, { writeTime, size: data.length, source: first });
646
+ public async del(key: string, config?: WriteConfig): Promise<void> {
647
+ // Deletes are tombstone writes (an empty file IS a missing file): the size-0 index entry is
648
+ // ordered by write time like any other write, propagates through synchronization, and
649
+ // expires after TOMBSTONE_EXPIRY
650
+ await this.set(key, Buffer.alloc(0), config);
422
651
  }
423
652
 
424
- public async del(key: string, config?: WriteConfig): Promise<void> {
425
- await this.init();
426
- if (config?.fast) {
427
- let writeDelay = config.writeDelay || DEFAULT_FAST_WRITE_DELAY;
428
- this.overlay.set(key, { data: undefined, t: Date.now(), flushAt: Date.now() + writeDelay });
429
- return;
653
+ private getWritableSources(config?: { ignoreWindow?: boolean }): number[] {
654
+ let writable: number[] = [];
655
+ for (let i = 0; i < this.sources.length; i++) {
656
+ if (!config?.ignoreWindow && !windowAcceptsWrites(this.sources[i].validWindow)) continue;
657
+ writable.push(i);
430
658
  }
431
- this.overlay.delete(key);
432
- await this.deleteFromSources(key);
659
+ return writable;
433
660
  }
434
661
 
435
- private async deleteFromSources(key: string): Promise<void> {
436
- for (let i = 0; i < this.sources.length; i++) {
437
- if (this.sources[i].options.noWriteBack) continue;
438
- await this.sources[i].source.del(key);
662
+ private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
663
+ // The routing file is exempt from the valid-window and route write filters: the CONFIG
664
+ // must keep flowing to every source, or a client probing one of them first would adopt a
665
+ // stale config. Only data writes stop.
666
+ let isRouting = key === ROUTING_FILE;
667
+ let writable = this.getWritableSources({ ignoreWindow: isRouting });
668
+ let first = writable.shift();
669
+ if (first === undefined) {
670
+ throw new Error(`No source accepts writes (every source's valid window is in the past), so writes cannot be stored (store ${this.folder})`);
671
+ }
672
+ // Only our own (first) source blocks the write. Downstream sources are written in the
673
+ // background: a down downstream source must not fail or stall writes, and reconcileSource
674
+ // re-sends anything they missed once they come back.
675
+ if (data.length === 0) {
676
+ // A tombstone stores nothing on our own source - the index entry alone records it
677
+ await this.sources[first].source.del(key);
678
+ } else {
679
+ await this.sources[first].source.set(key, data, { lastModified: writeTime });
680
+ }
681
+ this.setIndexEntry(key, { writeTime, size: data.length, source: first });
682
+ let route = !isRouting && getRoute(key) || 0;
683
+ for (let i of writable) {
684
+ if (!isRouting && !routeContains(this.sources[i].route, route)) continue;
685
+ // Downstream sources receive tombstones as actual empty writes, so their listings show
686
+ // the deletion (size 0) and other stores scan it in as a tombstone
687
+ void this.sources[i].source.set(key, data, { lastModified: writeTime }).catch((e: Error) => {
688
+ console.error(`Background write of ${key} to sync source ${this.sources[i].source.getDebugName()} failed: ${e.stack ?? e}`);
689
+ });
439
690
  }
440
- this.deleteIndexEntry(key);
441
691
  }
442
692
 
443
693
  public async getInfo(key: string): Promise<{ writeTime: number; size: number } | undefined> {
444
694
  await this.init();
445
695
  let overlayEntry = this.overlay.get(key);
446
696
  if (overlayEntry) {
447
- if (!overlayEntry.data) return undefined;
448
697
  return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
449
698
  }
450
699
  let entry = await this.getIndexEntry(key);
@@ -458,11 +707,13 @@ export class BlobStore implements IBucketStore {
458
707
  let infos = new Map<string, ArchiveFileInfo>();
459
708
  for (let [key, entry] of this.mem) {
460
709
  if (!key.startsWith(prefix)) continue;
710
+ // Tombstones are missing files, so listings hide them
711
+ if (entry.size === 0) continue;
461
712
  infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
462
713
  }
463
714
  for (let [key, overlayEntry] of this.overlay) {
464
715
  if (!key.startsWith(prefix)) continue;
465
- if (!overlayEntry.data) {
716
+ if (overlayEntry.data.length === 0) {
466
717
  infos.delete(key);
467
718
  continue;
468
719
  }
@@ -475,7 +726,8 @@ export class BlobStore implements IBucketStore {
475
726
 
476
727
  // All files changed after the given time — fast, straight from the in-memory index. Filters on
477
728
  // when WE learned of the change (changedAt), so files synchronized late (with old write times)
478
- // are still reported. Deletions are not reported.
729
+ // are still reported. Deletions ARE reported, as size-0 tombstone entries — that's how they
730
+ // propagate to stores syncing from us.
479
731
  public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
480
732
  await this.init();
481
733
  await this.waitForRequiredScans();
@@ -486,7 +738,6 @@ export class BlobStore implements IBucketStore {
486
738
  files.push({ path: key, createTime: entry.writeTime, size: entry.size });
487
739
  }
488
740
  for (let [key, overlayEntry] of this.overlay) {
489
- if (!overlayEntry.data) continue;
490
741
  if (overlayEntry.t <= time) continue;
491
742
  files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
492
743
  }
@@ -501,7 +752,9 @@ export class BlobStore implements IBucketStore {
501
752
  indexSize: this.mem.size,
502
753
  sources: this.sources.map((x, i) => ({
503
754
  debugName: x.source.getDebugName(),
504
- options: x.options,
755
+ validWindow: x.validWindow,
756
+ route: x.route,
757
+ noFullSync: x.noFullSync,
505
758
  supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
506
759
  initialScanComplete: this.sourceStates[i].scanComplete,
507
760
  scannedCount: this.sourceStates[i].scannedCount,
@@ -543,15 +796,75 @@ export class BlobStore implements IBucketStore {
543
796
  let now = Date.now();
544
797
  for (let [key, entry] of this.overlay) {
545
798
  if (!force && entry.flushAt > now) continue;
546
- if (entry.data) {
547
- await this.writeToSources(key, entry.data, entry.t);
548
- } else {
549
- await this.deleteFromSources(key);
550
- }
799
+ await this.writeToSources(key, entry.data, entry.t);
551
800
  // Only remove if it wasn't overwritten while we were flushing
552
801
  if (this.overlay.get(key) === entry) {
553
802
  this.overlay.delete(key);
554
803
  }
555
804
  }
556
805
  }
806
+
807
+ // readerDiskLimit: the disk is only a bounded read cache, so once it exceeds the limit, the
808
+ // least recently used files are deleted from it - but ONLY when another source verifiably
809
+ // holds a same-or-newer copy (the only copy of a file is never deleted), and the index entry
810
+ // repoints to that source so reads keep working (re-caching on the next read).
811
+ private evicting = false;
812
+ private async enforceDiskLimit(): Promise<void> {
813
+ let limit = this.config?.readerDiskLimit;
814
+ if (!limit || this.evicting) return;
815
+ if (this.sourceByteCounts[0] <= limit) return;
816
+ this.evicting = true;
817
+ try {
818
+ let candidates: { key: string; entry: IndexEntry }[] = [];
819
+ for (let [key, entry] of this.mem) {
820
+ if (entry.source !== 0 || entry.size === 0 || key === ROUTING_FILE) continue;
821
+ candidates.push({ key, entry });
822
+ }
823
+ sort(candidates, x => x.entry.lastAccess);
824
+ for (let { key, entry } of candidates) {
825
+ if (this.stopped.stop) return;
826
+ if (this.sourceByteCounts[0] <= limit) break;
827
+ if (this.mem.get(key) !== entry) continue;
828
+ let holder: number | undefined;
829
+ for (let i = 1; i < this.sources.length; i++) {
830
+ try {
831
+ let info = await this.sources[i].source.getInfo(key);
832
+ if (info && info.writeTime >= entry.writeTime) {
833
+ holder = i;
834
+ break;
835
+ }
836
+ } catch {
837
+ // A down source just can't vouch for this file right now
838
+ }
839
+ }
840
+ if (holder === undefined) continue;
841
+ await this.sources[0].source.del(key);
842
+ this.setIndexEntry(key, { writeTime: entry.writeTime, size: entry.size, source: holder });
843
+ }
844
+ } finally {
845
+ this.evicting = false;
846
+ }
847
+ }
848
+
849
+ // Tombstones only need to exist long enough for every store to learn of the deletion; expired
850
+ // ones come out of the index. The physical empty file is removed only on backblaze sources:
851
+ // remote stores expire their own tombstones (a del there would just mint a fresh one), and our
852
+ // own disk never stored anything for it.
853
+ private async cleanupTombstones(): Promise<void> {
854
+ let cutoff = Date.now() - TOMBSTONE_EXPIRY;
855
+ for (let [key, entry] of this.mem) {
856
+ if (this.stopped.stop) return;
857
+ if (entry.size !== 0) continue;
858
+ if (entry.writeTime > cutoff) continue;
859
+ this.deleteIndexEntry(key);
860
+ for (let sourceEntry of this.sources) {
861
+ if (!windowAcceptsWrites(sourceEntry.validWindow)) continue;
862
+ let source = sourceEntry.source;
863
+ if (!(source instanceof ArchivesBackblaze)) continue;
864
+ void source.del(key).catch((e: Error) => {
865
+ console.error(`Removing expired tombstone ${key} from ${source.getDebugName()} failed: ${e.stack ?? e}`);
866
+ });
867
+ }
868
+ }
869
+ }
557
870
  }