sliftutils 1.7.101 → 1.7.103

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 (29) hide show
  1. package/index.d.ts +227 -7
  2. package/package.json +1 -1
  3. package/storage/IArchives.d.ts +18 -0
  4. package/storage/IArchives.ts +14 -0
  5. package/storage/StreamingLogs.d.ts +77 -0
  6. package/storage/StreamingLogs.ts +412 -0
  7. package/storage/TransactionFile.d.ts +12 -6
  8. package/storage/TransactionFile.ts +44 -12
  9. package/storage/dist/IArchives.ts.cache +2 -2
  10. package/storage/dist/TransactionFile.ts.cache +41 -9
  11. package/storage/remoteStorage/ArchivesRemote.ts +3 -3
  12. package/storage/remoteStorage/blobStore.d.ts +32 -0
  13. package/storage/remoteStorage/blobStore.ts +124 -11
  14. package/storage/remoteStorage/createArchives.d.ts +40 -1
  15. package/storage/remoteStorage/createArchives.ts +84 -0
  16. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
  17. package/storage/remoteStorage/dist/blobStore.ts.cache +127 -12
  18. package/storage/remoteStorage/dist/createArchives.ts.cache +52 -3
  19. package/storage/remoteStorage/dist/storageController.ts.cache +40 -3
  20. package/storage/remoteStorage/dist/storageServerState.ts.cache +18 -3
  21. package/storage/remoteStorage/dist/storeSync.ts.cache +47 -2
  22. package/storage/remoteStorage/spec.md +4 -0
  23. package/storage/remoteStorage/storageController.d.ts +10 -0
  24. package/storage/remoteStorage/storageController.ts +37 -3
  25. package/storage/remoteStorage/storageLogs.d.ts +29 -0
  26. package/storage/remoteStorage/storageLogs.ts +96 -0
  27. package/storage/remoteStorage/storageServerState.ts +17 -2
  28. package/storage/remoteStorage/storeSync.d.ts +1 -0
  29. package/storage/remoteStorage/storeSync.ts +42 -0
@@ -6,6 +6,10 @@ This is the reasoning behind the remote storage system (createArchives / storage
6
6
 
7
7
  Every store fully rescans its sources' metadata (on startup and periodically), and every write — including deletions — is ordered by last-write time. Deletions are tombstones: an empty file IS a missing file, kept as a size-0 index entry (for a week) so the deletion itself propagates and reconciles like any other write. Because everything is a timestamped write and scans are bidirectional (pull what they have, push what they're missing), any two sources can be merged in any order and converge to the same state: newest write time wins, per file. There is no operation whose loss corrupts the system — a failed background write, a missed delete, a source that was down for a day — the next scan reconciles it.
8
8
 
9
+ ## Deletions are marked, not performed — the bytes become history
10
+
11
+ A deletion never removes bytes from a server's disk: the index MARKS the key deleted (keeping its entry - size, holder, original write time), reads and listings stop finding it, and the deletion propagates as before. The bytes sit on disk as deletion history, per node, until the history outgrows max(10GB, live bytes × history factor — see getHistoryFactor) — then the OLDEST deletions lose their bytes and demote to plain tombstones, which age out on the week timer as before (a marked deletion never expires by time, only by size: purging its tombstone while its bytes remain would let the next disk scan resurrect them). While the history holds a file, includeMarked reads and listings can still see it, and undelete (a set carrying SetConfig.undelete) flips the index entry back to live with a fresh write time — the bytes never moved, so reads just work again, and the restore propagates to peers the way the deletion did. Raw sources with no index (backblaze) keep the old behavior: deletions materialize as empty files there, so history lives on server disks only.
12
+
9
13
  ## storage/storagerouting.json Has the routing config and is duplicated on every node.
10
14
 
11
15
  Each bucket stores its complete routing config (the full redundancy list) inside itself, at storage/storagerouting.json, on every source. Discovery is therefore trivial: as long as ONE node is up, a client reading it gets the full overview of the intended sources. And because clients re-discover on every startup (and re-read every 5 minutes), a developer can change the configuration in one place and clients rapidly accept it — no redeploy, no coordinated restart of the fleet.
@@ -3,6 +3,7 @@
3
3
  import { ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, FindConfig, SourceConfig } from "../IArchives";
4
4
  import { ActiveBucketInfo, ServerBucketInfo } from "./storageServerState";
5
5
  import { AccessTotals, AccessSummaryState } from "./accessStats";
6
+ import { LogFileInfo } from "../StreamingLogs";
6
7
  import type { SummaryEntry } from "../../treeSummary";
7
8
  export declare const REMOTE_STORAGE_CLASS_GUID = "RemoteStorageController-b7e42a91";
8
9
  export declare const STORAGE_AUTH_PURPOSE = "remoteStorage-auth-1";
@@ -84,6 +85,7 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
84
85
  };
85
86
  internal?: boolean;
86
87
  includeTombstones?: boolean;
88
+ includeMarked?: boolean;
87
89
  }) => Promise<{
88
90
  data: Buffer;
89
91
  writeTime: number;
@@ -98,6 +100,7 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
98
100
  lastModified?: number;
99
101
  forceSetImmutable?: boolean;
100
102
  internal?: boolean;
103
+ undelete?: boolean;
101
104
  }) => Promise<void>;
102
105
  del: (config: {
103
106
  account: string;
@@ -182,6 +185,13 @@ export declare const RemoteStorageController: import("socket-function/SocketFunc
182
185
  account: string;
183
186
  bucketName: string;
184
187
  }) => Promise<ArchivesSyncStatus>;
188
+ listLogFiles: (config: {
189
+ account: string;
190
+ }) => Promise<LogFileInfo[]>;
191
+ getLogFile: (config: {
192
+ account: string;
193
+ name: string;
194
+ }) => Promise<Buffer>;
185
195
  startLargeFile: (config: {
186
196
  account: string;
187
197
  bucketName: string;
@@ -20,6 +20,8 @@ import { BlobStore } from "./blobStore";
20
20
  import { getRoutingFileResult } from "./bucketDisk";
21
21
  import { StorageClientController } from "./storageClientController";
22
22
  import { trackAccess, trackAccessCall, getAccessTotals, readAccessSummaries, clearAccountAccessStats, AccessTotals, AccessSummaryState } from "./accessStats";
23
+ import { logMutation, listStorageLogFiles, readStorageLogFile } from "./storageLogs";
24
+ import { LogFileInfo } from "../StreamingLogs";
23
25
  import { assertValidName, assertValidPath, assertValidArgs } from "./validation";
24
26
  import type { SummaryEntry } from "../../treeSummary";
25
27
 
@@ -275,7 +277,7 @@ class RemoteStorageControllerBase {
275
277
 
276
278
  @assertValidArgs
277
279
  @trackAccessCall("get")
278
- async get2(config: { account: string; bucketName: string; path: string; sourceConfig: SourceConfig; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
280
+ async get2(config: { account: string; bucketName: string; path: string; sourceConfig: SourceConfig; range?: { start: number; end: number }; internal?: boolean; includeTombstones?: boolean; includeMarked?: boolean }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
279
281
  if (config.path === ROUTING_FILE) {
280
282
  // The routing file lives outside every store and even outside the bucket (it is what CREATES it), so it is read straight off the disk - absent means undefined, exactly like any file read
281
283
  return await getRoutingFileResult(config.account, config.bucketName);
@@ -287,8 +289,9 @@ class RemoteStorageControllerBase {
287
289
  }
288
290
  @assertValidArgs
289
291
  @trackAccessCall("set")
290
- async set(config: { account: string; bucketName: string; path: string; data: Buffer; sourceConfig: SourceConfig; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean }): Promise<void> {
292
+ async set(config: { account: string; bucketName: string; path: string; data: Buffer; sourceConfig: SourceConfig; lastModified?: number; forceSetImmutable?: boolean; internal?: boolean; undelete?: boolean }): Promise<void> {
291
293
  assertWritesAllowed();
294
+ let caller = callerId();
292
295
  // Copied because the wire hands us a plain Uint8Array view, not a real Buffer
293
296
  let data = Buffer.from(config.data);
294
297
  if (config.path === ROUTING_FILE) {
@@ -296,21 +299,26 @@ class RemoteStorageControllerBase {
296
299
  return await writeRoutingConfig(config.account, config.bucketName, config.sourceConfig.name, data, config);
297
300
  }
298
301
  await withStore(config, store => store.set({ ...config, data }));
302
+ logMutation({ op: config.undelete && "undelete" || "set", account: config.account, bucketName: config.bucketName, store: config.sourceConfig.name, path: config.path, size: data.length, writeTime: config.lastModified, callerId: caller, internal: config.internal });
299
303
  }
300
304
  @assertValidArgs
301
305
  @trackAccessCall("del")
302
306
  async del(config: { account: string; bucketName: string; path: string; sourceConfig: SourceConfig; lastModified?: number; internal?: boolean }): Promise<void> {
303
307
  assertWritesAllowed();
308
+ let caller = callerId();
304
309
  await withStore(config, store => store.del(config));
310
+ logMutation({ op: "del", account: config.account, bucketName: config.bucketName, store: config.sourceConfig.name, path: config.path, writeTime: config.lastModified, callerId: caller, internal: config.internal });
305
311
  }
306
312
  @assertValidArgs
307
313
  @trackAccessCall("move")
308
314
  async move(config: { account: string; bucketName: string; fromPath: string; toPath: string; sourceConfig: SourceConfig }): Promise<void> {
309
315
  assertWritesAllowed();
316
+ let caller = callerId();
310
317
  // assertValidArgs only knows the well-known `path` field, so the two paths of a move are validated here
311
318
  assertValidPath(config.fromPath);
312
319
  assertValidPath(config.toPath);
313
320
  await withStore(config, store => store.move(config));
321
+ logMutation({ op: "move", account: config.account, bucketName: config.bucketName, store: config.sourceConfig.name, path: config.fromPath, toPath: config.toPath, callerId: caller });
314
322
  }
315
323
  @assertValidArgs
316
324
  @trackAccessCall("getInfo")
@@ -380,6 +388,17 @@ class RemoteStorageControllerBase {
380
388
  return await bucketSyncStatus(config.account, config.bucketName);
381
389
  }
382
390
 
391
+ /** The operation-log files THIS server holds (every server logs its own operations - ask each one; see listAllServerLogFiles in createArchives). */
392
+ @assertValidArgs
393
+ async listLogFiles(config: { account: string }): Promise<LogFileInfo[]> {
394
+ return await listStorageLogFiles();
395
+ }
396
+ /** One log file's bytes, always LZ4-compressed (a live file is flushed and compressed in memory before sending). Decode with decodeLogFile. */
397
+ @assertValidArgs
398
+ async getLogFile(config: { account: string; name: string }): Promise<Buffer> {
399
+ return await readStorageLogFile(config.name);
400
+ }
401
+
383
402
  @assertValidArgs
384
403
  async startLargeFile(config: { account: string; bucketName: string; path: string; sourceConfig: SourceConfig; lastModified?: number; forceSetImmutable?: boolean; noChecks?: boolean; internal?: boolean }): Promise<string> {
385
404
  assertWritesAllowed();
@@ -404,7 +423,11 @@ class RemoteStorageControllerBase {
404
423
  let info = largeUploadInfo.get(config.uploadId);
405
424
  if (!info) throw new Error(`Unknown large upload ${config.uploadId}`);
406
425
  largeUploadInfo.delete(config.uploadId);
407
- await getStore(info.account, info.bucketName, info.storeName).finishLargeUpload({ id: config.uploadId, path: info.path, lastModified: info.lastModified, forceSetImmutable: info.forceSetImmutable, noChecks: info.noChecks, internal: info.internal });
426
+ let caller = callerId();
427
+ let store = getStore(info.account, info.bucketName, info.storeName);
428
+ await store.finishLargeUpload({ id: config.uploadId, path: info.path, lastModified: info.lastModified, forceSetImmutable: info.forceSetImmutable, noChecks: info.noChecks, internal: info.internal });
429
+ let written = await store.getInfo({ path: info.path });
430
+ logMutation({ op: "setLarge", account: info.account, bucketName: info.bucketName, store: info.storeName, path: info.path, size: written?.size, writeTime: written?.writeTime, callerId: caller });
408
431
  }
409
432
  /** Best-effort cleanup: an unknown upload has nothing left to cancel. */
410
433
  async cancelLargeFile(config: { uploadId: string }): Promise<void> {
@@ -527,6 +550,15 @@ async function withStore<T>(config: { account: string; bucketName: string; sourc
527
550
  return await fn(findBucketStore(config.account, config.bucketName, config.sourceConfig));
528
551
  }
529
552
 
553
+ // The caller's node id for the mutation log - read synchronously at method entry (the call context does not survive awaits), and tolerant of there being none (local calls)
554
+ function callerId(): string | undefined {
555
+ try {
556
+ return SocketFunction.getCaller()?.nodeId;
557
+ } catch {
558
+ return undefined;
559
+ }
560
+ }
561
+
530
562
  const largeUploadInfo = new Map<string, { account: string; bucketName: string; path: string; lastModified?: number; forceSetImmutable?: boolean; noChecks?: boolean; internal?: boolean; storeName: string }>();
531
563
 
532
564
  const accountAccess: SocketFunctionHook = async (context) => {
@@ -577,6 +609,8 @@ export const RemoteStorageController = SocketFunction.register(
577
609
  getAccessStats: { hooks: [accountAccess] },
578
610
  getAccessSummaries: { hooks: [accountAccess] },
579
611
  getSyncStatus: { hooks: [accountAccess] },
612
+ listLogFiles: { hooks: [accountAccess] },
613
+ getLogFile: { hooks: [accountAccess] },
580
614
  startLargeFile: { hooks: [accountAccess] },
581
615
  uploadPart: { hooks: [uploadAccess] },
582
616
  finishLargeFile: { hooks: [uploadAccess] },
@@ -0,0 +1,29 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ import { LogFileInfo } from "../StreamingLogs";
4
+ export declare const LOGS_FOLDER_NAME = "logs";
5
+ /** One mutation the server performed: set/del/move/undelete/setLarge/routingConfig, plus the per-file synchronization writes ("sync get"/"sync set"). Sizes and times, never the data. internal marks writes pushed by a peer's synchronization rather than a client. */
6
+ export declare function logMutation(entry: {
7
+ op: string;
8
+ account: string;
9
+ bucketName: string;
10
+ store?: string;
11
+ path: string;
12
+ toPath?: string;
13
+ size?: number;
14
+ writeTime?: number;
15
+ callerId?: string;
16
+ internal?: boolean;
17
+ }): void;
18
+ /** A synchronization key point: scans and full syncs starting/finishing, reconciles, boundary scans - what an operator greps for to see whether the fleet is converging. */
19
+ export declare function logSyncEvent(entry: {
20
+ event: string;
21
+ store: string;
22
+ source?: string;
23
+ [key: string]: unknown;
24
+ }): void;
25
+ export declare function logStorageError(message: string): void;
26
+ /** The log files this server holds - see StreamingLogs.listFiles. Empty on processes with no storage folder. */
27
+ export declare function listStorageLogFiles(): Promise<LogFileInfo[]>;
28
+ /** One log file's bytes, always LZ4-compressed - see StreamingLogs.readFileCompressed (decode with decodeLogFile). */
29
+ export declare function readStorageLogFile(name: string): Promise<Buffer>;
@@ -0,0 +1,96 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ import { lazy } from "socket-function/src/caching";
4
+ import { StreamingLogs, LogFileInfo } from "../StreamingLogs";
5
+ import { getStorageFolder, getStorageServerConfigOptional } from "./serverConfig";
6
+ import { getOwnThreadId } from "../../misc/https/certs";
7
+
8
+ // The storage server's operation log: every mutation (never the data itself - just what happened, to what, when, how big, and who asked), every error, and the synchronization key points, streamed as JSON lines into <storage folder>/logs (see StreamingLogs for the file lifecycle). Everything here is a safe no-op on processes that have no storage folder - logging must never be the thing that breaks a request.
9
+
10
+ export const LOGS_FOLDER_NAME = "logs";
11
+
12
+ const getLogs = lazy((): StreamingLogs | undefined => {
13
+ let folder: string;
14
+ try {
15
+ folder = path.join(getStorageFolder(), LOGS_FOLDER_NAME);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ let threadId: string | undefined;
20
+ try {
21
+ threadId = getOwnThreadId(getStorageServerConfigOptional()?.rootDomain || "");
22
+ } catch {
23
+ threadId = undefined;
24
+ }
25
+ hookErrorLogging();
26
+ return new StreamingLogs({ folder, threadId });
27
+ });
28
+
29
+ function firstExternalIPv4(): string | undefined {
30
+ for (let addresses of Object.values(os.networkInterfaces())) {
31
+ for (let address of addresses || []) {
32
+ if (address.family === "IPv4" && !address.internal) return address.address;
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ // Computed once: none of it changes over a process's lifetime
39
+ const baseFields = lazy(() => ({
40
+ pid: process.pid,
41
+ entryPoint: process.argv[1],
42
+ ip: firstExternalIPv4(),
43
+ domain: getStorageServerConfigOptional()?.domain,
44
+ }));
45
+
46
+ function write(kind: string, entry: { [key: string]: unknown }): void {
47
+ let logs = getLogs();
48
+ if (!logs) return;
49
+ logs.log({ kind, time: Date.now(), ...baseFields(), ...entry });
50
+ }
51
+
52
+ /** One mutation the server performed: set/del/move/undelete/setLarge/routingConfig, plus the per-file synchronization writes ("sync get"/"sync set"). Sizes and times, never the data. internal marks writes pushed by a peer's synchronization rather than a client. */
53
+ export function logMutation(entry: { op: string; account: string; bucketName: string; store?: string; path: string; toPath?: string; size?: number; writeTime?: number; callerId?: string; internal?: boolean }): void {
54
+ write("mutation", entry);
55
+ }
56
+
57
+ /** A synchronization key point: scans and full syncs starting/finishing, reconciles, boundary scans - what an operator greps for to see whether the fleet is converging. */
58
+ export function logSyncEvent(entry: { event: string; store: string; source?: string; [key: string]: unknown }): void {
59
+ write("sync", entry);
60
+ }
61
+
62
+ export function logStorageError(message: string): void {
63
+ write("error", { message });
64
+ }
65
+
66
+ // Everything console.errored also lands in the log stream (with a reentrancy guard: a failure INSIDE logging must not log itself forever)
67
+ let writingError = false;
68
+ const hookErrorLogging = lazy(() => {
69
+ let original = console.error.bind(console);
70
+ console.error = (...args: unknown[]) => {
71
+ original(...args);
72
+ if (writingError) return;
73
+ writingError = true;
74
+ try {
75
+ logStorageError(args.map(x => typeof x === "string" && x || ((x as Error)?.stack ?? JSON.stringify(x))).join(" ").slice(0, 5000));
76
+ } catch { } finally {
77
+ writingError = false;
78
+ }
79
+ };
80
+ });
81
+
82
+ /** The log files this server holds - see StreamingLogs.listFiles. Empty on processes with no storage folder. */
83
+ export async function listStorageLogFiles(): Promise<LogFileInfo[]> {
84
+ let logs = getLogs();
85
+ if (!logs) return [];
86
+ return await logs.listFiles();
87
+ }
88
+
89
+ /** One log file's bytes, always LZ4-compressed - see StreamingLogs.readFileCompressed (decode with decodeLogFile). */
90
+ export async function readStorageLogFile(name: string): Promise<Buffer> {
91
+ let logs = getLogs();
92
+ if (!logs) {
93
+ throw new Error(`This process has no storage folder, so it has no logs to read`);
94
+ }
95
+ return await logs.readFileCompressed(name);
96
+ }
@@ -12,7 +12,8 @@ import { scheduleBoundaryWork, reinjectIntermediates } from "./intermediateManag
12
12
  import { SocketFunction } from "socket-function/SocketFunction";
13
13
  import { StorageClientController } from "./storageClientController";
14
14
  import { isSelfSource } from "./storePlan";
15
- import { countBucketWrite, getBucketWriteStats, BucketWriteStats } from "./accessStats";
15
+ import { countBucketWrite, getBucketWriteStats, trackAccess, BucketWriteStats } from "./accessStats";
16
+ import { logMutation } from "./storageLogs";
16
17
 
17
18
  /**
18
19
  * What this server HAS: the stores, by name (getStore) - made once, self-configuring from there -
@@ -43,6 +44,11 @@ export function getStore(account: string, bucketName: string, name: string, call
43
44
  createSource: config => createStoreSource({ sourceConfig: config.sourceConfig, folder, writeDelay: config.writeDelay }),
44
45
  applySource: (source, sourceConfig, writeDelay) => applySourceConfig(source, sourceConfig, writeDelay),
45
46
  onWriteCounted: (kind, bytes) => countBucketWrite(`${account}/${bucketName}`, kind, bytes),
47
+ onSyncTransfer: (operation, path, bytes) => {
48
+ trackAccess({ account, operation, path: `${bucketName}/${path}`, size: bytes });
49
+ // Every synchronization write is logged per file, exactly like client mutations - the log is the full account of what moved
50
+ logMutation({ op: operation, account, bucketName, store: name, path, size: bytes });
51
+ },
46
52
  resolveSourceUrl: resolveSourceArchives,
47
53
  // The store is the one that knows when a config landed, and a config with upcoming windows is what boundary scans are armed from
48
54
  onRoutingApplied: routing => scheduleBoundaryWork(account, bucketName, routing),
@@ -104,6 +110,7 @@ export async function readBucketInternal(account: string, bucketName: string, co
104
110
 
105
111
  function aggregateArchivesConfig(bucketStores: BlobStore[], routing: RemoteConfig | undefined): ArchivesConfig {
106
112
  let index = { fileCount: 0, byteCount: 0 };
113
+ let markedIndex: { fileCount: number; byteCount: number; oldestDeleteTime?: number } = { fileCount: 0, byteCount: 0 };
107
114
  let indexSources: { debugName: string; fileCount: number; byteCount: number }[] = [];
108
115
  let syncing: SyncActivity[] = [];
109
116
  let readerDiskLimit: number | undefined;
@@ -111,6 +118,11 @@ function aggregateArchivesConfig(bucketStores: BlobStore[], routing: RemoteConfi
111
118
  let progress = store.getSyncProgress();
112
119
  index.fileCount += progress.index.fileCount;
113
120
  index.byteCount += progress.index.byteCount;
121
+ markedIndex.fileCount += progress.marked.fileCount;
122
+ markedIndex.byteCount += progress.marked.byteCount;
123
+ if (progress.marked.oldestDeleteTime !== undefined && (markedIndex.oldestDeleteTime === undefined || progress.marked.oldestDeleteTime < markedIndex.oldestDeleteTime)) {
124
+ markedIndex.oldestDeleteTime = progress.marked.oldestDeleteTime;
125
+ }
114
126
  indexSources.push(...progress.sources);
115
127
  syncing.push(...progress.syncing);
116
128
  readerDiskLimit = readerDiskLimit || progress.readerDiskLimit;
@@ -120,6 +132,7 @@ function aggregateArchivesConfig(bucketStores: BlobStore[], routing: RemoteConfi
120
132
  supportsChangesAfter: true,
121
133
  remoteConfig: routing,
122
134
  index,
135
+ markedIndex,
123
136
  indexSources,
124
137
  readerDiskLimit,
125
138
  syncing,
@@ -182,7 +195,9 @@ export async function writeRoutingConfig(account: string, bucketName: string, na
182
195
  let incoming = parseRoutingData(data);
183
196
  let current = await readRoutingFromDisk(account, bucketName);
184
197
  let stored = reinjectIntermediates(current, incoming);
185
- await store.set({ path: ROUTING_FILE, data: Buffer.from(serializeRemoteConfig(stored)), lastModified: config?.lastModified });
198
+ let storedData = Buffer.from(serializeRemoteConfig(stored));
199
+ await store.set({ path: ROUTING_FILE, data: storedData, lastModified: config?.lastModified });
200
+ logMutation({ op: "routingConfig", account, bucketName, store: name, path: ROUTING_FILE, size: storedData.length, writeTime: config?.lastModified });
186
201
  broadcastRoutingChanged();
187
202
  }
188
203
 
@@ -50,4 +50,5 @@ export declare class StoreSync {
50
50
  private copySourceFiles;
51
51
  private enforceDiskLimit;
52
52
  private cleanupTombstones;
53
+ private enforceHistoryLimit;
53
54
  }
@@ -9,6 +9,8 @@ import { copyArchiveFile } from "../archiveHelpers";
9
9
  import { ArchivesBackblaze } from "../backblaze";
10
10
  import { ROUTING_FILE, getRoute, routeContains, parseRoutingData, getConfigVersion } from "./remoteConfig";
11
11
  import type { BlobStore, IndexEntry } from "./blobStore";
12
+ import { getHistoryFactor, HISTORY_MIN_BYTES } from "./blobStore";
13
+ import { logSyncEvent } from "./storageLogs";
12
14
  import { magenta } from "socket-function/src/formatting/logColors";
13
15
 
14
16
  // Everything that keeps a store's index in agreement with its sources, plus the two maintenance loops that follow from holding an index (evicting a bounded disk cache, expiring tombstones). The store owns the sources and decides where writes go; this decides what is scanned, pulled, pushed, evicted, and forgotten - so the write path stays readable without the several hundred lines of synchronization machinery interleaved into it.
@@ -100,6 +102,7 @@ export class StoreSync {
100
102
  }
101
103
  runInfinitePoll(CONFIG_POLL_INTERVAL, () => this.pollRoutingConfig(), this.store.stopped);
102
104
  runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.store.stopped);
105
+ runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.enforceHistoryLimit(), this.store.stopped);
103
106
  // Read live: the limit comes from the config, so a store that gains one later starts enforcing it without being restarted
104
107
  runInfinitePoll(DISK_LIMIT_CHECK_INTERVAL, () => this.enforceDiskLimit(), this.store.stopped);
105
108
  }
@@ -244,6 +247,7 @@ export class StoreSync {
244
247
  }
245
248
  let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, size: file.size, writeTime: file.createTime, forceSetImmutable: true, noChecks: true, internal: true });
246
249
  if (!copied) continue;
250
+ this.store.noteSyncTransfer("sync get", file.path, copied.size);
247
251
  if (copied.size === 0) {
248
252
  this.store.setIndexDeleted(file.path, copied.writeTime);
249
253
  tally.tombstone++;
@@ -257,6 +261,7 @@ export class StoreSync {
257
261
  }
258
262
  }
259
263
  console.log(`Boundary scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.store.folder}): ${changes.length} changes: ${formatScanTally(tally, changes.length)}`);
264
+ logSyncEvent({ event: "boundaryScanFinish", store: this.store.folder, source: source.getDebugName(), durationMs: Date.now() - scanStart, since: config.since, changes: changes.length, newPaths: tally.new, updated: tally.updated, tombstones: tally.tombstone });
260
265
  }
261
266
 
262
267
  // ── per-source loops ──
@@ -325,6 +330,7 @@ export class StoreSync {
325
330
  let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
326
331
  this.activities.add(activity);
327
332
  console.log(`Metadata scan of ${source.getDebugName()} starting (store ${this.store.folder})`);
333
+ logSyncEvent({ event: "scanStart", store: this.store.folder, source: source.getDebugName() });
328
334
  let progressTimer = setInterval(() => {
329
335
  console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.store.folder})`);
330
336
  }, SYNC_PROGRESS_LOG_INTERVAL);
@@ -390,6 +396,7 @@ export class StoreSync {
390
396
  let union = indexSizeBefore + newPaths;
391
397
  let pct = (n: number) => `${Math.round(n / Math.max(union, 1) * 1000) / 10}%`;
392
398
  console.log(`Metadata scan of ${source.getDebugName()} finished in ${Math.round((Date.now() - scanStart) / 1000)}s (store ${this.store.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)})`);
399
+ logSyncEvent({ event: "scanFinish", store: this.store.folder, source: source.getDebugName(), durationMs: Date.now() - scanStart, listed: files.length, indexedBefore: indexSizeBefore, newPaths: tally.new, updated: tally.updated, tombstones: tally.tombstone, missingOnSource, removedFromIndex });
393
400
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
394
401
  return seen;
395
402
  }
@@ -427,6 +434,7 @@ export class StoreSync {
427
434
  // 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.
428
435
  if (theirTime === undefined) continue;
429
436
  await source.del(key, { lastModified: writeTime, noChecks: true, internal: true });
437
+ this.store.noteSyncTransfer("sync set", key, 0);
430
438
  pushed++;
431
439
  consecutiveFailures = 0;
432
440
  continue;
@@ -435,6 +443,7 @@ export class StoreSync {
435
443
  if (!holder) continue;
436
444
  let copied = await copyArchiveFile({ from: holder, to: source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
437
445
  if (!copied) continue;
446
+ this.store.noteSyncTransfer("sync set", key, copied.size);
438
447
  pushed++;
439
448
  consecutiveFailures = 0;
440
449
  } catch (e) {
@@ -456,6 +465,9 @@ export class StoreSync {
456
465
  } else if (pushed) {
457
466
  console.log(`Reconciled sync source ${source.getDebugName()} (store ${this.store.folder}): pushed ${pushed} files it was missing or held older copies of`);
458
467
  }
468
+ if (pushed || failed) {
469
+ logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
470
+ }
459
471
  }
460
472
 
461
473
  private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
@@ -523,6 +535,7 @@ export class StoreSync {
523
535
  totalBytes,
524
536
  };
525
537
  this.activities.add(activity);
538
+ logSyncEvent({ event: "fullSyncStart", store: this.store.folder, source: source.getDebugName(), files: pending.length, bytes: totalBytes });
526
539
  let progressLogged = false;
527
540
  let logProgress = () => {
528
541
  progressLogged = true;
@@ -558,6 +571,7 @@ export class StoreSync {
558
571
  try {
559
572
  let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key, size: entry.size, writeTime: entry.writeTime, forceSetImmutable: true, noChecks: true, internal: true });
560
573
  if (copied) {
574
+ this.store.noteSyncTransfer("sync get", key, copied.size);
561
575
  // Only move the entry's source if it wasn't changed while we copied
562
576
  if (this.entryUnchanged(key, entry)) {
563
577
  this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
@@ -598,6 +612,7 @@ export class StoreSync {
598
612
  if (progressLogged) {
599
613
  logProgress();
600
614
  }
615
+ logSyncEvent({ event: "fullSyncFinish", store: this.store.folder, source: source.getDebugName(), durationMs: Date.now() - activity.startTime, copiedFiles: activity.doneFiles, copiedBytes: activity.doneBytes, totalFiles: pending.length, totalBytes });
601
616
  }
602
617
  }
603
618
 
@@ -661,6 +676,8 @@ export class StoreSync {
661
676
  for (let [key, tombstone] of this.store.deletedEntries()) {
662
677
  if (this.store.stopped.stop) return;
663
678
  if (tombstone.writeTime > cutoff) continue;
679
+ // A MARKED deletion never expires by time - its bytes are still on disk, and purging the tombstone would let the next disk scan resurrect them as live. Retention expires it by SIZE (enforceHistoryLimit); only once the bytes are gone does the plain tombstone age out here.
680
+ if (this.store.getMarkedEntry(key)) continue;
664
681
  this.store.purgeIndexEntry(key);
665
682
  for (let i = 0; i < this.store.sources.length; i++) {
666
683
  if (!this.isLive(i)) continue;
@@ -674,4 +691,29 @@ export class StoreSync {
674
691
  }
675
692
  }
676
693
  }
694
+
695
+ // Deletion history retention: marked files keep their bytes on our disk until the history outgrows max(HISTORY_MIN_BYTES, live bytes * history factor); then the OLDEST deletions lose their bytes and become plain tombstones, which age out normally (cleanupTombstones)
696
+ private async enforceHistoryLimit(): Promise<void> {
697
+ let allowed = Math.max(HISTORY_MIN_BYTES, this.store.indexTotals().byteCount * await getHistoryFactor());
698
+ let marked: { key: string; size: number; deleteTime: number }[] = [];
699
+ let totalBytes = 0;
700
+ for (let [key, entry] of this.store.markedEntries()) {
701
+ marked.push({ key, size: entry.size, deleteTime: entry.deleteTime });
702
+ totalBytes += entry.size;
703
+ }
704
+ if (totalBytes <= allowed) return;
705
+ let startedWithBytes = totalBytes;
706
+ sort(marked, x => x.deleteTime);
707
+ let dropped = 0;
708
+ let droppedBytes = 0;
709
+ for (let { key, size } of marked) {
710
+ if (this.store.stopped.stop) return;
711
+ if (totalBytes <= allowed) break;
712
+ await this.store.dropMarkedHistory(key);
713
+ totalBytes -= size;
714
+ dropped++;
715
+ droppedBytes += size;
716
+ }
717
+ console.log(`Deletion history of store ${this.store.folder} outgrew its budget (${formatNumber(startedWithBytes)}B kept vs ${formatNumber(allowed)}B allowed): dropped the ${dropped} oldest marked files (${formatNumber(droppedBytes)}B); their tombstones remain until they age out`);
718
+ }
677
719
  }