sliftutils 1.7.102 → 1.7.104

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 (32) hide show
  1. package/index.d.ts +223 -7
  2. package/package.json +2 -2
  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/StreamingLogs.ts.cache +397 -0
  11. package/storage/dist/TransactionFile.ts.cache +41 -9
  12. package/storage/remoteStorage/ArchivesRemote.ts +3 -3
  13. package/storage/remoteStorage/blobStore.d.ts +28 -0
  14. package/storage/remoteStorage/blobStore.ts +117 -11
  15. package/storage/remoteStorage/createArchives.d.ts +40 -1
  16. package/storage/remoteStorage/createArchives.ts +84 -0
  17. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
  18. package/storage/remoteStorage/dist/blobStore.ts.cache +122 -12
  19. package/storage/remoteStorage/dist/createArchives.ts.cache +87 -3
  20. package/storage/remoteStorage/dist/storageController.ts.cache +40 -3
  21. package/storage/remoteStorage/dist/storageLogs.ts.cache +109 -0
  22. package/storage/remoteStorage/dist/storageServerState.ts.cache +18 -4
  23. package/storage/remoteStorage/dist/storeSync.ts.cache +43 -2
  24. package/storage/remoteStorage/spec.md +4 -0
  25. package/storage/remoteStorage/storageController.d.ts +10 -0
  26. package/storage/remoteStorage/storageController.ts +37 -3
  27. package/storage/remoteStorage/storageLogs.d.ts +29 -0
  28. package/storage/remoteStorage/storageLogs.ts +96 -0
  29. package/storage/remoteStorage/storageServerState.ts +16 -2
  30. package/storage/remoteStorage/storeSync.d.ts +1 -0
  31. package/storage/remoteStorage/storeSync.ts +38 -0
  32. package/yarn.lock +4 -4
@@ -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
+ }
@@ -13,6 +13,7 @@ import { SocketFunction } from "socket-function/SocketFunction";
13
13
  import { StorageClientController } from "./storageClientController";
14
14
  import { isSelfSource } from "./storePlan";
15
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,7 +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),
46
- onSyncTransfer: (operation, path, bytes) => trackAccess({ account, operation, path: `${bucketName}/${path}`, size: 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
+ },
47
52
  resolveSourceUrl: resolveSourceArchives,
48
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
49
54
  onRoutingApplied: routing => scheduleBoundaryWork(account, bucketName, routing),
@@ -105,6 +110,7 @@ export async function readBucketInternal(account: string, bucketName: string, co
105
110
 
106
111
  function aggregateArchivesConfig(bucketStores: BlobStore[], routing: RemoteConfig | undefined): ArchivesConfig {
107
112
  let index = { fileCount: 0, byteCount: 0 };
113
+ let markedIndex: { fileCount: number; byteCount: number; oldestDeleteTime?: number } = { fileCount: 0, byteCount: 0 };
108
114
  let indexSources: { debugName: string; fileCount: number; byteCount: number }[] = [];
109
115
  let syncing: SyncActivity[] = [];
110
116
  let readerDiskLimit: number | undefined;
@@ -112,6 +118,11 @@ function aggregateArchivesConfig(bucketStores: BlobStore[], routing: RemoteConfi
112
118
  let progress = store.getSyncProgress();
113
119
  index.fileCount += progress.index.fileCount;
114
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
+ }
115
126
  indexSources.push(...progress.sources);
116
127
  syncing.push(...progress.syncing);
117
128
  readerDiskLimit = readerDiskLimit || progress.readerDiskLimit;
@@ -121,6 +132,7 @@ function aggregateArchivesConfig(bucketStores: BlobStore[], routing: RemoteConfi
121
132
  supportsChangesAfter: true,
122
133
  remoteConfig: routing,
123
134
  index,
135
+ markedIndex,
124
136
  indexSources,
125
137
  readerDiskLimit,
126
138
  syncing,
@@ -183,7 +195,9 @@ export async function writeRoutingConfig(account: string, bucketName: string, na
183
195
  let incoming = parseRoutingData(data);
184
196
  let current = await readRoutingFromDisk(account, bucketName);
185
197
  let stored = reinjectIntermediates(current, incoming);
186
- 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 });
187
201
  broadcastRoutingChanged();
188
202
  }
189
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
  }
@@ -258,6 +261,7 @@ export class StoreSync {
258
261
  }
259
262
  }
260
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 });
261
265
  }
262
266
 
263
267
  // ── per-source loops ──
@@ -326,6 +330,7 @@ export class StoreSync {
326
330
  let activity: SyncActivity = { type: "metadataScan", sourceDebugName: source.getDebugName(), startTime: scanStart };
327
331
  this.activities.add(activity);
328
332
  console.log(`Metadata scan of ${source.getDebugName()} starting (store ${this.store.folder})`);
333
+ logSyncEvent({ event: "scanStart", store: this.store.folder, source: source.getDebugName() });
329
334
  let progressTimer = setInterval(() => {
330
335
  console.log(`Metadata scan of ${source.getDebugName()} still running (${Math.round((Date.now() - scanStart) / 1000)}s, store ${this.store.folder})`);
331
336
  }, SYNC_PROGRESS_LOG_INTERVAL);
@@ -391,6 +396,7 @@ export class StoreSync {
391
396
  let union = indexSizeBefore + newPaths;
392
397
  let pct = (n: number) => `${Math.round(n / Math.max(union, 1) * 1000) / 10}%`;
393
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 });
394
400
  state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
395
401
  return seen;
396
402
  }
@@ -459,6 +465,9 @@ export class StoreSync {
459
465
  } else if (pushed) {
460
466
  console.log(`Reconciled sync source ${source.getDebugName()} (store ${this.store.folder}): pushed ${pushed} files it was missing or held older copies of`);
461
467
  }
468
+ if (pushed || failed) {
469
+ logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
470
+ }
462
471
  }
463
472
 
464
473
  private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
@@ -526,6 +535,7 @@ export class StoreSync {
526
535
  totalBytes,
527
536
  };
528
537
  this.activities.add(activity);
538
+ logSyncEvent({ event: "fullSyncStart", store: this.store.folder, source: source.getDebugName(), files: pending.length, bytes: totalBytes });
529
539
  let progressLogged = false;
530
540
  let logProgress = () => {
531
541
  progressLogged = true;
@@ -602,6 +612,7 @@ export class StoreSync {
602
612
  if (progressLogged) {
603
613
  logProgress();
604
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 });
605
616
  }
606
617
  }
607
618
 
@@ -665,6 +676,8 @@ export class StoreSync {
665
676
  for (let [key, tombstone] of this.store.deletedEntries()) {
666
677
  if (this.store.stopped.stop) return;
667
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;
668
681
  this.store.purgeIndexEntry(key);
669
682
  for (let i = 0; i < this.store.sources.length; i++) {
670
683
  if (!this.isLive(i)) continue;
@@ -678,4 +691,29 @@ export class StoreSync {
678
691
  }
679
692
  }
680
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
+ }
681
719
  }
package/yarn.lock CHANGED
@@ -1937,10 +1937,10 @@ slash@^3.0.0:
1937
1937
  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
1938
1938
  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
1939
1939
 
1940
- socket-function@^1.2.30:
1941
- version "1.2.30"
1942
- resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.2.30.tgz#95979b29a322c7e8d184ce7219310820d00cd042"
1943
- integrity sha512-A69c6ejRICUQXjC/3S3S2cd7CpoUjR1jjenPIEMA5b6G7xfUdQQqVM51hvxH73qDY/QhGV2UyZTDmlmgPh+f+g==
1940
+ socket-function@^1.2.31:
1941
+ version "1.2.31"
1942
+ resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.2.31.tgz#b9ced45d5c99c310ecbf775d13a731e2aefff8e2"
1943
+ integrity sha512-TBAHfSS2iapdweD6sOt2wRvynlrZ9B9EYSYBpm1x84hgbn/xWJRu1yL3e4uHeC07Fun4l8+meUEM6nZkujls0A==
1944
1944
  dependencies:
1945
1945
  "@types/pako" "^2.0.3"
1946
1946
  "@types/ws" "^8.5.3"