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.
- package/index.d.ts +227 -7
- package/package.json +1 -1
- 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/TransactionFile.ts.cache +41 -9
- package/storage/remoteStorage/ArchivesRemote.ts +3 -3
- package/storage/remoteStorage/blobStore.d.ts +32 -0
- package/storage/remoteStorage/blobStore.ts +124 -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 +127 -12
- package/storage/remoteStorage/dist/createArchives.ts.cache +52 -3
- package/storage/remoteStorage/dist/storageController.ts.cache +40 -3
- package/storage/remoteStorage/dist/storageServerState.ts.cache +18 -3
- package/storage/remoteStorage/dist/storeSync.ts.cache +47 -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 +17 -2
- package/storage/remoteStorage/storeSync.d.ts +1 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
}
|