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.
- package/index.d.ts +223 -7
- package/package.json +2 -2
- package/storage/IArchives.d.ts +18 -0
- package/storage/IArchives.ts +14 -0
- package/storage/StreamingLogs.d.ts +77 -0
- package/storage/StreamingLogs.ts +412 -0
- package/storage/TransactionFile.d.ts +12 -6
- package/storage/TransactionFile.ts +44 -12
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/StreamingLogs.ts.cache +397 -0
- package/storage/dist/TransactionFile.ts.cache +41 -9
- package/storage/remoteStorage/ArchivesRemote.ts +3 -3
- package/storage/remoteStorage/blobStore.d.ts +28 -0
- package/storage/remoteStorage/blobStore.ts +117 -11
- package/storage/remoteStorage/createArchives.d.ts +40 -1
- package/storage/remoteStorage/createArchives.ts +84 -0
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
- package/storage/remoteStorage/dist/blobStore.ts.cache +122 -12
- package/storage/remoteStorage/dist/createArchives.ts.cache +87 -3
- package/storage/remoteStorage/dist/storageController.ts.cache +40 -3
- package/storage/remoteStorage/dist/storageLogs.ts.cache +109 -0
- package/storage/remoteStorage/dist/storageServerState.ts.cache +18 -4
- package/storage/remoteStorage/dist/storeSync.ts.cache +43 -2
- package/storage/remoteStorage/spec.md +4 -0
- package/storage/remoteStorage/storageController.d.ts +10 -0
- package/storage/remoteStorage/storageController.ts +37 -3
- package/storage/remoteStorage/storageLogs.d.ts +29 -0
- package/storage/remoteStorage/storageLogs.ts +96 -0
- package/storage/remoteStorage/storageServerState.ts +16 -2
- package/storage/remoteStorage/storeSync.d.ts +1 -0
- package/storage/remoteStorage/storeSync.ts +38 -0
- 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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
|
@@ -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.
|
|
1941
|
-
version "1.2.
|
|
1942
|
-
resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.2.
|
|
1943
|
-
integrity sha512-
|
|
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"
|