sliftutils 1.7.88 → 1.7.90

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 (126) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/dist/treeSummary.ts.cache +2 -2
  3. package/index.d.ts +679 -300
  4. package/misc/dist/environment.ts.cache +2 -2
  5. package/misc/dist/getSecret.ts.cache +2 -2
  6. package/misc/dist/strings.ts.cache +2 -2
  7. package/misc/dist/zip.ts.cache +2 -2
  8. package/misc/https/dist/certs.ts.cache +2 -2
  9. package/misc/https/dist/cloudflareHelpers.ts.cache +2 -2
  10. package/misc/https/dist/dns.ts.cache +2 -2
  11. package/misc/https/dist/hostServer.ts.cache +49 -21
  12. package/misc/https/dist/hostsFile.ts.cache +82 -0
  13. package/misc/https/dist/httpsCerts.ts.cache +2 -2
  14. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  15. package/misc/https/hostServer.d.ts +6 -2
  16. package/misc/https/hostServer.ts +52 -19
  17. package/misc/https/hostsFile.d.ts +7 -0
  18. package/misc/https/hostsFile.ts +66 -0
  19. package/package.json +1 -1
  20. package/render-utils/dist/observer.tsx.cache +2 -2
  21. package/storage/ArchivesDisk.d.ts +3 -6
  22. package/storage/ArchivesDisk.ts +26 -2
  23. package/storage/BulkDatabase2/BulkDatabaseReader.ts +1 -1
  24. package/storage/BulkDatabase2/dist/BulkDatabaseBase.ts.cache +2 -2
  25. package/storage/BulkDatabase2/dist/BulkDatabaseFormat.ts.cache +2 -2
  26. package/storage/BulkDatabase2/dist/BulkDatabaseMerge.ts.cache +2 -2
  27. package/storage/BulkDatabase2/dist/BulkDatabaseReader.ts.cache +3 -3
  28. package/storage/BulkDatabase2/dist/LoadedIndex.ts.cache +2 -2
  29. package/storage/BulkDatabase2/dist/WriteOverlay.ts.cache +2 -2
  30. package/storage/BulkDatabase2/dist/blockCache.ts.cache +2 -2
  31. package/storage/BulkDatabase2/dist/mergeLock.ts.cache +2 -2
  32. package/storage/BulkDatabase2/dist/mergeMarkers.ts.cache +2 -2
  33. package/storage/BulkDatabase2/dist/streamLog.ts.cache +2 -2
  34. package/storage/BulkDatabase2/dist/syncClient.ts.cache +2 -2
  35. package/storage/IArchives.d.ts +37 -24
  36. package/storage/IArchives.ts +47 -62
  37. package/storage/LogMap.d.ts +56 -0
  38. package/storage/LogMap.ts +220 -0
  39. package/storage/archiveHelpers.d.ts +36 -0
  40. package/storage/archiveHelpers.ts +103 -0
  41. package/storage/backblaze.d.ts +7 -12
  42. package/storage/backblaze.ts +53 -48
  43. package/storage/dist/ArchivesDisk.ts.cache +29 -3
  44. package/storage/dist/FileFolderAPI.tsx.cache +2 -2
  45. package/storage/dist/IArchives.ts.cache +17 -53
  46. package/storage/dist/IndexedDBFileFolderAPI.ts.cache +2 -2
  47. package/storage/dist/JSONStorage.ts.cache +2 -2
  48. package/storage/dist/LogMap.ts.cache +212 -0
  49. package/storage/dist/PendingManager.tsx.cache +2 -2
  50. package/storage/dist/TransactionStorage.ts.cache +2 -2
  51. package/storage/dist/archiveHelpers.ts.cache +87 -0
  52. package/storage/dist/backblaze.ts.cache +59 -45
  53. package/storage/dist/fileSystemPointer.ts.cache +2 -2
  54. package/storage/dist/remoteFileStorage.ts.cache +2 -2
  55. package/storage/remoteStorage/ArchivesDelayed.d.ts +66 -0
  56. package/storage/remoteStorage/ArchivesDelayed.ts +237 -0
  57. package/storage/remoteStorage/ArchivesRemote.d.ts +5 -6
  58. package/storage/remoteStorage/ArchivesRemote.ts +26 -5
  59. package/storage/remoteStorage/ArchivesUrl.d.ts +2 -5
  60. package/storage/remoteStorage/ArchivesUrl.ts +2 -2
  61. package/storage/remoteStorage/accessPage.tsx +12 -0
  62. package/storage/remoteStorage/blobStore.d.ts +162 -189
  63. package/storage/remoteStorage/blobStore.ts +495 -952
  64. package/storage/remoteStorage/bucketDisk.d.ts +24 -3
  65. package/storage/remoteStorage/bucketDisk.ts +56 -22
  66. package/storage/remoteStorage/certTrustModal.d.ts +1 -0
  67. package/storage/remoteStorage/certTrustModal.tsx +48 -0
  68. package/storage/remoteStorage/cliArgs.d.ts +2 -0
  69. package/storage/remoteStorage/cliArgs.ts +5 -0
  70. package/storage/remoteStorage/createArchives.d.ts +8 -7
  71. package/storage/remoteStorage/createArchives.ts +65 -26
  72. package/storage/remoteStorage/deployTakeover.ts +1 -1
  73. package/storage/remoteStorage/dist/ArchivesDelayed.ts.cache +220 -0
  74. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +26 -6
  75. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +3 -3
  76. package/storage/remoteStorage/dist/accessPage.tsx.cache +13 -3
  77. package/storage/remoteStorage/dist/accessStats.ts.cache +2 -2
  78. package/storage/remoteStorage/dist/blobStore.ts.cache +484 -966
  79. package/storage/remoteStorage/dist/bucketDisk.ts.cache +60 -29
  80. package/storage/remoteStorage/dist/certTrustModal.tsx.cache +55 -0
  81. package/storage/remoteStorage/dist/cliArgs.ts.cache +9 -4
  82. package/storage/remoteStorage/dist/createArchives.ts.cache +64 -27
  83. package/storage/remoteStorage/dist/deployTakeover.ts.cache +5 -5
  84. package/storage/remoteStorage/dist/intermediateManagement.ts.cache +286 -0
  85. package/storage/remoteStorage/dist/intermediateSources.ts.cache +2 -2
  86. package/storage/remoteStorage/dist/remoteConfig.ts.cache +122 -56
  87. package/storage/remoteStorage/dist/serverConfig.ts.cache +4 -4
  88. package/storage/remoteStorage/dist/sourceWrapper.ts.cache +7 -11
  89. package/storage/remoteStorage/dist/sourcesList.ts.cache +3 -6
  90. package/storage/remoteStorage/dist/storageClientController.ts.cache +2 -2
  91. package/storage/remoteStorage/dist/storageController.ts.cache +33 -36
  92. package/storage/remoteStorage/dist/storageServer.ts.cache +27 -7
  93. package/storage/remoteStorage/dist/storageServerCli.ts.cache +7 -3
  94. package/storage/remoteStorage/dist/storageServerState.ts.cache +138 -707
  95. package/storage/remoteStorage/dist/storeConfig.ts.cache +52 -0
  96. package/storage/remoteStorage/dist/storePlan.ts.cache +110 -81
  97. package/storage/remoteStorage/dist/storeSources.ts.cache +51 -0
  98. package/storage/remoteStorage/dist/storeSync.ts.cache +651 -0
  99. package/storage/remoteStorage/dist/validation.ts.cache +14 -4
  100. package/storage/remoteStorage/intermediateManagement.d.ts +12 -0
  101. package/storage/remoteStorage/intermediateManagement.ts +264 -0
  102. package/storage/remoteStorage/remoteConfig.d.ts +32 -0
  103. package/storage/remoteStorage/remoteConfig.ts +115 -50
  104. package/storage/remoteStorage/serverConfig.d.ts +1 -1
  105. package/storage/remoteStorage/serverConfig.ts +1 -1
  106. package/storage/remoteStorage/sourceWrapper.ts +4 -8
  107. package/storage/remoteStorage/sourcesList.d.ts +0 -2
  108. package/storage/remoteStorage/sourcesList.ts +0 -3
  109. package/storage/remoteStorage/spec.md +18 -4
  110. package/storage/remoteStorage/storageController.d.ts +10 -0
  111. package/storage/remoteStorage/storageController.ts +37 -41
  112. package/storage/remoteStorage/storageServer.d.ts +2 -0
  113. package/storage/remoteStorage/storageServer.ts +28 -4
  114. package/storage/remoteStorage/storageServerCli.ts +5 -1
  115. package/storage/remoteStorage/storageServerState.d.ts +39 -26
  116. package/storage/remoteStorage/storageServerState.ts +149 -669
  117. package/storage/remoteStorage/storeConfig.d.ts +30 -0
  118. package/storage/remoteStorage/storeConfig.ts +62 -0
  119. package/storage/remoteStorage/storePlan.d.ts +29 -17
  120. package/storage/remoteStorage/storePlan.ts +112 -85
  121. package/storage/remoteStorage/storeSources.d.ts +11 -0
  122. package/storage/remoteStorage/storeSources.ts +47 -0
  123. package/storage/remoteStorage/storeSync.d.ts +53 -0
  124. package/storage/remoteStorage/storeSync.ts +632 -0
  125. package/storage/remoteStorage/validation.d.ts +2 -0
  126. package/storage/remoteStorage/validation.ts +10 -0
@@ -1,163 +1,41 @@
1
1
  import path from "path";
2
2
  import { lazy } from "socket-function/src/caching";
3
- import { runInfinitePoll, delay } from "socket-function/src/batching";
4
- import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
5
- import { formatNumber, formatTime } from "socket-function/src/formatting/format";
3
+ import { timeInMinute, sort } from "socket-function/src/misc";
6
4
  import {
7
- IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, SourceConfig, assertValidLastModified,
8
- windowAcceptsWrites, windowsAcceptWrites, SyncActivity, FULL_ROUTE, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE, copyArchiveFile,
5
+ IArchives, ArchiveFileInfo, ArchivesSource, ArchivesSyncStatus, ChangesAfterConfig, FindConfig, HostedConfig, RemoteConfig, SourceConfig, assertValidLastModified,
6
+ windowsAcceptWrites, SyncActivity, FULL_ROUTE, FULL_VALID_WINDOW, STORAGE_WRONG_VALID_WINDOW, STORAGE_WRONG_ROUTE,
9
7
  } from "../IArchives";
10
8
  import { ArchivesDisk, applyFindInfoShape } from "../ArchivesDisk";
11
- import { ArchivesBackblaze } from "../backblaze";
12
- import { ROUTING_FILE, getRoute, routeContains } from "./remoteConfig";
9
+ import { ROUTING_FILE, getRoute, routeContains, routeIntersection, parseRoutingData, assertValidRemoteConfig, getConfigVersion, sourceIdentity, sourcePersistentUrl } from "./remoteConfig";
13
10
  import { selectEntryAt } from "./storePlan";
14
- import { BulkDatabaseBase, noopReactiveDeps } from "../BulkDatabase2/BulkDatabaseBase";
15
- import { wrapHandle, NodeJSDirectoryHandleWrapper, DirectoryWrapper } from "../FileFolderAPI";
11
+ import { sourceWriteDelay } from "./ArchivesDelayed";
12
+ import { LogMap } from "../LogMap";
16
13
  import { SourcesList } from "./sourcesList";
14
+ import { StoreSync } from "./storeSync";
15
+ import { StoreConfig } from "./storeConfig";
16
+ import { asDelayed, unwrapDelayed } from "./ArchivesDelayed";
17
17
 
18
- // 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. The store also holds its own routing entries (the self entries of the ONE route it serves), so it validates writes itself: valid windows, routes, immutability, and internal-push acceptance.
18
+ // 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 answers every read from it (see ArchivesSource in IArchives.ts). Keeping that index in agreement with the sources is StoreSync's job, not this file's; what lives here is the index itself, reads, writes, and the validation a write has to pass.
19
19
 
20
- export const DEFAULT_FAST_WRITE_DELAY = timeInMinute * 5;
21
- // The most a fast write is buffered before reaching our OWN storage servers (type-"remote" downstream sources) and our own disk - short, so cross-node redundancy and read-your-writes across nodes don't wait the full writeDelay. The writeDelay (default 5 min) is reserved for the non-"remote" sources (backblaze etc). A fast write with a writeDelay shorter than this uses the shorter one for both.
22
- const MAX_REMOTE_FAST_BUFFER = 1000 * 5;
23
- // The overlay is polled this often for due flushes - fine enough that the fast (remote) flush lands close to MAX_REMOTE_FAST_BUFFER, not one coarse tick later
24
- const FAST_FLUSH_POLL = 1000 * 2;
25
20
  // Fast writes are never delayed past our own valid window, and within this margin of the window's end they write through immediately - so when the next window's source takes over, the writes are already on disk
26
21
  export const WINDOW_END_FLUSH_MARGIN = timeInMinute * 5;
27
- // Index changes are buffered in memory and written to the BulkDatabase2 in batches
28
- const INDEX_FLUSH_INTERVAL = 1000 * 30;
29
- // Sources with a native (index-backed) change feed are polled this often
30
- const CHANGES_POLL_INTERVAL = 1000 * 60;
31
- // 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.
32
- const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
33
- const FULL_RESCAN_UNINDEXED_INTERVAL = 1000 * 60 * 60 * 6;
34
- // On a request for a file the index doesn't know, changes-after sources are re-polled, at most this often
35
- const MISS_CHECK_INTERVAL = 1000 * 5;
36
- // Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
37
- const CHANGES_POLL_OVERLAP = timeInMinute;
38
- const SCAN_RETRY_DELAY = 1000 * 30;
39
- // Deletes are tombstones (an empty file IS a missing file): the size-0 index entry is what lets a deletion propagate/reconcile like any other write, and it expires after this long
40
- const TOMBSTONE_EXPIRY = 1000 * 60 * 60 * 24 * 7;
41
- const TOMBSTONE_CLEANUP_INTERVAL = 1000 * 60 * 60;
42
- // While a metadata scan or full sync is running, its progress is logged this often
43
- const SYNC_PROGRESS_LOG_INTERVAL = 1000 * 60;
44
- const DISK_LIMIT_CHECK_INTERVAL = 1000 * 60;
45
- // Full syncs download this many files concurrently (high-latency sources like backblaze would otherwise crawl one round-trip at a time)
46
- const FULL_SYNC_PARALLEL = 8;
47
- // 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
48
- const FULL_SYNC_SLOW_ERROR_INTERVAL = 1000 * 60 * 60;
49
- // 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
50
- const RECONCILE_MAX_CONSECUTIVE_FAILURES = 5;
51
- const RECONCILE_ERROR_LOG_LIMIT = 3;
52
22
  const WRONG_TARGET_LOG_THROTTLE = 60 * 1000;
23
+ // Marks an upload id that never reached the disk, so it can never collide with one ArchivesDisk handed out
24
+ const DISCARDED_UPLOAD_PREFIX = "discarded_";
53
25
 
54
- // What the storage server needs from a bucket's store. BlobStore implements it fully (with validation from its routing entries); RawDiskStore adapts an ArchivesDisk for rawDisk buckets (no index, no sync, no validation - raw means raw).
55
- export type IBucketStore = {
56
- /** internal (store-to-store) reads answer purely from the local disk; see GetConfig.internal */
57
- get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined>;
58
- /** internal (store-to-store) writes go to the local disk + index with no fan-out; see SetConfig.internal */
59
- set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void>;
60
- del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void>;
61
- getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined>;
62
- findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]>;
63
- getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
64
- getSyncStatus?(): Promise<ArchivesSyncStatus>;
65
- getSyncProgress?(): {
66
- index: { fileCount: number; byteCount: number };
67
- sources: { debugName: string; fileCount: number; byteCount: number }[];
68
- readerDiskLimit?: number;
69
- syncing: SyncActivity[];
70
- };
71
- computeIndexTotals?(): Promise<{ fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] }>;
72
- /** path/lastModified let the store reject an upload into an immutable bucket before any bytes move */
73
- startLargeUpload(config?: { path?: string; lastModified?: number }): Promise<string>;
74
- appendLargeUpload(config: { id: string; data: Buffer }): Promise<void>;
75
- finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void>;
76
- cancelLargeUpload(config: { id: string }): Promise<void>;
77
- };
78
26
 
79
- /** rawDisk buckets: the disk IS the store. No index, no synchronization, no window/route/immutability validation. */
80
- export class RawDiskStore implements IBucketStore {
81
- constructor(private disk: ArchivesDisk) { }
82
-
83
- public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
84
- return await this.disk.get2(config.path, { range: config.range, includeTombstones: config.includeTombstones });
85
- }
86
- public async set(config: { path: string; data: Buffer; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
87
- await this.disk.set(config.path, config.data, { lastModified: config.lastModified });
88
- }
89
- public async del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void> {
90
- if (config.path === ROUTING_FILE) {
91
- throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
92
- }
93
- await this.disk.del(config.path, { lastModified: config.lastModified });
94
- }
95
- public async getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined> {
96
- return await this.disk.getInfo(config.path, { includeTombstones: config.includeTombstones });
97
- }
98
- public async findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]> {
99
- return await this.disk.findInfo(config.prefix, { shallow: config.shallow, type: config.type });
100
- }
101
- public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
102
- return await this.disk.getChangesAfter2(config);
103
- }
104
- public async startLargeUpload(): Promise<string> {
105
- return await this.disk.startLargeUpload();
106
- }
107
- public async appendLargeUpload(config: { id: string; data: Buffer }): Promise<void> {
108
- await this.disk.appendLargeUpload(config.id, config.data);
109
- }
110
- public async finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void> {
111
- await this.disk.finishLargeUpload(config.id, config.path, config.lastModified);
112
- }
113
- public async cancelLargeUpload(config: { id: string }): Promise<void> {
114
- await this.disk.cancelLargeUpload(config.id);
115
- }
116
- }
117
-
118
- type OverlayEntry = {
119
- // A zero-length buffer is a pending delete (tombstone)
120
- data: Buffer;
121
- t: number;
122
- // When to flush to our own disk + type-"remote" downstream sources (our own storage servers): short, so cross-node reads and redundancy don't wait the full writeDelay
123
- fastFlushAt: number;
124
- // When to flush to the remaining (non-"remote") downstream sources, e.g. backblaze: the full writeDelay, coalescing to keep expensive external writes rare
125
- slowFlushAt: number;
126
- // Set once the fast flush has run (own disk + remote peers written); the slow flush then only needs the non-remote sources
127
- fastFlushed?: boolean;
128
- };
129
-
130
- // One row of the BulkDatabase2 index (key is the file path)
131
- type BlobIndexEntry = {
132
- key: string;
133
- writeTime: number;
27
+ /** What we store about a file. Its times are not in here: the index keeps those for every key, deleted ones included (see LogMap). */
28
+ type IndexValue = {
134
29
  size: number;
135
30
  // 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
136
31
  sourcesListIndex: number;
137
32
  };
138
- type IndexEntry = {
33
+
34
+ /** One file we hold, as everything outside the index sees it. */
35
+ export type IndexEntry = IndexValue & {
139
36
  writeTime: number;
140
- size: number;
141
- sourcesListIndex: number;
142
37
  // 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
143
38
  changedAt: number;
144
- // In-memory only (not persisted): when the file was last served, for readerDiskLimit's LRU eviction. Starts as changedAt on load.
145
- lastAccess: number;
146
- };
147
-
148
- type SourceState = {
149
- supportsChangesAfter: boolean;
150
- initialScan: ReturnType<typeof promiseObj>;
151
- scanComplete: boolean;
152
- // Files seen in this source's scans / change polls so far
153
- scannedCount: number;
154
- // Watermark for getChangesAfter2 polls
155
- changesAfterTime: number;
156
- lastMissCheck: number;
157
- // Per-slot stop token: a removed source's loops stop without touching the rest of the store
158
- stopped: { stop: boolean };
159
- // 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.
160
- dead?: boolean;
161
39
  };
162
40
 
163
41
  // One source of a live source-list update; see BlobStore.updateSources
@@ -175,31 +53,10 @@ export type BlobSourceSpec = {
175
53
  sourceConfig?: SourceConfig;
176
54
  // Only called for sources that don't match an existing live slot
177
55
  create: () => IArchives;
56
+ // The opposite case: the endpoint is already running and only its config changed, so the RUNNING source is handed the new one (see applySourceConfig)
57
+ applyConfig?: (source: IArchives) => void;
178
58
  };
179
59
 
180
- // What a scanned listing entry meant when compared against our index
181
- type ScanOutcome = "filtered" | "new" | "updated" | "tombstone" | "unchanged";
182
- type ScanTally = Record<ScanOutcome, number>;
183
- function newScanTally(): ScanTally {
184
- return { filtered: 0, new: 0, updated: 0, tombstone: 0, unchanged: 0 };
185
- }
186
- function formatScanTally(tally: ScanTally, total: number): string {
187
- let pct = (n: number) => `${Math.round(n / Math.max(total, 1) * 1000) / 10}%`;
188
- return `${tally.new} new paths (${pct(tally.new)}), ${tally.updated} newer writes (${pct(tally.updated)}), ${tally.tombstone} deletions (${pct(tally.tombstone)}), ${tally.unchanged} unchanged (${pct(tally.unchanged)}), ${tally.filtered} outside route (${pct(tally.filtered)})`;
189
- }
190
-
191
- function newSourceState(): SourceState {
192
- return {
193
- supportsChangesAfter: false,
194
- initialScan: promiseObj(),
195
- scanComplete: false,
196
- scannedCount: 0,
197
- changesAfterTime: 0,
198
- lastMissCheck: 0,
199
- stopped: { stop: false },
200
- };
201
- }
202
-
203
60
  let lastWrongTargetLog = 0;
204
61
  function logWrongTargetRejection(message: string): void {
205
62
  if (Date.now() - lastWrongTargetLog < WRONG_TARGET_LOG_THROTTLE) return;
@@ -207,52 +64,181 @@ function logWrongTargetRejection(message: string): void {
207
64
  console.log(message);
208
65
  }
209
66
 
210
- export class BlobStore implements IBucketStore {
67
+ // The bucket store, and the only one: every bucket a server holds is served by these, one per store name in its routing config. Its sources are plain IArchives - the first is always the store's own disk folder, the rest are the configured peers (see createStoreSource) - so "no synchronization" is simply a store with no peers, not a different kind of store.
68
+ export class BlobStore {
69
+ // ── state ──
70
+ // Set once the store is shutting down; every loop it owns (and StoreSync's maintenance loops) stops on it
71
+ public stopped = { stop: false };
72
+ // Whether init has reached the point where a newly added source can start scanning immediately
73
+ public syncStarted = false;
74
+ /** Its sources, in config order: slot 0 is always its own disk folder, the rest are the peers it synchronizes with. Filled by updateSources, which is also how they change. The store OWNS them - writes pick among them, reads resolve holders through them - and StoreSync only scans whatever is in here at the time. */
75
+ public sources: ArchivesSource[] = [];
76
+ // Uploads immutability already decided against: the client is mid-stream by the time we know, so it keeps sending and every part is dropped
77
+ private discardedUploads = new Set<string>();
78
+ private nextDiscardedUpload = 1;
79
+ // The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
80
+ private sourcesList: SourcesList;
81
+ // Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
82
+ private slotSourcesListIndexes: number[] = [];
83
+ private slotRegistrations: Promise<void>[] = [];
84
+ // Every file we know of, in memory, with an append-only log of the changes behind it (see LogMap). A new file name each time the entry format changes generation: the previous ones cannot be read, and a scan rebuilds the index anyway, so they are simply never read again.
85
+ private index: LogMap<IndexValue>;
86
+ /** Keeping the index in agreement with the sources: scanning, pulling, pushing, and the maintenance that follows from holding an index (disk-limit eviction, tombstone expiry). It reads and writes this store's index and sources - it does not own them. */
87
+ public sync: StoreSync;
88
+
211
89
  constructor(
212
- private folder: string,
213
- private sources: ArchivesSource[],
90
+ // Public for StoreSync, which logs it with everything it does
91
+ public folder: string,
92
+ /** The name this store answers to (see CommonConfig.name) - the entries of the routing config that carry it are the ones that configure it, and the rest are its peers. */
93
+ public storeName: string,
214
94
  private config?: {
95
+ /** Whether a config entry is THIS SERVER (same account, same bucket, our own address). Injected because a store knows nothing about servers - it only needs to tell its own entries apart from its peers'. Absent means nothing is us, which is what a bare store (no server around it) wants. */
96
+ isSelf?: (source: SourceConfig) => boolean;
97
+ /** Builds one of its sources. Injected for the same reason, and because the delay it is created with is this store's policy. */
98
+ createSource?: (config: { sourceConfig?: SourceConfig; writeDelay: number }) => IArchives;
99
+ /** Hands a running source a changed config, so an endpoint we already talk to is never rebuilt just because a flag moved. */
100
+ applySource?: (source: IArchives, sourceConfig: SourceConfig | undefined, writeDelay: number) => void;
215
101
  // Called whenever a key's index entry changes (our own writes AND files pulled in via synchronization) — how the storage server notices routing config updates.
216
102
  onIndexChanged?: (key: string) => void;
217
- // LRU-bound the disk (base source) to this many bytes; see CommonConfig.readerDiskLimit
218
- readerDiskLimit?: number;
219
103
  // Every accepted write ("original") and every write that actually reached the sources ("flushed"). Fast writes coalesce, so the two counts differ.
220
104
  onWriteCounted?: (kind: "original" | "flushed", bytes: number) => void;
221
105
  // Resolves a persisted source URL (see ArchivesSource.url) to a cached IArchives, so entries whose holder is no longer configured can still be read
222
106
  resolveSourceUrl?: (url: string) => IArchives;
223
- // This store's own entries in the routing config (all the same route, different valid windows). What set/del validate against: valid windows and routes for fresh writes, immutability, internal-push acceptance, and the fast/writeDelay flags. Empty = no validation (a store serving leftover disk data with no config).
224
- entries?: HostedConfig[];
225
107
  }
226
- ) { }
108
+ ) {
109
+ this.sourcesList = new SourcesList(path.join(folder, "index", "sourcesList.txt"));
110
+ this.index = new LogMap<IndexValue>(path.join(folder, "index", "blobIndex3.log"));
111
+ this.sync = new StoreSync(this);
112
+ this.storeConfig = new StoreConfig(storeName, []);
113
+ this.ownDisk = new ArchivesDisk(folder);
114
+ }
115
+
116
+ /** This store's folder, unwrapped: the same bytes slot 0 serves, but reached without its write delay. Used for the two things that cannot go through a buffered source - reading our own routing config before we have any sources, and streaming a large upload that must not sit in memory. */
117
+ private ownDisk: ArchivesDisk;
118
+
119
+ /** What this store is configured to be. It owns this: the routing config is a file IN the store, so the store reads it, applies it to itself, and re-applies it whenever the file changes - by our own write, or by a peer's copy arriving through synchronization. */
120
+ public storeConfig: StoreConfig;
121
+ // The routing config this store last applied, so a write of an identical (or older) one is not re-applied
122
+ private appliedRoutingVersion = -1;
227
123
 
228
124
  // #region Main interface
229
125
 
230
126
  public init = lazy(async () => {
127
+ // Configure first: the config decides what the sources ARE, and everything below is per source. With no config at all that is just our own disk, which is enough to serve and to be written to.
128
+ await this.applyRoutingConfig();
231
129
  for (let i = 0; i < this.sources.length; i++) {
232
130
  await this.registerSlot(i);
233
131
  }
132
+ // After the slots, which is what index entries name as their holder
234
133
  await this.loadIndex();
235
134
  this.syncStarted = true;
236
- for (let i = 0; i < this.sources.length; i++) {
237
- if (!this.isLive(i)) continue;
238
- void this.runSourceSync(i);
135
+ this.sync.start();
136
+ });
137
+
138
+ /**
139
+ * Re-reads the routing config out of this store and applies it to itself. Called at startup and
140
+ * whenever that file changes here - which is the ONE mechanism: a config written by an operator
141
+ * and a config pulled off a peer are the same event, a write of that path into this store.
142
+ *
143
+ * A store with no routing config configures itself as its own disk, valid always, for the whole
144
+ * key space. That is a complete, working store - it just has nobody to synchronize with - and it
145
+ * is what lets a store exist before it has ever heard of a configuration.
146
+ */
147
+ public async applyRoutingConfig(): Promise<void> {
148
+ let routing = await this.readRoutingConfig();
149
+ let entries: HostedConfig[] = [];
150
+ let peers: SourceConfig[] = [];
151
+ for (let source of routing?.sources || []) {
152
+ if (typeof source === "string") continue;
153
+ if (source.name === this.storeName && this.config?.isSelf?.(source)) {
154
+ entries.push(source as HostedConfig);
155
+ continue;
156
+ }
157
+ peers.push(source);
239
158
  }
240
- runInfinitePoll(FAST_FLUSH_POLL, () => this.flushOverlay(), this.stopped);
241
- runInfinitePoll(INDEX_FLUSH_INTERVAL, () => this.flushIndex(), this.stopped);
242
- runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.stopped);
243
- if (this.config?.readerDiskLimit) {
244
- runInfinitePoll(DISK_LIMIT_CHECK_INTERVAL, () => this.enforceDiskLimit(), this.stopped);
159
+ if (routing && !entries.length) {
160
+ console.warn(`The routing config in store ${this.folder} (version ${getConfigVersion(routing)}) does not name this store (${JSON.stringify(this.storeName)}) as one of ours, so it stays a disk-only store`);
245
161
  }
246
- });
162
+ this.storeConfig.update(entries);
163
+ this.updateSources(this.planSources(peers));
164
+ this.appliedRoutingVersion = routing && getConfigVersion(routing) || -1;
165
+ }
166
+
167
+ // The routing config as this store holds it, read straight off its folder: the store may be configuring itself before it has scanned anything, and this file is what decides what a scan would even talk to
168
+ private async readRoutingConfig(): Promise<RemoteConfig | undefined> {
169
+ let data = await this.ownDisk.get(ROUTING_FILE);
170
+ if (!data || !data.length) return undefined;
171
+ try {
172
+ return parseRoutingData(data);
173
+ } catch (e) {
174
+ console.error(`Ignoring the routing config in store ${this.folder}: it could not be parsed (${(e as Error).message})`);
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ /** The version of the routing config this store is running, so a copy found on a peer is only taken when it is genuinely newer. -1 means it has none. */
180
+ public routingVersion(): number {
181
+ return this.appliedRoutingVersion;
182
+ }
183
+
184
+ // Applying it touches the sources, so it is serialized behind whatever application is already running rather than interleaving with it
185
+ private routingApplies: Promise<void> = Promise.resolve();
186
+ public reapplyRoutingConfig(): void {
187
+ let next = this.routingApplies.then(() => this.applyRoutingConfig());
188
+ this.routingApplies = next.catch(() => { });
189
+ void next.catch((e: Error) => console.error(`Applying the routing config in store ${this.folder} failed: ${e.stack ?? e}`));
190
+ }
191
+
192
+ // Our own disk first (always), then every peer whose route overlaps ours - the sources this store synchronizes with, as its own config describes them
193
+ private planSources(peers: SourceConfig[]): BlobSourceSpec[] {
194
+ let self = this.storeConfig.current();
195
+ let makeSpec = (sourceConfig: SourceConfig | undefined, validWindows: [number, number][], route: [number, number] | undefined, noFullSync: boolean | undefined): BlobSourceSpec => {
196
+ let writeDelay = sourceWriteDelay({ sourceConfig, fast: self.fast, writeDelay: self.writeDelay });
197
+ return {
198
+ identity: sourceIdentity(sourceConfig),
199
+ url: sourcePersistentUrl(sourceConfig, this.folder),
200
+ validWindows,
201
+ route,
202
+ noFullSync,
203
+ intermediate: sourceConfig?.intermediate,
204
+ sourceConfig,
205
+ create: () => {
206
+ let created = this.config?.createSource?.({ sourceConfig, writeDelay });
207
+ if (!created) {
208
+ throw new Error(`This store has no way to build sources, so it cannot use ${JSON.stringify(sourceConfig?.url || this.folder)} (store ${this.folder}). Pass createSource.`);
209
+ }
210
+ return created;
211
+ },
212
+ applyConfig: (source: IArchives) => this.config?.applySource?.(source, sourceConfig, writeDelay),
213
+ };
214
+ };
215
+ // Our own disk accepts writes while ANY window this store holds is open; with no config at all it is simply always valid
216
+ let ownWindows = this.storeConfig.all().map(x => x.validWindow);
217
+ let specs: BlobSourceSpec[] = [makeSpec(undefined, ownWindows.length && ownWindows || [FULL_VALID_WINDOW], undefined, undefined)];
218
+ // One spec per endpoint, carrying all of its windows: a switchover splits one window around an intermediate, and that is still one source to us
219
+ let byIdentity = new Map<string, BlobSourceSpec>();
220
+ for (let peer of peers) {
221
+ let shared = routeIntersection(self.route, peer.route);
222
+ if (!shared) continue;
223
+ let identity = sourceIdentity(peer);
224
+ let existing = byIdentity.get(identity);
225
+ if (existing) {
226
+ existing.validWindows.push(peer.validWindow);
227
+ continue;
228
+ }
229
+ let spec = makeSpec(peer, [peer.validWindow], shared, peer.noFullSync || self.noFullSync);
230
+ byIdentity.set(identity, spec);
231
+ specs.push(spec);
232
+ }
233
+ return specs;
234
+ }
247
235
 
248
- // Stops all synchronization scans/polls and flushes pending writes. Only used when the store genuinely cannot continue (rawDisk flip, process shutdown) - routine config changes go through updateSources instead, which the store survives.
236
+ // Stops all synchronization scans/polls and flushes pending writes. Only used when the store genuinely cannot continue (its route or disk limit changed, process shutdown) - routine config changes go through updateSources instead, which the store survives.
249
237
  public async dispose(): Promise<void> {
250
238
  this.stopped.stop = true;
251
- for (let state of this.sourceStates) {
252
- state.stopped.stop = true;
253
- }
254
- await this.flushOverlay(true);
255
- await this.flushIndex();
239
+ this.sync.stop();
240
+ await this.flushDelayedWrites(true);
241
+ await this.index.flush();
256
242
  }
257
243
 
258
244
  public async get2(config: { path: string; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
@@ -262,25 +248,16 @@ export class BlobStore implements IBucketStore {
262
248
  await this.init();
263
249
  let key = config.path;
264
250
  let range = config.range;
265
- let overlayEntry = this.overlay.get(key);
266
- if (overlayEntry) {
267
- // An empty file IS a missing file (tombstone)
268
- if (overlayEntry.data.length === 0 && !config.includeTombstones) return undefined;
269
- let data = overlayEntry.data;
270
- let size = data.length;
271
- if (range) {
272
- data = data.subarray(Math.min(range.start, data.length), Math.min(range.end, data.length));
273
- }
274
- return { data, writeTime: overlayEntry.t, size };
275
- }
276
- let entry = await this.getIndexEntry(key);
277
- if (!entry) return undefined;
278
- if (entry.size === 0) {
251
+ // Not in the index means it does not exist here: the index IS the answer, and scanning heals it on its own schedule. 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.
252
+ let entry = this.getIndexEntry(key);
253
+ if (!entry) {
279
254
  if (!config.includeTombstones) return undefined;
280
- // A tombstone has no stored bytes - the index entry alone is the deletion, so the flag-caller gets its write time with empty data
281
- return { data: Buffer.alloc(0), writeTime: entry.writeTime, size: 0 };
255
+ // A deletion has no bytes - the tombstone IS the answer, so a flag-caller gets its time with empty data
256
+ let deleted = this.getDeletedEntry(key);
257
+ if (!deleted) return undefined;
258
+ return { data: Buffer.alloc(0), writeTime: deleted.writeTime, size: 0 };
282
259
  }
283
- entry.lastAccess = Date.now();
260
+ this.sync.noteAccess(key);
284
261
  let holderArchives = await this.getEntryHolder(entry);
285
262
  let result: { data: Buffer; writeTime: number; size: number } | undefined;
286
263
  let holderError: Error | undefined;
@@ -334,8 +311,8 @@ export class BlobStore implements IBucketStore {
334
311
  return { data, writeTime: fallback.writeTime, size: fallback.size };
335
312
  }
336
313
  if (holderError) throw holderError;
337
- // The holder answered "not there" and no other source has it either: the entry was stale
338
- this.deleteIndexEntry(key);
314
+ // The holder answered "not there" and no other source has it either: the entry was stale. Forgotten rather than deleted - nothing happened to this file, we were simply wrong about holding it, and saying otherwise would push a deletion out to everyone else.
315
+ this.purgeIndexEntry(key);
339
316
  return undefined;
340
317
  }
341
318
 
@@ -347,25 +324,19 @@ export class BlobStore implements IBucketStore {
347
324
  await this.init();
348
325
  let writeTime = config.lastModified || Date.now();
349
326
  let route = getRoute(key);
350
- // The routing file defines the windows/routes, so they can't possibly apply to it (and it never flows through validation)
351
- if (key !== ROUTING_FILE && this.entries.length) {
352
- if (!config.lastModified) {
353
- let timeValid = this.entries.filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
354
- if (!timeValid.length) {
355
- logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): writeTime ${writeTime} (${new Date(writeTime).toISOString()}) is outside all our valid windows ${JSON.stringify(this.entries.map(x => x.validWindow))} (a switchover moved the write target)`);
356
- throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This store is not a valid write target at ${writeTime} (our valid windows: ${JSON.stringify(this.entries.map(x => x.validWindow))}, store ${this.folder}). Re-resolve the currently valid source and retry.`);
357
- }
358
- if (!timeValid.some(x => routeContains(x.route, route))) {
359
- logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): route ${route} is outside our routes ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))} at writeTime ${writeTime} (the client's shard config is stale)`);
360
- throw new Error(`${STORAGE_WRONG_ROUTE} This store does not handle route ${route} (key ${JSON.stringify(key)}, our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}, store ${this.folder}). Re-resolve the source for this key and retry.`);
361
- }
362
- }
327
+ // The routing file defines the windows/routes, so they can't possibly apply to it (and it never flows through validation) - but it has a rule of its own, which is ours to enforce because the file is ours
328
+ if (key === ROUTING_FILE) {
329
+ this.assertRoutingConfigWritable(data);
330
+ } else {
331
+ this.assertWriteTarget(key, route, config.lastModified);
332
+ }
333
+ if (key !== ROUTING_FILE && this.storeConfig.all().length) {
363
334
  if (config.forceSetImmutable) {
364
335
  if (!config.lastModified) {
365
336
  throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${JSON.stringify(key)} (store ${this.folder})`);
366
337
  }
367
338
  // Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable)
368
- let self = selectEntryAt(this.entries, writeTime, route);
339
+ let self = selectEntryAt(this.storeConfig.all(), writeTime, route);
369
340
  if (self?.immutable && await this.getInfo({ path: key })) return;
370
341
  } else {
371
342
  await this.assertMutable(key, writeTime);
@@ -375,12 +346,14 @@ export class BlobStore implements IBucketStore {
375
346
  if (!config.lastModified) {
376
347
  throw new Error(`Internal writes must carry lastModified (they are synchronization pushes, ordered by their write time), writing ${JSON.stringify(key)} (store ${this.folder})`);
377
348
  }
378
- this.assertInternalWriteAccepted(key, config.lastModified, route);
349
+ // The routing file is exempt for the same reason it skips every other check: it is the file that DEFINES our windows and routes, so judging it by the config it is about to replace is how a store gets stuck on a config it can never be told to leave
350
+ if (key !== ROUTING_FILE) {
351
+ this.assertInternalWriteAccepted(key, config.lastModified, route);
352
+ }
379
353
  await this.setInternal(key, data, { lastModified: config.lastModified });
380
354
  return;
381
355
  }
382
- let self = this.entries.length && selectEntryAt(this.entries, writeTime, route) || undefined;
383
- await this.setOrDelete(key, data, { fast: self?.fast, writeDelay: self?.writeDelay, lastModified: config.lastModified });
356
+ await this.setOrDelete(key, data, { lastModified: config.lastModified });
384
357
  }
385
358
 
386
359
  public async del(config: { path: string; lastModified?: number; internal?: boolean }): Promise<void> {
@@ -389,6 +362,8 @@ export class BlobStore implements IBucketStore {
389
362
  throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be deleted (overwrite it to change the bucket's configuration)`);
390
363
  }
391
364
  await this.init();
365
+ // A deletion lands in exactly the same place a write would, so it is judged the same way: one aimed at a shard we don't serve is just as invisible as a write to it
366
+ this.assertWriteTarget(key, getRoute(key), config.lastModified);
392
367
  if (config.internal) {
393
368
  if (!config.lastModified) {
394
369
  throw new Error(`Internal deletions must carry lastModified (they are synchronization pushes, ordered by their write time), deleting ${JSON.stringify(key)} (store ${this.folder})`);
@@ -398,66 +373,67 @@ export class BlobStore implements IBucketStore {
398
373
  await this.setInternal(key, Buffer.alloc(0), { lastModified: config.lastModified });
399
374
  return;
400
375
  }
401
- // Deletes are tombstone writes (an empty file IS a missing file): the size-0 index entry is ordered by write time like any other write, propagates through synchronization, and expires after TOMBSTONE_EXPIRY
402
- let writeTime = config.lastModified || Date.now();
403
- let self = this.entries.length && selectEntryAt(this.entries, writeTime, getRoute(key)) || undefined;
404
- await this.setOrDelete(key, Buffer.alloc(0), { fast: self?.fast, writeDelay: self?.writeDelay, lastModified: config.lastModified });
376
+ // Deletes are writes (an empty file IS a missing file): the tombstone is ordered by write time like any other write, propagates through synchronization, and is eventually expired
377
+ await this.setOrDelete(key, Buffer.alloc(0), { lastModified: config.lastModified });
378
+ }
379
+
380
+ /** A node-side move: the bytes never travel through the client. Deliberately just get2 + set + del rather than a disk rename, so the destination write passes EVERY rule a set passes (windows, routes, immutability, only-take-latest, index, fan-out to peers) and the deletion propagates as a normal tombstone - a rename would bypass all of it. The set stamps fresh, so the moved file beats any tombstone at its new path. */
381
+ public async move(config: { fromPath: string; toPath: string }): Promise<void> {
382
+ await this.init();
383
+ if (config.fromPath === config.toPath) return;
384
+ if (config.fromPath === ROUTING_FILE || config.toPath === ROUTING_FILE) {
385
+ throw new Error(`The routing config ${JSON.stringify(ROUTING_FILE)} cannot be moved (store ${this.folder})`);
386
+ }
387
+ let result = await this.get2({ path: config.fromPath });
388
+ if (!result || !result.data.length) {
389
+ throw new Error(`Cannot move ${JSON.stringify(config.fromPath)} to ${JSON.stringify(config.toPath)}: the source file does not exist (store ${this.folder})`);
390
+ }
391
+ await this.set({ path: config.toPath, data: result.data });
392
+ await this.del({ path: config.fromPath });
405
393
  }
406
394
 
407
395
  public async getInfo(config: { path: string; includeTombstones?: boolean }): Promise<{ writeTime: number; size: number } | undefined> {
408
396
  await this.init();
409
397
  let key = config.path;
410
- let overlayEntry = this.overlay.get(key);
411
- if (overlayEntry) {
412
- if (!overlayEntry.data.length && !config.includeTombstones) return undefined;
413
- return { writeTime: overlayEntry.t, size: overlayEntry.data.length };
414
- }
415
- let entry = await this.getIndexEntry(key);
416
- if (!entry) return undefined;
417
- if (!entry.size && !config.includeTombstones) return undefined;
418
- return { writeTime: entry.writeTime, size: entry.size };
398
+ // Not in the index means it does not exist here: the index IS the answer, and scanning heals it on its own schedule. 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.
399
+ let entry = this.getIndexEntry(key);
400
+ if (entry) return { writeTime: entry.writeTime, size: entry.size };
401
+ if (!config.includeTombstones) return undefined;
402
+ let deleted = this.getDeletedEntry(key);
403
+ if (!deleted) return undefined;
404
+ return { writeTime: deleted.writeTime, size: 0 };
419
405
  }
420
406
 
421
407
  public async findInfo(config: FindConfig & { prefix: string }): Promise<ArchiveFileInfo[]> {
422
408
  await this.init();
423
- await this.waitForRequiredScans();
409
+ await this.sync.waitForRequiredScans();
424
410
  let prefix = config.prefix;
425
411
  let infos = new Map<string, ArchiveFileInfo>();
426
- for (let [key, entry] of this.mem) {
412
+ // Deletions are not in here at all, which is what a listing wants: an empty file IS a missing file
413
+ for (let [key, entry] of this.indexEntries()) {
427
414
  if (!key.startsWith(prefix)) continue;
428
- // Tombstones are missing files, so listings hide them
429
- if (entry.size === 0) continue;
430
415
  infos.set(key, { path: key, createTime: entry.writeTime, size: entry.size });
431
416
  }
432
- for (let [key, overlayEntry] of this.overlay) {
433
- if (!key.startsWith(prefix)) continue;
434
- if (overlayEntry.data.length === 0) {
435
- infos.delete(key);
436
- continue;
437
- }
438
- infos.set(key, { path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
439
- }
440
417
  let files = applyFindInfoShape(Array.from(infos.values()), prefix, { shallow: config.shallow, type: config.type });
441
418
  sort(files, x => x.path);
442
419
  return files;
443
420
  }
444
421
 
445
- // 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.
422
+ // All files changed after config.time — straight from the 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 entries: a caller reading a change feed has to hear about them, and size 0 is how the feed says "gone" (an empty file IS a missing file). config.routes lets a store syncing a partial shard ask for just its slice.
446
423
  public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
447
424
  await this.init();
448
- await this.waitForRequiredScans();
425
+ await this.sync.waitForRequiredScans();
449
426
  let inRoutes = (key: string) => !config.routes || config.routes.some(route => routeContains(route, getRoute(key)));
450
427
  let files: ArchiveFileInfo[] = [];
451
- for (let [key, entry] of this.mem) {
428
+ for (let [key, entry] of this.indexEntries()) {
452
429
  if (entry.changedAt <= config.time) continue;
453
- if (this.overlay.has(key)) continue;
454
430
  if (!inRoutes(key)) continue;
455
431
  files.push({ path: key, createTime: entry.writeTime, size: entry.size });
456
432
  }
457
- for (let [key, overlayEntry] of this.overlay) {
458
- if (overlayEntry.t <= config.time) continue;
433
+ for (let [key, tombstone] of this.deletedEntries()) {
434
+ if (tombstone.changedAt <= config.time) continue;
459
435
  if (!inRoutes(key)) continue;
460
- files.push({ path: key, createTime: overlayEntry.t, size: overlayEntry.data.length });
436
+ files.push({ path: key, createTime: tombstone.writeTime, size: 0 });
461
437
  }
462
438
  sort(files, x => x.path);
463
439
  return files;
@@ -465,70 +441,68 @@ export class BlobStore implements IBucketStore {
465
441
 
466
442
  public async getSyncStatus(): Promise<ArchivesSyncStatus> {
467
443
  await this.init();
468
- return {
469
- allScansComplete: this.sourceStates.every(x => x.scanComplete),
470
- indexSize: this.mem.size,
471
- sources: this.sources.map((x, i) => ({
472
- debugName: x.source.getDebugName(),
473
- validWindows: x.validWindows,
474
- route: x.route,
475
- noFullSync: x.noFullSync,
476
- supportsChangesAfter: this.sourceStates[i].supportsChangesAfter,
477
- initialScanComplete: this.sourceStates[i].scanComplete,
478
- scannedCount: this.sourceStates[i].scannedCount,
479
- })).filter((x, i) => this.isLive(i)),
480
- };
444
+ return this.sync.getStatus();
481
445
  }
482
446
 
483
- /** The cheap always-current totals plus any in-progress background synchronization. */
447
+ /** The index's totals plus any in-progress background synchronization. */
484
448
  public getSyncProgress(): {
485
449
  index: { fileCount: number; byteCount: number };
486
450
  sources: { debugName: string; fileCount: number; byteCount: number }[];
487
451
  readerDiskLimit?: number;
488
452
  syncing: SyncActivity[];
489
453
  } {
454
+ let totals = this.namedIndexTotals();
490
455
  return {
491
- index: { fileCount: this.indexFileCount, byteCount: this.indexByteCount },
492
- sources: this.sources.map((x, i) => ({
493
- debugName: x.source.getDebugName(),
494
- fileCount: this.sourceFileCounts[i],
495
- byteCount: this.sourceByteCounts[i],
496
- })).filter((x, i) => this.isLive(i)),
497
- readerDiskLimit: this.config?.readerDiskLimit,
498
- syncing: [...this.syncActivities],
456
+ index: { fileCount: totals.fileCount, byteCount: totals.byteCount },
457
+ sources: totals.sources,
458
+ readerDiskLimit: this.readerDiskLimit,
459
+ syncing: this.sync.getActivities(),
499
460
  };
500
461
  }
501
462
 
502
- /** Walks the whole index for exact totals - more expensive than getSyncProgress, but immune to any drift in the maintained counters (and loads the index first, so it's never cold zeros). */
463
+ /** getSyncProgress's totals, but loading the index first, so they are never the zeroes of a store nothing has touched yet. */
503
464
  public async computeIndexTotals(): Promise<{
504
465
  fileCount: number;
505
466
  byteCount: number;
506
467
  sources: { debugName: string; fileCount: number; byteCount: number }[];
507
468
  }> {
508
469
  await this.init();
509
- let fileCount = 0;
510
- let byteCount = 0;
511
- let sources = this.sources.map(x => ({ debugName: x.source.getDebugName(), fileCount: 0, byteCount: 0 }));
512
- for (let entry of this.mem.values()) {
513
- if (entry.size === 0) continue;
514
- fileCount++;
515
- byteCount += entry.size;
516
- let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
517
- if (slot !== undefined) {
518
- sources[slot].fileCount++;
519
- sources[slot].byteCount += entry.size;
520
- }
521
- }
522
- return { fileCount, byteCount, sources: sources.filter((x, i) => this.isLive(i)) };
470
+ return this.namedIndexTotals();
523
471
  }
524
472
 
525
- /** Applies a config change to the RUNNING store: windows/routes update in place, new sources are added (their sync starts immediately), and removed sources' slots go dead (their scans stop, their index entries drop). The store survives every routine config evolution - it is never destroyed for a source-list change, only for structural flips it cannot express (rawDisk). Pending fast writes are re-capped to the new flush deadline (flushing immediately when it has already passed). */
526
- public updateSources(specs: BlobSourceSpec[], entries?: HostedConfig[]): void {
473
+ private namedIndexTotals(): { fileCount: number; byteCount: number; sources: { debugName: string; fileCount: number; byteCount: number }[] } {
474
+ let totals = this.indexTotals();
475
+ return {
476
+ fileCount: totals.fileCount,
477
+ byteCount: totals.byteCount,
478
+ sources: this.sources
479
+ .map((x, i) => ({ debugName: x.source.getDebugName(), ...totals.slots[i] }))
480
+ .filter((x, i) => this.isLive(i)),
481
+ };
482
+ }
483
+
484
+ /**
485
+ * The store's sources, as the current routing config says they should be. This is the ONLY way
486
+ * they are ever set: the first call populates an empty store, every later one applies a change to
487
+ * the running one. Windows, routes and flags move in place, genuinely new endpoints are added and
488
+ * start scanning, and endpoints that are gone go dead (their scans stop, their index entries
489
+ * drop).
490
+ *
491
+ * A store is never rebuilt for a config change. Its name decides its folder and its identity, and
492
+ * a config change cannot change either - so there is nothing a change can do to a store except
493
+ * this.
494
+ */
495
+ public updateSources(specs: BlobSourceSpec[]): void {
527
496
  if (!specs.length || specs[0].identity !== "disk") {
528
497
  throw new Error(`updateSources expects the disk source first (identity "disk"), got ${JSON.stringify(specs.map(x => x.identity))} (store ${this.folder})`);
529
498
  }
530
- if (entries) {
531
- this.entries = entries;
499
+ if (!this.sources.length) {
500
+ // First call: the disk slot has to exist before anything can be matched against it
501
+ let disk = specs[0];
502
+ let source = disk.create();
503
+ asDelayed(source)?.bindFlushDeadline(() => this.writeFlushDeadline());
504
+ this.sources.push({ source, url: disk.url, validWindows: disk.validWindows, route: disk.route, noFullSync: disk.noFullSync, sourceConfig: disk.sourceConfig, identity: disk.identity });
505
+ this.sync.addSource(0);
532
506
  }
533
507
  let setWindows = (i: number, windows: [number, number][]) => {
534
508
  let old = this.sources[i].validWindows;
@@ -555,6 +529,7 @@ export class BlobStore implements IBucketStore {
555
529
  for (let spec of specs.slice(1)) {
556
530
  let slot = liveByIdentity.get(spec.identity)?.shift();
557
531
  if (slot !== undefined) {
532
+ // The SAME endpoint (see sourceIdentity), so we are already synchronized with it: the slot keeps its connection, its completed scans, and its index entries, and only the policy on it moves. Recreating it here would mean rescanning the endpoint from scratch every time a flag changes.
558
533
  matched.add(slot);
559
534
  setWindows(slot, spec.validWindows);
560
535
  let existing = this.sources[slot];
@@ -562,110 +537,98 @@ export class BlobStore implements IBucketStore {
562
537
  console.log(`Route changed for ${existing.source.getDebugName()} (store ${this.folder}): ${JSON.stringify(existing.route)} -> ${JSON.stringify(spec.route)}`);
563
538
  existing.route = spec.route;
564
539
  }
540
+ if (JSON.stringify(existing.sourceConfig) !== JSON.stringify(spec.sourceConfig)) {
541
+ console.log(`Config changed for ${existing.source.getDebugName()} (store ${this.folder}), keeping the source (so it is not rescanned): ${JSON.stringify(existing.sourceConfig)} -> ${JSON.stringify(spec.sourceConfig)}`);
542
+ }
543
+ // The source carries its config (and its write delay) into everything it does, so it has to learn the new one - see applySourceConfig
544
+ spec.applyConfig?.(existing.source);
565
545
  existing.noFullSync = spec.noFullSync;
566
546
  existing.sourceConfig = spec.sourceConfig;
567
547
  continue;
568
548
  }
569
549
  let source = spec.create();
550
+ asDelayed(source)?.bindFlushDeadline(() => this.writeFlushDeadline());
570
551
  this.sources.push({ source, url: spec.url, validWindows: spec.validWindows, route: spec.route, noFullSync: spec.noFullSync, intermediate: spec.intermediate, sourceConfig: spec.sourceConfig, identity: spec.identity });
571
- this.sourceStates.push(newSourceState());
572
- this.sourceFileCounts.push(0);
573
- this.sourceByteCounts.push(0);
574
- console.log(`Added sync source ${source.getDebugName()} (store ${this.folder})`);
575
- if (this.syncStarted) {
576
- void this.runSourceSync(this.sources.length - 1);
577
- }
552
+ console.log(`Added sync source ${source.getDebugName()} (store ${this.folder}): a genuinely new endpoint, so it is scanned from scratch`);
553
+ this.sync.addSource(this.sources.length - 1);
578
554
  }
579
555
  for (let i = 1; i < originalLength; i++) {
580
556
  if (!this.isLive(i) || matched.has(i)) continue;
581
- this.removeSource(i);
582
- }
583
- let deadline = this.baseWriteWindowEnd() - WINDOW_END_FLUSH_MARGIN;
584
- let recapped = 0;
585
- for (let entry of this.overlay.values()) {
586
- if (entry.fastFlushAt <= deadline && entry.slowFlushAt <= deadline) continue;
587
- entry.fastFlushAt = Math.min(entry.fastFlushAt, deadline);
588
- entry.slowFlushAt = Math.min(entry.slowFlushAt, deadline);
589
- recapped++;
590
- }
591
- if (recapped) {
592
- console.log(`Re-capped ${recapped} pending fast writes to the new flush deadline ${new Date(deadline).toISOString()} (store ${this.folder})`);
593
- if (deadline <= Date.now()) {
594
- void this.flushOverlay().catch((e: Error) => console.error(`Flushing fast writes after a valid window change failed (store ${this.folder}): ${e.stack ?? e}`));
595
- }
557
+ // The slot is dead the moment this returns; dropping the index entries it held is the part that reads the index, so it finishes in the background
558
+ void this.sync.removeSource(i).catch((e: Error) => console.error(`Removing sync source ${this.sources[i].source.getDebugName()} (store ${this.folder}) failed: ${e.stack ?? e}`));
596
559
  }
597
560
  }
598
561
 
599
562
  /** 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. */
600
563
  public async rescanBase(): Promise<void> {
601
564
  await this.init();
602
- await this.scanSource(0);
565
+ await this.sync.rescanBase();
603
566
  }
604
567
 
605
- /** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes): just its changes since the boundary neighborhood, with matching values pulled onto our own disk. */
568
+ /** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes). */
606
569
  public async boundaryScanRemote(source: IArchives, config: { since: number; route?: [number, number] }): Promise<void> {
607
570
  await this.init();
608
- let scanStart = Date.now();
609
- 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})`);
610
- let changes = await source.getChangesAfter2({ time: config.since, routes: config.route && [config.route] || undefined });
611
- let tally = newScanTally();
612
- for (let file of changes) {
613
- if (file.path === ROUTING_FILE) {
614
- tally.filtered++;
615
- continue;
616
- }
617
- let overlayEntry = this.overlay.get(file.path);
618
- let entry = this.mem.get(file.path);
619
- let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
620
- if (file.createTime <= currentTime) {
621
- tally.unchanged++;
622
- continue;
623
- }
624
- if (file.size === 0) {
625
- // A tombstone stores nothing on our own source - the index entry alone records it
626
- this.setIndexEntry(file.path, { writeTime: file.createTime, size: 0, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
627
- tally.tombstone++;
628
- continue;
629
- }
630
- let copied = await copyArchiveFile({ from: source, to: this.sources[0].source, path: file.path, size: file.size, writeTime: file.createTime, forceSetImmutable: true, noChecks: true, internal: true });
631
- if (!copied) continue;
632
- if (copied.size === 0) {
633
- this.setIndexEntry(file.path, { writeTime: copied.writeTime, size: 0, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
634
- tally.tombstone++;
635
- continue;
636
- }
637
- this.setIndexEntry(file.path, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
638
- if (entry || overlayEntry) {
639
- tally.updated++;
640
- } else {
641
- tally.new++;
642
- }
643
- }
644
- console.log(`Boundary scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
571
+ await this.sync.boundaryScanRemote(source, config);
645
572
  }
646
573
 
647
- // Large uploads stream onto the local disk source directly (they may not fit in memory)
648
- public async startLargeUpload(config?: { path?: string; lastModified?: number }): Promise<string> {
574
+ // Large uploads stream onto the local disk source directly (they may not fit in memory), and reconciliation carries them to the other sources - there is no delayed-write stage they could pass through. The write itself is validated exactly like set's, right here: the client is about to send the whole file, so every reason to refuse it must be found before the first byte.
575
+ public async startLargeUpload(config?: { path?: string; lastModified?: number; forceSetImmutable?: boolean; noChecks?: boolean; internal?: boolean }): Promise<string> {
649
576
  await this.init();
650
- if (config?.path) {
651
- await this.assertMutable(config.path, config.lastModified || Date.now());
577
+ let key = config?.path;
578
+ if (key) {
579
+ let writeTime = config?.lastModified || Date.now();
580
+ let route = getRoute(key);
581
+ if (key !== ROUTING_FILE) {
582
+ this.assertWriteTarget(key, route, config?.lastModified);
583
+ }
584
+ if (key !== ROUTING_FILE && this.storeConfig.all().length) {
585
+ if (config?.forceSetImmutable) {
586
+ if (!config.lastModified) {
587
+ throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), uploading ${JSON.stringify(key)} (store ${this.folder})`);
588
+ }
589
+ // Immutability wins: an existing path is kept instead of the push throwing (see SetConfig.forceSetImmutable). The client is already streaming, so the upload is accepted and every part discarded - the alternative is throwing at a caller that did nothing wrong.
590
+ let self = selectEntryAt(this.storeConfig.all(), writeTime, route);
591
+ if (self?.immutable && await this.getInfo({ path: key })) {
592
+ let id = `${DISCARDED_UPLOAD_PREFIX}${this.nextDiscardedUpload++}`;
593
+ this.discardedUploads.add(id);
594
+ return id;
595
+ }
596
+ } else {
597
+ await this.assertMutable(key, writeTime);
598
+ }
599
+ }
600
+ if (config?.internal) {
601
+ if (!config.lastModified) {
602
+ throw new Error(`Internal writes must carry lastModified (they are synchronization pushes, ordered by their write time), uploading ${JSON.stringify(key)} (store ${this.folder})`);
603
+ }
604
+ this.assertInternalWriteAccepted(key, config.lastModified, route);
605
+ }
652
606
  }
653
607
  return await this.getDiskSource().disk.startLargeUpload();
654
608
  }
655
609
  public async appendLargeUpload(config: { id: string; data: Buffer }): Promise<void> {
610
+ if (this.discardedUploads.has(config.id)) return;
656
611
  await this.getDiskSource().disk.appendLargeUpload(config.id, config.data);
657
612
  }
658
- public async finishLargeUpload(config: { id: string; path: string; lastModified?: number }): Promise<void> {
613
+ public async finishLargeUpload(config: { id: string; path: string; lastModified?: number; forceSetImmutable?: boolean; noChecks?: boolean; internal?: boolean }): Promise<void> {
614
+ if (this.discardedUploads.delete(config.id)) return;
659
615
  let { disk, sourceIndex } = this.getDiskSource();
616
+ if (config.lastModified) {
617
+ assertValidLastModified(config.lastModified);
618
+ // An older write never overwrites a newer one (see IArchives.set) - a newer value can land while a long upload is still streaming, so this is re-checked here rather than only at the start
619
+ if (config.lastModified < await this.currentWriteTime(config.path)) {
620
+ await disk.cancelLargeUpload(config.id);
621
+ return;
622
+ }
623
+ }
660
624
  await disk.finishLargeUpload(config.id, config.path, config.lastModified);
661
- this.overlay.delete(config.path);
662
- // includeTombstones: a zero-byte upload is still a real file whose index entry must be written
663
- let info = await disk.getInfo(config.path, { includeTombstones: true });
625
+ let info = await disk.getInfo(config.path);
664
626
  if (info) {
665
627
  this.setIndexEntry(config.path, { writeTime: info.writeTime, size: info.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
666
628
  }
667
629
  }
668
630
  public async cancelLargeUpload(config: { id: string }): Promise<void> {
631
+ if (this.discardedUploads.delete(config.id)) return;
669
632
  await this.getDiskSource().disk.cancelLargeUpload(config.id);
670
633
  }
671
634
 
@@ -673,42 +636,21 @@ export class BlobStore implements IBucketStore {
673
636
 
674
637
  // #region Internals
675
638
 
676
- private stopped = { stop: false };
639
+ /** Bytes of read cache the disk may hold; see CommonConfig.readerDiskLimit (StoreSync enforces it). Read from the config in effect, so raising or removing the limit takes effect on the next eviction pass. */
640
+ public get readerDiskLimit(): number | undefined {
641
+ return this.storeConfig.current().readerDiskLimit;
642
+ }
677
643
 
678
- // 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.
679
- private index = new BulkDatabaseBase<BlobIndexEntry>("blobIndex2", noopReactiveDeps, async (p: string) => {
680
- let base: DirectoryWrapper = new NodeJSDirectoryHandleWrapper(path.join(this.folder, "index"));
681
- for (let part of p.split("/")) {
682
- if (part) base = await base.getDirectoryHandle(part, { create: true });
683
- }
684
- return wrapHandle(base);
685
- });
686
- // The in-memory copy of the index. All reads are served from it; changes are buffered in dirty and flushed to the BulkDatabase2 in batches (undefined = delete).
687
- private mem = new Map<string, IndexEntry>();
688
- // Live totals over mem (tombstones excluded), adjusted on every mutation and recomputed on load - so any drift heals on restart. computeIndexTotals gives the walk-the-index truth.
689
- private indexFileCount = 0;
690
- private indexByteCount = 0;
691
- // The same totals per holding source (index 0 = our disk, which readerDiskLimit bounds)
692
- private sourceFileCounts = this.sources.map(() => 0);
693
- private sourceByteCounts = this.sources.map(() => 0);
694
- // Background scans / full syncs currently in progress (a Set - one source can have a change poll's full sync and a rescan overlapping)
695
- private syncActivities = new Set<SyncActivity>();
696
- private dirty = new Map<string, IndexEntry | undefined>();
697
- private overlay = new Map<string, OverlayEntry>();
698
- private sourceStates = this.sources.map(() => newSourceState());
699
- private syncStarted = false;
700
- private entries = this.config?.entries || [];
701
- // The persistent identities behind IndexEntry.sourcesListIndex (see SourcesList)
702
- private sourcesList = new SourcesList(path.join(this.folder, "index", "sourcesList.txt"));
703
- // Per slot: the persistent sourcesListIndex of that slot's URL, filled by registerSlot before the slot's sync runs
704
- private slotSourcesListIndexes: number[] = [];
705
- private slotRegistrations: Promise<void>[] = [];
644
+ /** The write time a new write has to beat, or 0 when we have never heard of the key. Counts DELETIONS too: a write older than the deletion that removed it must not bring it back. The index is authoritative even for a write still buffered in a delayed source, since the entry is recorded when the write is accepted rather than when it reaches storage. */
645
+ public currentWriteTime(key: string): number {
646
+ return this.index.timeOf(key);
647
+ }
706
648
 
707
649
  private isLive(sourceIndex: number): boolean {
708
- return !!this.sources[sourceIndex] && !this.sourceStates[sourceIndex].dead;
650
+ return this.sync.isLive(sourceIndex);
709
651
  }
710
652
 
711
- private registerSlot(slot: number): Promise<void> {
653
+ public registerSlot(slot: number): Promise<void> {
712
654
  let existing = this.slotRegistrations[slot];
713
655
  if (existing) return existing;
714
656
  let registration = this.sourcesList.ensure(this.sources[slot].url).then(index => {
@@ -718,8 +660,13 @@ export class BlobStore implements IBucketStore {
718
660
  return registration;
719
661
  }
720
662
 
721
- // The persistent sourcesListIndex of a slot - only valid once the slot's registration resolved (init and runSourceSync guarantee that before any indexing happens)
722
- private sourcesListIndexOfSlot(slot: number): number {
663
+ /** The persistent sourcesListIndex of a slot, or undefined when the slot never got that far (a source removed before its registration resolved). */
664
+ public slotSourcesListIndex(slot: number): number | undefined {
665
+ return this.slotSourcesListIndexes[slot];
666
+ }
667
+
668
+ // The persistent sourcesListIndex of a slot - only valid once the slot's registration resolved (init and the source's sync loop guarantee that before any indexing happens)
669
+ public sourcesListIndexOfSlot(slot: number): number {
723
670
  let index = this.slotSourcesListIndexes[slot];
724
671
  if (index === undefined) {
725
672
  throw new Error(`Source slot ${slot} (${this.sources[slot]?.url}) has no registered sourcesListIndex yet (store ${this.folder})`);
@@ -728,7 +675,7 @@ export class BlobStore implements IBucketStore {
728
675
  }
729
676
 
730
677
  // 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).
731
- private slotForSourcesListIndex(sourcesListIndex: number): number | undefined {
678
+ public slotForSourcesListIndex(sourcesListIndex: number): number | undefined {
732
679
  for (let i = 0; i < this.slotSourcesListIndexes.length; i++) {
733
680
  if (this.slotSourcesListIndexes[i] === sourcesListIndex && this.isLive(i)) return i;
734
681
  }
@@ -736,7 +683,7 @@ export class BlobStore implements IBucketStore {
736
683
  }
737
684
 
738
685
  // 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
739
- private async getEntryHolder(entry: IndexEntry): Promise<IArchives | undefined> {
686
+ public async getEntryHolder(entry: IndexEntry): Promise<IArchives | undefined> {
740
687
  let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
741
688
  if (slot !== undefined) return this.sources[slot].source;
742
689
  let url = this.sourcesList.getUrl(entry.sourcesListIndex) || await this.sourcesList.getUrlReloading(entry.sourcesListIndex);
@@ -745,465 +692,170 @@ export class BlobStore implements IBucketStore {
745
692
  }
746
693
 
747
694
  private async loadIndex(): Promise<void> {
748
- let [writeTimes, sizes, sourcesListIndexes] = await Promise.all([
749
- this.index.getColumn("writeTime"),
750
- this.index.getColumn("size"),
751
- this.index.getColumn("sourcesListIndex"),
752
- ]);
753
- let sizeMap = new Map(sizes.map(x => [x.key, x.value]));
754
- let sourcesListIndexMap = new Map(sourcesListIndexes.map(x => [x.key, x.value]));
755
- for (let entry of writeTimes) {
756
- let size = sizeMap.get(entry.key);
757
- let sourcesListIndex = sourcesListIndexMap.get(entry.key);
758
- // Explicit checks, as 0 is a valid size and a valid sourcesListIndex
759
- if (size === undefined || sourcesListIndex === undefined) continue;
760
- // 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
761
- if (entry.key === ROUTING_FILE) {
762
- sourcesListIndex = this.sourcesListIndexOfSlot(0);
763
- }
764
- let full: IndexEntry = { writeTime: entry.value, size, sourcesListIndex, changedAt: entry.time, lastAccess: entry.time };
765
- this.mem.set(entry.key, full);
766
- this.countEntry(full, 1);
695
+ await this.index.load();
696
+ // The routing config is only ever read off our own disk (see StoreSync's scan handling), and a loaded bucket always has it there - a persisted entry pointing elsewhere is stale, so it is corrected once, here
697
+ let routing = this.getIndexEntry(ROUTING_FILE);
698
+ let baseSourcesListIndex = this.sourcesListIndexOfSlot(0);
699
+ if (routing && routing.sourcesListIndex !== baseSourcesListIndex) {
700
+ this.setIndexEntry(ROUTING_FILE, { writeTime: routing.writeTime, size: routing.size, sourcesListIndex: baseSourcesListIndex });
767
701
  }
768
702
  }
769
703
 
770
- private countEntry(entry: IndexEntry | undefined, direction: number): void {
771
- if (!entry || entry.size === 0) return;
772
- this.indexFileCount += direction;
773
- this.indexByteCount += entry.size * direction;
774
- // Entries can reference a source no longer configured (readable via getEntryHolder, but with no slot to count under)
775
- let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
776
- if (slot !== undefined) {
777
- this.sourceFileCounts[slot] += direction;
778
- this.sourceByteCounts[slot] += entry.size * direction;
779
- }
704
+ /** A file we hold. A deleted one is not one: it is a tombstone, and only getDeletedEntry knows about it. */
705
+ public getIndexEntry(key: string): IndexEntry | undefined {
706
+ let entry = this.index.get(key);
707
+ if (!entry) return undefined;
708
+ return { ...entry.value, writeTime: entry.time, changedAt: entry.changedAt };
780
709
  }
781
710
 
782
- private setIndexEntry(key: string, entry: { writeTime: number; size: number; sourcesListIndex: number }): void {
783
- let full: IndexEntry = { ...entry, changedAt: Date.now(), lastAccess: Date.now() };
784
- this.countEntry(this.mem.get(key), -1);
785
- this.countEntry(full, 1);
786
- this.mem.set(key, full);
787
- this.dirty.set(key, full);
788
- this.config?.onIndexChanged?.(key);
789
- }
790
- private deleteIndexEntry(key: string): void {
791
- let existing = this.mem.get(key);
792
- if (!existing) return;
793
- this.countEntry(existing, -1);
794
- this.mem.delete(key);
795
- this.dirty.set(key, undefined);
711
+ /** When a key was deleted, if it was. A deletion is an absence with a time attached - that time is what makes it propagate and what expires it. */
712
+ public getDeletedEntry(key: string): { writeTime: number; changedAt: number } | undefined {
713
+ let tombstone = this.index.getDeleted(key);
714
+ if (!tombstone) return undefined;
715
+ return { writeTime: tombstone.time, changedAt: tombstone.changedAt };
796
716
  }
797
717
 
798
- // The slot stays in the arrays forever (index entries reference sources by slot number); it just goes dead - loops stop, and its index entries drop (other sources' scans re-find any copy that's still reachable through the new config)
799
- private removeSource(sourceIndex: number): void {
800
- let state = this.sourceStates[sourceIndex];
801
- let source = this.sources[sourceIndex].source;
802
- state.dead = true;
803
- state.stopped.stop = true;
804
- state.scanComplete = true;
805
- state.initialScan.resolve(undefined);
806
- let sourcesListIndex = this.slotSourcesListIndexes[sourceIndex];
807
- // The same URL can be another live slot (one entry per valid window) - the endpoint is still configured, so its entries stay
808
- if (sourcesListIndex !== undefined && this.slotForSourcesListIndex(sourcesListIndex) === undefined) {
809
- let dropped = 0;
810
- for (let [key, entry] of this.mem) {
811
- if (entry.sourcesListIndex !== sourcesListIndex) continue;
812
- this.deleteIndexEntry(key);
813
- dropped++;
814
- }
815
- console.log(`Removed sync source ${source.getDebugName()} (store ${this.folder}): its scans are stopped and ${dropped} index entries it held were dropped`);
816
- return;
718
+ /** Every file we hold, for the passes that walk them all (listings, scans, reconciliation, eviction). Deletions are not in here - see deletedEntries. Live: deleting entries while iterating is expected here, and safe. */
719
+ public *indexEntries(): IterableIterator<[string, IndexEntry]> {
720
+ for (let [key, entry] of this.index.entries()) {
721
+ yield [key, { ...entry.value, writeTime: entry.time, changedAt: entry.changedAt }];
817
722
  }
818
- 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)`);
819
723
  }
820
724
 
821
- private async flushIndex(): Promise<void> {
822
- if (!this.dirty.size) return;
823
- let dirty = this.dirty;
824
- this.dirty = new Map();
825
- let writes: BlobIndexEntry[] = [];
826
- let deletes: string[] = [];
827
- for (let [key, entry] of dirty) {
828
- if (entry) {
829
- writes.push({ key, writeTime: entry.writeTime, size: entry.size, sourcesListIndex: entry.sourcesListIndex });
830
- } else {
831
- deletes.push(key);
832
- }
725
+ /** Every deletion we know of. A much smaller walk than the files, which is what makes expiring them cheap. */
726
+ public *deletedEntries(): IterableIterator<[string, { writeTime: number; changedAt: number }]> {
727
+ for (let [key, tombstone] of this.index.deletedEntries()) {
728
+ yield [key, { writeTime: tombstone.time, changedAt: tombstone.changedAt }];
833
729
  }
834
- if (writes.length) await this.index.writeBatch(writes);
835
- if (deletes.length) await this.index.deleteBatch(deletes);
836
730
  }
837
731
 
838
- // ── validation (from this store's own routing entries) ──
839
-
840
- private async assertMutable(key: string, writeTime: number): Promise<void> {
841
- if (!this.entries.length) return;
842
- let self = selectEntryAt(this.entries, writeTime, getRoute(key));
843
- if (!self?.immutable) return;
844
- if (await this.getInfo({ path: key })) {
845
- throw new Error(`This store is immutable (at write time ${writeTime}) and ${JSON.stringify(key)} already exists, so it cannot be written to (store ${this.folder})`);
846
- }
732
+ /** How many files we hold, deletions excluded. */
733
+ public indexSize(): number {
734
+ return this.index.size;
847
735
  }
848
736
 
849
- // See SetConfig.internal: the stamp must land inside SOME window+route this store is configured for (any window, including past ones - synchronization moves old data), so a confused peer can't stuff data onto a store that was never meant to hold it
850
- private assertInternalWriteAccepted(key: string, writeTime: number, route: number): void {
851
- if (!this.entries.length) return;
852
- let covered = this.entries.some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
853
- if (!covered) {
854
- throw new Error(`Internal write of ${JSON.stringify(key)} rejected: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) at route ${route} is outside every window/route this store is configured for: ${JSON.stringify(this.entries.map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))} (store ${this.folder})`);
737
+ /** Totals over the files we hold, broken down by the slot holding each (entries can name a source that is no longer configured, which counts towards the total but no slot). */
738
+ public indexTotals(): { fileCount: number; byteCount: number; slots: { fileCount: number; byteCount: number }[] } {
739
+ let fileCount = 0;
740
+ let byteCount = 0;
741
+ let slots = this.sources.map(() => ({ fileCount: 0, byteCount: 0 }));
742
+ for (let [, entry] of this.indexEntries()) {
743
+ fileCount++;
744
+ byteCount += entry.size;
745
+ let slot = this.slotForSourcesListIndex(entry.sourcesListIndex);
746
+ if (slot === undefined) continue;
747
+ slots[slot].fileCount++;
748
+ slots[slot].byteCount += entry.size;
855
749
  }
750
+ return { fileCount, byteCount, slots };
856
751
  }
857
752
 
858
- // ── synchronization ──
859
-
860
- private async runSourceSync(sourceIndex: number): Promise<void> {
861
- await this.registerSlot(sourceIndex);
862
- let { source } = this.sources[sourceIndex];
863
- let state = this.sourceStates[sourceIndex];
864
- // Read live for every pass, not captured - updateSources can change it while loops run
865
- let noFullSync = () => this.sources[sourceIndex].noFullSync;
866
- let intermediate = this.sources[sourceIndex].intermediate;
867
- let listing: Map<string, number> | undefined;
868
- while (!this.stopped.stop && !state.stopped.stop) {
869
- if (this.isDeadIntermediate(sourceIndex)) {
870
- console.log(`Not scanning sync source ${source.getDebugName()} (store ${this.folder}): it is an intermediate whose window already ended`);
871
- break;
872
- }
873
- try {
874
- let config = await source.getConfig();
875
- state.supportsChangesAfter = !!config.supportsChangesAfter;
876
- listing = await this.scanSource(sourceIndex);
877
- break;
878
- } catch (e) {
879
- if (intermediate) {
880
- console.error(`Initial scan of intermediate sync source ${source.getDebugName()} (store ${this.folder}) failed; not retrying (intermediates are temporary switchover ports): ${(e as Error).stack ?? e}`);
881
- break;
882
- }
883
- console.error(`Initial scan of sync source ${source.getDebugName()} failed, retrying:`, e);
884
- await delay(SCAN_RETRY_DELAY);
885
- }
886
- }
887
- state.scanComplete = true;
888
- state.initialScan.resolve(undefined);
889
- if (this.stopped.stop || state.stopped.stop) return;
890
- // An intermediate that never produced a listing is dead or dying - polling and copying from it would only log errors
891
- if (intermediate && !listing) return;
892
- if (listing) {
893
- await this.reconcileSource(sourceIndex, listing);
894
- }
895
- if (!noFullSync()) {
896
- try {
897
- await this.copySourceFiles(sourceIndex);
898
- } catch (e) {
899
- console.error(`Copying files from sync source ${source.getDebugName()} failed:`, e);
900
- }
901
- }
902
- if (state.supportsChangesAfter) {
903
- runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
904
- if (this.isDeadIntermediate(sourceIndex)) return;
905
- await this.pollChanges(sourceIndex);
906
- if (!noFullSync()) await this.copySourceFiles(sourceIndex);
907
- }, state.stopped);
908
- // Change polls only show what the source HAS, never what it's missing, so pushes run on the full-rescan cadence (findInfo on an index-backed source is cheap)
909
- runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
910
- if (this.isDeadIntermediate(sourceIndex)) return;
911
- let files = await source.findInfo("");
912
- await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
913
- }, state.stopped);
914
- } else {
915
- runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
916
- if (this.isDeadIntermediate(sourceIndex)) return;
917
- let rescan = await this.scanSource(sourceIndex);
918
- await this.reconcileSource(sourceIndex, rescan);
919
- if (!noFullSync()) await this.copySourceFiles(sourceIndex);
920
- }, state.stopped);
753
+ /** Records a file, as of its write time. Returns false, having changed nothing, when we already know something at least as new - the index cannot be made to go backwards, whichever path the write came in by. */
754
+ public setIndexEntry(key: string, entry: { writeTime: number; size: number; sourcesListIndex: number }): boolean {
755
+ if (!this.index.set(key, { size: entry.size, sourcesListIndex: entry.sourcesListIndex }, entry.writeTime)) return false;
756
+ // The routing config landing here - written by an operator, or pulled off a peer by synchronization - is how this store learns what it is meant to be
757
+ if (key === ROUTING_FILE && this.syncStarted) {
758
+ this.reapplyRoutingConfig();
921
759
  }
760
+ this.config?.onIndexChanged?.(key);
761
+ return true;
922
762
  }
923
763
 
924
- // An intermediate is a deploy switchover's temporary alternate port: once its window is past, the port is gone for good, so scanning it (or retrying a failed scan) can never succeed - it would just log errors forever. (Dead only once EVERY window has ended - an intermediate normally has just one.)
925
- private isDeadIntermediate(sourceIndex: number): boolean {
926
- let { intermediate, validWindows } = this.sources[sourceIndex];
927
- return !!intermediate && validWindows.every(w => w[1] <= Date.now());
764
+ /** Records a DELETION, as of its time: the key stops existing here, and the tombstone is what makes that fact propagate and reconcile like any other write. Same ordering rule as setIndexEntry. */
765
+ public setIndexDeleted(key: string, writeTime: number): boolean {
766
+ if (!this.index.delete(key, writeTime)) return false;
767
+ this.config?.onIndexChanged?.(key);
768
+ return true;
928
769
  }
929
770
 
930
- // Full metadata scan (size, writeTime, path) of one source, applied to the index. Returns the source's listing (path -> write time), which reconcileSource uses for the push direction.
931
- private async scanSource(sourceIndex: number): Promise<Map<string, number>> {
932
- let { source, route } = this.sources[sourceIndex];
933
- let state = this.sourceStates[sourceIndex];
934
- let scanStart = Date.now();
935
- let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
936
- this.syncActivities.add(activity);
937
- console.log(`Metadata scan of ${source.getDebugName()} starting (store ${this.folder})`);
938
- let progressTimer = setInterval(() => {
939
- console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.folder})`);
940
- }, SYNC_PROGRESS_LOG_INTERVAL);
941
- (progressTimer as { unref?: () => void }).unref?.();
942
- // 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.
943
- let files: ArchiveFileInfo[];
944
- try {
945
- files = await source.findInfo("");
946
- } finally {
947
- clearInterval(progressTimer);
948
- this.syncActivities.delete(activity);
949
- }
950
- // The source may have been removed while the listing was in flight; its results are dead
951
- if (state.stopped.stop) return new Map();
952
- let indexSizeBefore = this.mem.size;
953
- let seen = new Map<string, number>();
954
- let tally = newScanTally();
955
- let newPaths = 0;
956
- for (let file of files) {
957
- seen.set(file.path, file.createTime);
958
- if (!this.mem.has(file.path)) {
959
- newPaths++;
960
- }
961
- tally[this.updateScanIndex(sourceIndex, file)]++;
962
- }
963
- state.scannedCount = files.length;
964
- // 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).
965
- let removedFromIndex = 0;
966
- let missingOnSource = 0;
967
- let scannedSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
968
- for (let [key, entry] of this.mem) {
969
- if (seen.has(key)) continue;
970
- if (entry.sourcesListIndex === scannedSourcesListIndex && entry.size !== 0 && entry.changedAt < scanStart) {
971
- this.deleteIndexEntry(key);
972
- removedFromIndex++;
973
- continue;
974
- }
975
- // Counted only when the source SHOULD hold the entry (its route matches) - these are what the reconcile pass pushes to it (which also ignores the valid window: synchronization moves existing values, the window only routes fresh writes)
976
- if (entry.size === 0 || key === ROUTING_FILE) continue;
977
- if (!routeContains(route, getRoute(key))) continue;
978
- missingOnSource++;
979
- }
980
- // Percentages are of the union of both sides (our index + their listing), so every count has a stable denominator
981
- let union = indexSizeBefore + newPaths;
982
- let pct = (n: number) => `${Math.round(n / Math.max(union, 1) * 1000) / 10}%`;
983
- console.log(`Metadata scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.folder}): ${files.length} listed vs ${indexSizeBefore} indexed (union ${union}): ${formatScanTally(tally, union)}, ${missingOnSource} in index but missing on source (${pct(missingOnSource)}), ${removedFromIndex} removed from index (${pct(removedFromIndex)})`);
984
- state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
985
- return seen;
771
+ /** Forgets a key entirely, tombstone included. NOT a deletion: it says nothing happened to the file, only that we no longer know anything about it - for an entry whose holder turned out not to have it, and for a tombstone old enough that everyone has heard. */
772
+ public purgeIndexEntry(key: string): void {
773
+ this.index.purge(key);
986
774
  }
987
775
 
988
- // 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.
989
- private async reconcileSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
990
- let { source, validWindows, route } = this.sources[sourceIndex];
991
- let state = this.sourceStates[sourceIndex];
992
- let acceptsWrites = windowsAcceptWrites(validWindows);
993
- let targetSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
994
- let pushed = 0;
995
- let failed = 0;
996
- let consecutiveFailures = 0;
997
- let errors: string[] = [];
998
- let aborted = false;
999
- for (let [key, entry] of this.mem) {
1000
- if (this.stopped.stop || state.stopped.stop) return;
1001
- if (entry.sourcesListIndex === targetSourcesListIndex) continue;
1002
- // 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
1003
- if (key === ROUTING_FILE) continue;
1004
- if (!acceptsWrites) continue;
1005
- if (!routeContains(route, getRoute(key))) continue;
1006
- let theirTime = listing.get(key);
1007
- if (theirTime !== undefined && theirTime >= entry.writeTime) continue;
1008
- try {
1009
- if (entry.size === 0) {
1010
- // A deletion only needs pushing while the source still holds an older copy. It travels as del (never as an empty set - set rejects empty buffers), with the ORIGINAL deletion time so ordering survives.
1011
- if (theirTime === undefined) continue;
1012
- await source.del(key, { lastModified: entry.writeTime, noChecks: true, internal: true });
1013
- pushed++;
1014
- consecutiveFailures = 0;
1015
- continue;
1016
- }
1017
- let holder = await this.getEntryHolder(entry);
1018
- if (!holder) continue;
1019
- let copied = await copyArchiveFile({ from: holder, to: source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
1020
- if (!copied) continue;
1021
- pushed++;
1022
- consecutiveFailures = 0;
1023
- } catch (e) {
1024
- failed++;
1025
- consecutiveFailures++;
1026
- if (errors.length < RECONCILE_ERROR_LOG_LIMIT) {
1027
- errors.push(`${key}: ${(e as Error).stack ?? e}`);
1028
- }
1029
- if (consecutiveFailures >= RECONCILE_MAX_CONSECUTIVE_FAILURES) {
1030
- aborted = true;
1031
- break;
1032
- }
1033
- }
1034
- }
1035
- if (failed) {
1036
- 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(" | ")}`);
1037
- } else if (pushed) {
1038
- console.log(`Reconciled sync source ${source.getDebugName()} (store ${this.folder}): pushed ${pushed} files it was missing or held older copies of`);
1039
- }
1040
- }
776
+ // ── validation (from this store's own routing entries) ──
1041
777
 
1042
- private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
1043
- // An in-flight scan can outlive its source's removal; its results are dead
1044
- if (!this.isLive(sourceIndex)) return "filtered";
1045
- if (file.path === ROUTING_FILE) {
1046
- // The routing config is NEVER pulled from other sources - it only ever arrives as an explicit, version-validated write, and is only ever read off our own disk. Route and valid-window filters can't possibly apply to it either: it is the file DEFINING them, so filtering it would mean certain sources could never have their routing config updated, ever.
1047
- if (sourceIndex !== 0) return "filtered";
1048
- } else {
1049
- // The valid window is deliberately NOT applied here: it decides where WRITES route, but a scan is us asking a source what it already holds - existing values synchronize regardless of the window (the same reasoning that lets synchronization ignore the immutable flag). Only the route filters: a partially-overlapping shard's listing legitimately includes keys that aren't ours.
1050
- let { route } = this.sources[sourceIndex];
1051
- if (!routeContains(route, getRoute(file.path))) return "filtered";
778
+ /**
779
+ * Every write, however it is stamped, has to be one we are actually meant to hold - because the
780
+ * alternative is not a smaller problem, it is a silent one. A write that lands on a store that
781
+ * does not serve its route (or on a server that is not in the bucket's config at all) goes into a
782
+ * folder nothing scans and no peer reconciles: it succeeds, and then it is gone. The markers make
783
+ * the client re-read the routing config and retry, which is exactly the right outcome when the
784
+ * reason it aimed here is that its config was stale.
785
+ */
786
+ private assertWriteTarget(key: string, route: number, lastModified: number | undefined): void {
787
+ if (!this.storeConfig.all().length) {
788
+ logWrongTargetRejection(`Rejecting write of ${JSON.stringify(key)} (store ${this.folder}): this server has no entry in the bucket's routing config, so nothing would ever scan or reconcile what it stored`);
789
+ throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This server is not in the routing config for this bucket, so it cannot accept writes (store ${this.folder}). Re-resolve the currently valid source and retry.`);
790
+ }
791
+ if (!lastModified) {
792
+ this.assertFreshWriteTarget(key, Date.now(), route);
793
+ return;
1052
794
  }
1053
- let existing = this.mem.get(file.path);
1054
- // The highest write time wins across all sources (ties keep the existing entry)
1055
- if (existing && file.createTime <= existing.writeTime) return "unchanged";
1056
- this.setIndexEntry(file.path, { writeTime: file.createTime, size: file.size, sourcesListIndex: this.sourcesListIndexOfSlot(sourceIndex) });
1057
- if (file.size === 0) return "tombstone";
1058
- if (existing) return "updated";
1059
- return "new";
795
+ // A stamped write picked its own time, so no window can judge it (that is the whole point of a synchronized write) - but the ROUTE is not a matter of timing, and a stamped write to the wrong shard is exactly as invisible as a fresh one
796
+ if (this.storeConfig.all().some(x => routeContains(x.route, route))) return;
797
+ logWrongTargetRejection(`Rejecting stamped write of ${JSON.stringify(key)} (store ${this.folder}): route ${route} is outside every route we serve ${JSON.stringify(this.storeConfig.all().map(x => x.route || FULL_ROUTE))}`);
798
+ throw new Error(`${STORAGE_WRONG_ROUTE} This store does not handle route ${route} (key ${JSON.stringify(key)}, our routes: ${JSON.stringify(this.storeConfig.all().map(x => x.route || FULL_ROUTE))}, store ${this.folder}). Re-resolve the source for this key and retry.`);
1060
799
  }
1061
800
 
1062
- private async pollChanges(sourceIndex: number): Promise<void> {
1063
- let { source, route } = this.sources[sourceIndex];
1064
- let state = this.sourceStates[sourceIndex];
1065
- let pollStart = Date.now();
1066
- let changes = await source.getChangesAfter2({ time: state.changesAfterTime, routes: route && [route] || undefined });
1067
- let tally = newScanTally();
1068
- for (let file of changes) {
1069
- tally[this.updateScanIndex(sourceIndex, file)]++;
1070
- }
1071
- // Polls run constantly, so only the ones that actually changed the index get a line
1072
- if (tally.new || tally.updated || tally.tombstone) {
1073
- console.log(`Changes poll of ${source.getDebugName()} (store ${this.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
1074
- }
1075
- state.scannedCount += changes.length;
1076
- state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
801
+ /**
802
+ * Whether a routing config may be written here. Two rules, and this is the one place either is
803
+ * applied - a config only ever enters the system through a write, so a config that got in is a
804
+ * config that passed, and reading one back never judges it again.
805
+ *
806
+ * The config has to be valid as a whole (see assertValidRemoteConfig), and it has to outrank what
807
+ * we are running: the same version means the same config, so re-writing it is harmless, but a
808
+ * lower one is an older config arriving late and must never undo a newer one.
809
+ */
810
+ private assertRoutingConfigWritable(data: Buffer): void {
811
+ let routing = parseRoutingData(data);
812
+ assertValidRemoteConfig(routing);
813
+ let incoming = getConfigVersion(routing);
814
+ let current = this.routingVersion();
815
+ if (incoming >= current) return;
816
+ throw new Error(`Refusing to write ${ROUTING_FILE} to store ${this.folder}: its version (${incoming}) is older than the one this store is running (${current}). Increment the version to update it.`);
1077
817
  }
1078
818
 
1079
- // Downloads the files a source currently holds onto our own base source (the local disk), preserving their modified times so a newer local write always wins. Skipped for noFullSync sources (fronting a large database without copying it); reads still down-cache lazily.
1080
- private async copySourceFiles(sourceIndex: number): Promise<void> {
1081
- if (sourceIndex === 0) return;
1082
- let { source } = this.sources[sourceIndex];
1083
- let state = this.sourceStates[sourceIndex];
1084
- let pending: { key: string; entry: IndexEntry }[] = [];
1085
- let totalBytes = 0;
1086
- let copiedSourcesListIndex = this.sourcesListIndexOfSlot(sourceIndex);
1087
- for (let [key, entry] of this.mem) {
1088
- if (entry.sourcesListIndex !== copiedSourcesListIndex) continue;
1089
- if (entry.size === 0) continue;
1090
- pending.push({ key, entry });
1091
- totalBytes += entry.size;
819
+ // A fresh (unstamped) write must land on the node the config currently points at: the markers tell the client its config is stale, so it re-resolves and retries against the right node instead of us silently accepting data we were never meant to hold.
820
+ private assertFreshWriteTarget(key: string, writeTime: number, route: number): void {
821
+ let timeValid = this.storeConfig.all().filter(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1]);
822
+ if (!timeValid.length) {
823
+ logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): writeTime ${writeTime} (${new Date(writeTime).toISOString()}) is outside all our valid windows ${JSON.stringify(this.storeConfig.all().map(x => x.validWindow))} (a switchover moved the write target)`);
824
+ throw new Error(`${STORAGE_WRONG_VALID_WINDOW} This store is not a valid write target at ${writeTime} (our valid windows: ${JSON.stringify(this.storeConfig.all().map(x => x.validWindow))}, store ${this.folder}). Re-resolve the currently valid source and retry.`);
1092
825
  }
1093
- if (!pending.length) return;
1094
- let activity: SyncActivity = {
1095
- type: "fullSync",
1096
- sourceDebugName: source.getDebugName(),
1097
- startTime: Date.now(),
1098
- doneFiles: 0,
1099
- totalFiles: pending.length,
1100
- doneBytes: 0,
1101
- totalBytes,
1102
- };
1103
- this.syncActivities.add(activity);
1104
- let progressLogged = false;
1105
- let logProgress = () => {
1106
- progressLogged = true;
1107
- 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)}%)`);
1108
- };
1109
- let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
1110
- (progressTimer as { unref?: () => void }).unref?.();
1111
- let slowErrorTimer = setInterval(() => {
1112
- let elapsed = Date.now() - activity.startTime;
1113
- let doneFiles = activity.doneFiles || 0;
1114
- let doneBytes = activity.doneBytes || 0;
1115
- let bytesPerSecond = doneBytes / (elapsed / 1000);
1116
- let remainingBytes = totalBytes - doneBytes;
1117
- let etaText = "unknown (no bytes transferred yet)";
1118
- if (bytesPerSecond > 0) {
1119
- let remainingMs = remainingBytes / bytesPerSecond * 1000;
1120
- etaText = `${formatTime(remainingMs)} remaining, completing around ${new Date(Date.now() + remainingMs).toISOString()}`;
1121
- }
1122
- console.error(`Full sync from ${source.getDebugName()} (store ${this.folder}) has been running for ${formatTime(elapsed)}: ${doneFiles}/${pending.length} files (${(doneFiles / pending.length * 100).toFixed(1)}%), ${formatNumber(doneBytes)}B/${formatNumber(totalBytes)}B (${(totalBytes && doneBytes / totalBytes * 100 || 100).toFixed(1)}%), ${formatNumber(bytesPerSecond)}B/s. Estimated ${etaText}.`);
1123
- }, FULL_SYNC_SLOW_ERROR_INTERVAL);
1124
- (slowErrorTimer as { unref?: () => void }).unref?.();
1125
- try {
1126
- let nextIndex = 0;
1127
- let failed = false;
1128
- let copyWorker = async () => {
1129
- while (!failed && !this.stopped.stop && !state.stopped.stop) {
1130
- let index = nextIndex++;
1131
- if (index >= pending.length) return;
1132
- let { key, entry } = pending[index];
1133
- let copied = await copyArchiveFile({ from: source, to: this.sources[0].source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
1134
- if (copied) {
1135
- // Only move the entry's source if it wasn't changed while we copied
1136
- if (this.mem.get(key) === entry) {
1137
- this.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
1138
- }
1139
- }
1140
- activity.doneFiles = (activity.doneFiles || 0) + 1;
1141
- activity.doneBytes = (activity.doneBytes || 0) + entry.size;
1142
- }
1143
- };
1144
- let workers: Promise<void>[] = [];
1145
- for (let i = 0; i < Math.min(FULL_SYNC_PARALLEL, pending.length); i++) {
1146
- workers.push(copyWorker().catch((e: Error) => {
1147
- // Stop the other workers pulling new files, then surface the error
1148
- failed = true;
1149
- throw e;
1150
- }));
1151
- }
1152
- await Promise.all(workers);
1153
- } finally {
1154
- clearInterval(progressTimer);
1155
- clearInterval(slowErrorTimer);
1156
- this.syncActivities.delete(activity);
1157
- // A sync slow enough to have logged progress also logs its completion
1158
- if (progressLogged) {
1159
- logProgress();
1160
- }
826
+ if (!timeValid.some(x => routeContains(x.route, route))) {
827
+ logWrongTargetRejection(`Rejecting fresh write of ${JSON.stringify(key)} (store ${this.folder}): route ${route} is outside our routes ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))} at writeTime ${writeTime} (the client's shard config is stale)`);
828
+ throw new Error(`${STORAGE_WRONG_ROUTE} This store does not handle route ${route} (key ${JSON.stringify(key)}, our routes at this time: ${JSON.stringify(timeValid.map(x => x.route || FULL_ROUTE))}, store ${this.folder}). Re-resolve the source for this key and retry.`);
1161
829
  }
1162
830
  }
1163
831
 
1164
- // 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.
1165
- private async waitForRequiredScans(): Promise<void> {
1166
- await this.sourceStates[0].initialScan.promise;
1167
- }
1168
-
1169
- // A requested file isn't in the index: our own base source (implicitly required) is checked directly if its initial scan hasn't finished, and changes-after sources are re-polled (at most every 5 seconds)
1170
- private async checkMissingKey(key: string): Promise<void> {
1171
- for (let i = 0; i < this.sources.length; i++) {
1172
- if (!this.isLive(i)) continue;
1173
- let { source } = this.sources[i];
1174
- let state = this.sourceStates[i];
1175
- if (i === 0 && !state.scanComplete) {
1176
- // includeTombstones: a deletion on disk (an empty file) must be ingested as a tombstone, write time included
1177
- let info = await source.getInfo(key, { includeTombstones: true });
1178
- if (info) {
1179
- this.updateScanIndex(i, { path: key, createTime: info.writeTime, size: info.size });
1180
- }
1181
- continue;
1182
- }
1183
- if (state.supportsChangesAfter && Date.now() - state.lastMissCheck > MISS_CHECK_INTERVAL) {
1184
- state.lastMissCheck = Date.now();
1185
- await this.pollChanges(i);
1186
- }
832
+ private async assertMutable(key: string, writeTime: number): Promise<void> {
833
+ if (!this.storeConfig.all().length) return;
834
+ let self = selectEntryAt(this.storeConfig.all(), writeTime, getRoute(key));
835
+ if (!self?.immutable) return;
836
+ if (await this.getInfo({ path: key })) {
837
+ throw new Error(`This store is immutable (at write time ${writeTime}) and ${JSON.stringify(key)} already exists, so it cannot be written to (store ${this.folder})`);
1187
838
  }
1188
839
  }
1189
840
 
1190
- private async getIndexEntry(key: string): Promise<IndexEntry | undefined> {
1191
- // 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)
1192
- let entry = this.mem.get(key);
1193
- if (entry) return entry;
1194
- await this.checkMissingKey(key);
1195
- return this.mem.get(key);
841
+ // See SetConfig.internal: the stamp must land inside SOME window+route this store is configured for (any window, including past ones - synchronization moves old data), so a confused peer can't stuff data onto a store that was never meant to hold it
842
+ private assertInternalWriteAccepted(key: string, writeTime: number, route: number): void {
843
+ if (!this.storeConfig.all().length) return;
844
+ let covered = this.storeConfig.all().some(x => writeTime >= x.validWindow[0] && writeTime < x.validWindow[1] && routeContains(x.route, route));
845
+ if (!covered) {
846
+ throw new Error(`Internal write of ${JSON.stringify(key)} rejected: writeTime ${writeTime} (${new Date(writeTime).toISOString()}) at route ${route} is outside every window/route this store is configured for: ${JSON.stringify(this.storeConfig.all().map(x => ({ validWindow: x.validWindow, route: x.route || FULL_ROUTE })))} (store ${this.folder})`);
847
+ }
1196
848
  }
1197
849
 
1198
- /** Internal (store-to-store) read: purely the local disk, completely short-circuiting the index and holder resolution - the caller is another store, and chasing OUR remote holders while answering it is how infinite get loops between stores form. No window or route checks: if the bytes are on our disk, the caller may have them. Note fast writes still sitting in the overlay are invisible here; the caller re-finds them after our flush. */
850
+ /** Internal (store-to-store) read: purely the local disk, completely short-circuiting the index and holder resolution - the caller is another store, and chasing OUR remote holders while answering it is how infinite get loops between stores form. No window or route checks: if the bytes are on our disk, the caller may have them. Note this reads the disk past any write delay, so a fast write still buffered in memory is invisible here; the caller re-finds it once it flushes. */
1199
851
  private async getInternal2(config: { path: string; range?: { start: number; end: number }; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
1200
852
  await this.init();
1201
853
  // includeTombstones forwards to the disk: a flag-caller (a peer store's synchronization) needs to see our deletions, not just our content. A tombstone deleted from disk entirely only lives in our index, so fall back to that.
1202
854
  let result = await this.getDiskSource().disk.get2(config.path, { range: config.range, includeTombstones: config.includeTombstones });
1203
855
  if (!result || !result.data) {
1204
856
  if (config.includeTombstones) {
1205
- let entry = this.mem.get(config.path);
1206
- if (entry && !entry.size) return { data: Buffer.alloc(0), writeTime: entry.writeTime, size: 0 };
857
+ let deleted = this.getDeletedEntry(config.path);
858
+ if (deleted) return { data: Buffer.alloc(0), writeTime: deleted.writeTime, size: 0 };
1207
859
  }
1208
860
  return undefined;
1209
861
  }
@@ -1214,16 +866,14 @@ export class BlobStore implements IBucketStore {
1214
866
  private async setInternal(key: string, data: Buffer, config: { lastModified: number }): Promise<void> {
1215
867
  await this.init();
1216
868
  assertValidLastModified(config.lastModified);
1217
- let overlayEntry = this.overlay.get(key);
1218
- let entry = this.mem.get(key);
1219
- let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
1220
- if (config.lastModified < currentTime) return;
869
+ if (config.lastModified < await this.currentWriteTime(key)) return;
1221
870
  if (data.length === 0) {
1222
- // A tombstone stores nothing on our own source - the index entry alone records it
871
+ // A deletion stores nothing on our own source - the tombstone is the whole of it
1223
872
  await this.sources[0].source.del(key);
1224
- } else {
1225
- await this.sources[0].source.set(key, data, { lastModified: config.lastModified, forceSetImmutable: true, noChecks: true });
873
+ this.setIndexDeleted(key, config.lastModified);
874
+ return;
1226
875
  }
876
+ await this.sources[0].source.set(key, data, { lastModified: config.lastModified, forceSetImmutable: true, noChecks: true });
1227
877
  this.setIndexEntry(key, { writeTime: config.lastModified, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(0) });
1228
878
  }
1229
879
 
@@ -1234,46 +884,29 @@ export class BlobStore implements IBucketStore {
1234
884
  }
1235
885
 
1236
886
  // The shared engine of set and del: an empty buffer is exactly a deletion here, which is why the empty-buffer rejection lives in set (the public API), not in this machinery
1237
- private async setOrDelete(key: string, data: Buffer, config: { fast?: boolean; writeDelay?: number; lastModified?: number }): Promise<void> {
887
+ private async setOrDelete(key: string, data: Buffer, config: { lastModified?: number }): Promise<void> {
1238
888
  this.config?.onWriteCounted?.("original", data.length);
1239
889
  let lastModified = config.lastModified;
1240
890
  if (lastModified) {
1241
891
  assertValidLastModified(lastModified);
1242
- let overlayEntry = this.overlay.get(key);
1243
- let entry = this.mem.get(key);
1244
- let currentTime = overlayEntry && overlayEntry.t || entry && entry.writeTime || 0;
1245
892
  // An older write never overwrites a newer one (see IArchives.set)
1246
- if (lastModified < currentTime) return;
1247
- }
1248
- let writeTime = lastModified || Date.now();
1249
- if (config.fast) {
1250
- // A writeDelay of zero is a real choice (no delay at all), so only an omitted delay gets the default
1251
- let writeDelay = config.writeDelay;
1252
- if (writeDelay === undefined) {
1253
- writeDelay = DEFAULT_FAST_WRITE_DELAY;
1254
- }
1255
- // The delay never extends past our own valid window's end (minus the margin, so the writes are on disk before the next window's source takes over - a deploy switchover is just this too, since its remap ends our window). Past that point fast writes write through immediately.
1256
- let deadline = this.baseWriteWindowEnd() - WINDOW_END_FLUSH_MARGIN;
1257
- if (writeDelay > 0 && Date.now() < deadline) {
1258
- // Own disk + our own storage servers (type-"remote") flush within MAX_REMOTE_FAST_BUFFER; the non-"remote" sources (backblaze) wait the full writeDelay
1259
- let fastFlushAt = Math.min(Date.now() + Math.min(writeDelay, MAX_REMOTE_FAST_BUFFER), deadline);
1260
- let slowFlushAt = Math.min(Date.now() + writeDelay, deadline);
1261
- this.overlay.set(key, { data, t: writeTime, fastFlushAt, slowFlushAt });
1262
- return;
1263
- }
893
+ if (lastModified < await this.currentWriteTime(key)) return;
1264
894
  }
1265
- this.overlay.delete(key);
1266
- await this.writeToSources(key, data, writeTime);
895
+ // Fast writes are not handled here: a source that was configured with a write delay buffers them itself (see ArchivesDelayed), so this write lands in memory and returns, exactly as it would have. The index is updated either way, so the write is immediately visible to readers of this store.
896
+ await this.writeToSources(key, data, lastModified || Date.now());
1267
897
  }
1268
898
 
1269
- // The end of our own (base disk) write window that CONTAINS now - the deadline fast writes must flush before, so the next window's source has the data on handoff. The LATEST end among covering windows (overlapping windows hand off at the last one). No window contains now (an inert store, or a moment between our windows) -> 0, i.e. flush through immediately.
1270
- private baseWriteWindowEnd(): number {
899
+ /** The instant every delayed write must be on its source: the end of our own write window that contains now, minus the flush margin (so the next window's source finds the data on handoff). The LATEST end among covering windows - overlapping windows hand off at the last one. No window contains now (an inert store, or a moment between our windows) -> 0, i.e. nothing may be delayed at all. */
900
+ public writeFlushDeadline(): number {
901
+ // Before updateSources has run there is nothing to hold a write anyway, and nothing may be delayed
902
+ if (!this.sources.length) return 0;
1271
903
  let now = Date.now();
1272
904
  let end = 0;
1273
905
  for (let w of this.sources[0].validWindows) {
1274
906
  if (w[0] <= now && now < w[1]) end = Math.max(end, w[1]);
1275
907
  }
1276
- return end;
908
+ if (!end) return 0;
909
+ return end - WINDOW_END_FLUSH_MARGIN;
1277
910
  }
1278
911
 
1279
912
  private getWritableSources(config?: { ignoreWindow?: boolean }): number[] {
@@ -1286,8 +919,8 @@ export class BlobStore implements IBucketStore {
1286
919
  return writable;
1287
920
  }
1288
921
 
1289
- // config lets a staged fast flush write only PART of the sources: writeBase (our own disk + index) defaults on, and downstream filters which non-base peers get the fan-out (the fast flush writes only type-"remote" peers, the slow flush only the rest). A full write (no config) does base + every route-matching peer, as before.
1290
- private async writeToSources(key: string, data: Buffer, writeTime: number, config?: { writeBase?: boolean; downstream?: (source: ArchivesSource) => boolean }): Promise<void> {
922
+ // Our own source (the first writable one) plus every route-matching peer. Each source decides for itself how long it holds the write before it hits storage (see ArchivesDelayed) - here they all just get written.
923
+ private async writeToSources(key: string, data: Buffer, writeTime: number): Promise<void> {
1291
924
  // The routing file is NEVER synchronized between storage nodes: the writer writes it directly to each node, so we store it on our own disk only (no valid-window filter - routing/valid windows can't possibly apply to the file defining them) and never forward it to other sources.
1292
925
  this.config?.onWriteCounted?.("flushed", data.length);
1293
926
  let isRouting = key === ROUTING_FILE;
@@ -1296,21 +929,19 @@ export class BlobStore implements IBucketStore {
1296
929
  if (first === undefined) {
1297
930
  throw new Error(`No source accepts writes (every source's valid window is in the past), so writes cannot be stored (store ${this.folder})`);
1298
931
  }
1299
- if (config?.writeBase ?? true) {
1300
- // Only our own (first) source blocks the write. Downstream sources are written in the background: a down downstream source must not fail or stall writes, and reconcileSource re-sends anything they missed once they come back.
1301
- if (data.length === 0) {
1302
- // A tombstone stores nothing on our own source - the index entry alone records it
1303
- await this.sources[first].source.del(key);
1304
- } else {
1305
- await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
1306
- }
932
+ // Only our own (first) source blocks the write. Downstream sources are written in the background: a down downstream source must not fail or stall writes, and reconciliation re-sends anything they missed once they come back.
933
+ if (data.length === 0) {
934
+ // A deletion stores nothing on our own source - the tombstone is the whole of it
935
+ await this.sources[first].source.del(key);
936
+ this.setIndexDeleted(key, writeTime);
937
+ } else {
938
+ await this.sources[first].source.set(key, data, { lastModified: writeTime, noChecks: true });
1307
939
  this.setIndexEntry(key, { writeTime, size: data.length, sourcesListIndex: this.sourcesListIndexOfSlot(first) });
1308
940
  }
1309
941
  if (isRouting) return;
1310
942
  let route = getRoute(key);
1311
943
  for (let i of writable) {
1312
944
  if (!routeContains(this.sources[i].route, route)) continue;
1313
- if (config?.downstream && !config.downstream(this.sources[i])) continue;
1314
945
  // Deletions travel as del carrying the original write time (never as empty sets - set rejects empty buffers). Backblaze materializes such dels as real empty files, so its listings still show the deletion for other stores to scan in as a tombstone.
1315
946
  let push: Promise<unknown>;
1316
947
  if (data.length === 0) {
@@ -1324,105 +955,17 @@ export class BlobStore implements IBucketStore {
1324
955
  }
1325
956
  }
1326
957
 
958
+ // Slot 0 is always this store's own folder (see planSources), so the disk is known without searching for it - and it is the unwrapped one, past any write delay
1327
959
  private getDiskSource(): { disk: ArchivesDisk; sourceIndex: number } {
1328
- for (let i = 0; i < this.sources.length; i++) {
1329
- let source = this.sources[i].source;
1330
- if (source instanceof ArchivesDisk) return { disk: source, sourceIndex: i };
1331
- }
1332
- throw new Error(`Large uploads require an ArchivesDisk source, and this store has none (store ${this.folder})`);
1333
- }
1334
-
1335
- private isRemoteSource(source: ArchivesSource): boolean {
1336
- return source.sourceConfig?.type === "remote";
1337
- }
1338
-
1339
- private async flushOverlay(force?: boolean): Promise<void> {
1340
- let now = Date.now();
1341
- for (let [key, entry] of this.overlay) {
1342
- let doFast = !entry.fastFlushed && (force || entry.fastFlushAt <= now);
1343
- let doSlow = force || entry.slowFlushAt <= now;
1344
- if (!doFast && !doSlow) continue;
1345
- if (doSlow) {
1346
- // The remaining sources: if the fast flush already ran, only the non-"remote" ones (own disk + remote peers are done); otherwise everything at once (writeDelay was <= MAX_REMOTE_FAST_BUFFER, or a forced/window-boundary flush)
1347
- await this.writeToSources(key, entry.data, entry.t, entry.fastFlushed ? { writeBase: false, downstream: s => !this.isRemoteSource(s) } : undefined);
1348
- // Only remove if it wasn't overwritten while we were flushing
1349
- if (this.overlay.get(key) === entry) {
1350
- this.overlay.delete(key);
1351
- }
1352
- continue;
1353
- }
1354
- // Fast stage: own disk + our own storage servers only; the entry stays in the overlay for its slow flush
1355
- await this.writeToSources(key, entry.data, entry.t, { downstream: s => this.isRemoteSource(s) });
1356
- entry.fastFlushed = true;
1357
- }
1358
- }
1359
-
1360
- // readerDiskLimit: the disk is only a bounded read cache, so once it exceeds the limit, the least recently used files are deleted from it - but ONLY when another source verifiably holds a same-or-newer copy (the only copy of a file is never deleted), and the index entry repoints to that source so reads keep working (re-caching on the next read).
1361
- private evicting = false;
1362
- private async enforceDiskLimit(): Promise<void> {
1363
- let limit = this.config?.readerDiskLimit;
1364
- if (!limit || this.evicting) return;
1365
- if (this.sourceByteCounts[0] <= limit) return;
1366
- this.evicting = true;
1367
- let evictedFiles = 0;
1368
- let evictedBytes = 0;
1369
- try {
1370
- let baseSourcesListIndex = this.sourcesListIndexOfSlot(0);
1371
- let candidates: { key: string; entry: IndexEntry }[] = [];
1372
- for (let [key, entry] of this.mem) {
1373
- if (entry.sourcesListIndex !== baseSourcesListIndex || entry.size === 0 || key === ROUTING_FILE) continue;
1374
- candidates.push({ key, entry });
1375
- }
1376
- sort(candidates, x => x.entry.lastAccess);
1377
- for (let { key, entry } of candidates) {
1378
- if (this.stopped.stop) return;
1379
- if (this.sourceByteCounts[0] <= limit) break;
1380
- if (this.mem.get(key) !== entry) continue;
1381
- let holder: number | undefined;
1382
- for (let i = 1; i < this.sources.length; i++) {
1383
- if (!this.isLive(i)) continue;
1384
- try {
1385
- let info = await this.sources[i].source.getInfo(key);
1386
- if (info && info.writeTime >= entry.writeTime) {
1387
- holder = i;
1388
- break;
1389
- }
1390
- } catch {
1391
- // A down source just can't vouch for this file right now
1392
- }
1393
- }
1394
- if (holder === undefined) continue;
1395
- await this.sources[0].source.del(key);
1396
- this.setIndexEntry(key, { writeTime: entry.writeTime, size: entry.size, sourcesListIndex: this.sourcesListIndexOfSlot(holder) });
1397
- evictedFiles++;
1398
- evictedBytes += entry.size;
1399
- }
1400
- } finally {
1401
- this.evicting = false;
1402
- if (evictedFiles) {
1403
- console.log(`Disk cache over readerDiskLimit (store ${this.folder}): evicted ${evictedFiles} least-recently-used files (${formatNumber(evictedBytes)}B), now at ${formatNumber(this.sourceByteCounts[0])}B/${formatNumber(this.config?.readerDiskLimit || 0)}B`);
1404
- }
1405
- }
960
+ return { disk: this.ownDisk, sourceIndex: 0 };
1406
961
  }
1407
962
 
1408
- // Tombstones only need to exist long enough for every store to learn of the deletion; expired ones come out of the index. The physical empty file is removed only on backblaze sources: remote stores expire their own tombstones (a del there would just mint a fresh one), and our own disk never stored anything for it.
1409
- private async cleanupTombstones(): Promise<void> {
1410
- let cutoff = Date.now() - TOMBSTONE_EXPIRY;
1411
- for (let [key, entry] of this.mem) {
1412
- if (this.stopped.stop) return;
1413
- if (entry.size !== 0) continue;
1414
- if (entry.writeTime > cutoff) continue;
1415
- this.deleteIndexEntry(key);
1416
- for (let i = 0; i < this.sources.length; i++) {
1417
- if (!this.isLive(i)) continue;
1418
- let sourceEntry = this.sources[i];
1419
- if (!windowsAcceptWrites(sourceEntry.validWindows)) continue;
1420
- let source = sourceEntry.source;
1421
- if (!(source instanceof ArchivesBackblaze)) continue;
1422
- void source.del(key).catch((e: Error) => {
1423
- console.error(`Removing expired tombstone ${key} from ${source.getDebugName()} failed: ${e.stack ?? e}`);
1424
- });
1425
- }
963
+ /** Writes everything still held by a delayed source (see ArchivesDelayed). force also writes what isn't due yet - shutdown cannot leave writes in memory. */
964
+ private async flushDelayedWrites(force?: boolean): Promise<void> {
965
+ for (let { source } of this.sources) {
966
+ let delayed = asDelayed(source);
967
+ if (!delayed) continue;
968
+ await delayed.flush(force);
1426
969
  }
1427
970
  }
1428
971