sliftutils 1.7.10 → 1.7.12

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