sliftutils 1.7.106 → 1.7.107
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 +4 -2
- package/package.json +1 -1
- package/storage/ArchivesDisk.ts +1 -1
- package/storage/dist/ArchivesDisk.ts.cache +4 -4
- package/storage/remoteStorage/dist/storageLogs.ts.cache +25 -16
- package/storage/remoteStorage/dist/storeSync.ts.cache +50 -39
- package/storage/remoteStorage/storageLogs.ts +25 -15
- package/storage/remoteStorage/storeSync.d.ts +4 -2
- package/storage/remoteStorage/storeSync.ts +46 -34
|
@@ -9,7 +9,10 @@ import { getOwnThreadId } from "../../misc/https/certs";
|
|
|
9
9
|
|
|
10
10
|
export const LOGS_FOLDER_NAME = "logs";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// NOT lazy: a failure to resolve the folder (the server config may simply not be SET yet this early in startup) must be retried on the next log call, not cached as "no logs ever"
|
|
13
|
+
let logsInstance: StreamingLogs | undefined;
|
|
14
|
+
function getLogs(): StreamingLogs | undefined {
|
|
15
|
+
if (logsInstance) return logsInstance;
|
|
13
16
|
let folder: string;
|
|
14
17
|
try {
|
|
15
18
|
folder = path.join(getStorageFolder(), LOGS_FOLDER_NAME);
|
|
@@ -17,8 +20,9 @@ const getLogs = lazy((): StreamingLogs | undefined => {
|
|
|
17
20
|
return undefined;
|
|
18
21
|
}
|
|
19
22
|
hookErrorLogging();
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
logsInstance = new StreamingLogs({ folder, threadId: getThreadIdSafe() });
|
|
24
|
+
return logsInstance;
|
|
25
|
+
}
|
|
22
26
|
|
|
23
27
|
function firstExternalIPv4(): string | undefined {
|
|
24
28
|
for (let addresses of Object.values(os.networkInterfaces())) {
|
|
@@ -29,22 +33,28 @@ function firstExternalIPv4(): string | undefined {
|
|
|
29
33
|
return undefined;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
// Memoized on SUCCESS only: the thread cert may not be loaded yet when the first entries are written, and that must not permanently strip the thread id from every later entry
|
|
37
|
+
let cachedThreadId: string | undefined;
|
|
32
38
|
function getThreadIdSafe(): string | undefined {
|
|
39
|
+
if (cachedThreadId) return cachedThreadId;
|
|
33
40
|
try {
|
|
34
|
-
|
|
35
|
-
} catch {
|
|
36
|
-
|
|
37
|
-
}
|
|
41
|
+
cachedThreadId = getOwnThreadId(getStorageServerConfigOptional()?.rootDomain || "");
|
|
42
|
+
} catch { }
|
|
43
|
+
return cachedThreadId;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const cachedIp = lazy(() => firstExternalIPv4());
|
|
47
|
+
|
|
48
|
+
// threadId and domain are re-resolved until known (early entries may predate the config/certs); the rest never changes
|
|
49
|
+
function baseFields() {
|
|
50
|
+
return {
|
|
51
|
+
pid: process.pid,
|
|
52
|
+
threadId: getThreadIdSafe(),
|
|
53
|
+
entryPoint: process.argv[1],
|
|
54
|
+
ip: cachedIp(),
|
|
55
|
+
domain: getStorageServerConfigOptional()?.domain,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
48
58
|
|
|
49
59
|
// Everything logged here goes BOTH to the storage log stream and the console - one call, never two. The console line carries only the entry (the base fields are constant per process, so they would just be noise there); the stream gets everything.
|
|
50
60
|
function write(kind: string, entry: { [key: string]: unknown }, alsoConsole: boolean): void {
|
|
@@ -37,14 +37,16 @@ export declare class StoreSync {
|
|
|
37
37
|
waitForRequiredScans(): Promise<void>;
|
|
38
38
|
/** Rescans our own disk's metadata into the index - used around valid window handoffs, where another process wrote files to the shared folder that our index hasn't seen. */
|
|
39
39
|
rescanBase(): Promise<void>;
|
|
40
|
+
/** One synchronization round of a source: the PULL direction always (its listing, applied to our index), and with "push" the push direction too (what our index says the source is missing, written to it). Push is an argument rather than a separate call because it cannot run without the pull's listing - the index alone cannot say what the source already holds. Listings unblock (initialScan) between the halves, so they never wait behind a push. */
|
|
41
|
+
private syncSource;
|
|
40
42
|
/** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes): just its changes since the boundary neighborhood, with matching values pulled onto our own disk. */
|
|
41
43
|
boundaryScanRemote(source: IArchives, config: {
|
|
42
44
|
since: number;
|
|
43
45
|
route?: [number, number];
|
|
44
46
|
}): Promise<void>;
|
|
45
47
|
private runSourceSync;
|
|
46
|
-
private
|
|
47
|
-
private
|
|
48
|
+
private pullSource;
|
|
49
|
+
private pushSource;
|
|
48
50
|
private updateScanIndex;
|
|
49
51
|
private pollChanges;
|
|
50
52
|
private copySourceFiles;
|
|
@@ -48,6 +48,8 @@ type SourceState = {
|
|
|
48
48
|
supportsChangesAfter: boolean;
|
|
49
49
|
initialScan: ReturnType<typeof promiseObj>;
|
|
50
50
|
scanComplete: boolean;
|
|
51
|
+
// Whether a scan of this source has ever actually SUCCEEDED - what full syncs gate on: without one, the index's view of this source can be arbitrarily stale (e.g. it was restarting when we first tried), and syncing from stale information churns forever
|
|
52
|
+
scanSucceeded: boolean;
|
|
51
53
|
// Files seen in this source's scans / change polls so far
|
|
52
54
|
scannedCount: number;
|
|
53
55
|
// Watermark for getChangesAfter2 polls
|
|
@@ -63,6 +65,7 @@ function newSourceState(): SourceState {
|
|
|
63
65
|
supportsChangesAfter: false,
|
|
64
66
|
initialScan: promiseObj(),
|
|
65
67
|
scanComplete: false,
|
|
68
|
+
scanSucceeded: false,
|
|
66
69
|
scannedCount: 0,
|
|
67
70
|
changesAfterTime: 0,
|
|
68
71
|
stopped: { stop: false },
|
|
@@ -216,7 +219,18 @@ export class StoreSync {
|
|
|
216
219
|
|
|
217
220
|
/** Rescans our own disk's metadata into the index - used around valid window handoffs, where another process wrote files to the shared folder that our index hasn't seen. */
|
|
218
221
|
public async rescanBase(): Promise<void> {
|
|
219
|
-
await this.
|
|
222
|
+
await this.syncSource(0);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** One synchronization round of a source: the PULL direction always (its listing, applied to our index), and with "push" the push direction too (what our index says the source is missing, written to it). Push is an argument rather than a separate call because it cannot run without the pull's listing - the index alone cannot say what the source already holds. Listings unblock (initialScan) between the halves, so they never wait behind a push. */
|
|
226
|
+
private async syncSource(sourceIndex: number, push?: "push"): Promise<void> {
|
|
227
|
+
let listing = await this.pullSource(sourceIndex);
|
|
228
|
+
let state = this.states[sourceIndex];
|
|
229
|
+
state.scanComplete = true;
|
|
230
|
+
state.initialScan.resolve(undefined);
|
|
231
|
+
if (push && !this.store.stopped.stop && !state.stopped.stop) {
|
|
232
|
+
await this.pushSource(sourceIndex, listing);
|
|
233
|
+
}
|
|
220
234
|
}
|
|
221
235
|
|
|
222
236
|
/** A boundary scan of the node that owned (part of) our route in the valid window before ours, when that node is different storage (a disk rescan can't see its writes): just its changes since the boundary neighborhood, with matching values pulled onto our own disk. */
|
|
@@ -272,7 +286,6 @@ export class StoreSync {
|
|
|
272
286
|
logSyncEvent({ event: "sourceSyncStart", store: this.store.folder, source: source.getDebugName(), sourceIndex, url: this.store.sources[sourceIndex].url, intermediate: this.store.sources[sourceIndex].intermediate });
|
|
273
287
|
// Read live for every pass, not captured - the store's source list can change while loops run
|
|
274
288
|
let noFullSync = () => this.store.sources[sourceIndex].noFullSync;
|
|
275
|
-
let listing: Map<string, number> | undefined;
|
|
276
289
|
// An intermediate is a deploy switchover's temporary alternate PORT onto a source we already have: the same bucket, reachable another way for a few minutes. Scanning it would list exactly what scanning that source lists, against a port that is about to disappear - so it is never scanned, and the source it was split out of covers it for as long as it exists and after it is gone.
|
|
277
290
|
if (this.store.sources[sourceIndex].intermediate) {
|
|
278
291
|
state.scanComplete = true;
|
|
@@ -283,19 +296,17 @@ export class StoreSync {
|
|
|
283
296
|
try {
|
|
284
297
|
let config = await source.getConfig();
|
|
285
298
|
state.supportsChangesAfter = !!config.supportsChangesAfter;
|
|
286
|
-
|
|
299
|
+
await this.syncSource(sourceIndex, "push");
|
|
287
300
|
break;
|
|
288
301
|
} catch (e) {
|
|
289
|
-
|
|
302
|
+
logSyncEvent({ event: "initialScanFailed", store: this.store.folder, source: source.getDebugName(), retryInMs: SCAN_RETRY_DELAY, error: String((e as Error).stack ?? e).slice(0, 2000) });
|
|
290
303
|
await delay(SCAN_RETRY_DELAY);
|
|
291
304
|
}
|
|
292
305
|
}
|
|
306
|
+
// The loop can also exit by the stop tokens, and nothing may wait forever on a stopped source's initial scan
|
|
293
307
|
state.scanComplete = true;
|
|
294
308
|
state.initialScan.resolve(undefined);
|
|
295
309
|
if (this.store.stopped.stop || state.stopped.stop) return;
|
|
296
|
-
if (listing) {
|
|
297
|
-
await this.reconcileSource(sourceIndex, listing);
|
|
298
|
-
}
|
|
299
310
|
if (!noFullSync()) {
|
|
300
311
|
try {
|
|
301
312
|
await this.copySourceFiles(sourceIndex);
|
|
@@ -305,25 +316,22 @@ export class StoreSync {
|
|
|
305
316
|
}
|
|
306
317
|
if (state.supportsChangesAfter) {
|
|
307
318
|
runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
|
|
319
|
+
// A scan that has not succeeded yet is retried HERE, before anything else - polling changes and copying against a never-scanned source would run on arbitrarily stale information
|
|
320
|
+
if (!state.scanSucceeded) {
|
|
321
|
+
await this.syncSource(sourceIndex, "push");
|
|
322
|
+
}
|
|
308
323
|
await this.pollChanges(sourceIndex);
|
|
309
324
|
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
310
325
|
}, state.stopped);
|
|
311
|
-
// Change polls only show what the source HAS, never what it's missing, so pushes run on the full-rescan cadence (findInfo on an index-backed source is cheap)
|
|
312
|
-
runInfinitePoll(FULL_RESCAN_INTERVAL, async () => {
|
|
313
|
-
let files = await source.findInfo("");
|
|
314
|
-
await this.reconcileSource(sourceIndex, new Map(files.map(x => [x.path, x.createTime])));
|
|
315
|
-
}, state.stopped);
|
|
316
|
-
} else {
|
|
317
|
-
runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
|
|
318
|
-
let rescan = await this.scanSource(sourceIndex);
|
|
319
|
-
await this.reconcileSource(sourceIndex, rescan);
|
|
320
|
-
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
321
|
-
}, state.stopped);
|
|
322
326
|
}
|
|
327
|
+
runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
|
|
328
|
+
await this.syncSource(sourceIndex, "push");
|
|
329
|
+
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
330
|
+
}, state.stopped);
|
|
323
331
|
}
|
|
324
332
|
|
|
325
|
-
//
|
|
326
|
-
private async
|
|
333
|
+
// The PULL direction: a full metadata scan (size, writeTime, path) of one source, applied to our index. Returns the source's listing (path -> write time), which pushSource uses for the opposite direction.
|
|
334
|
+
private async pullSource(sourceIndex: number): Promise<Map<string, number>> {
|
|
327
335
|
let { source, route } = this.store.sources[sourceIndex];
|
|
328
336
|
let state = this.states[sourceIndex];
|
|
329
337
|
let scanStart = Date.now();
|
|
@@ -393,11 +401,12 @@ export class StoreSync {
|
|
|
393
401
|
}
|
|
394
402
|
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, unchanged: tally.unchanged, outsideRoute: tally.filtered, missingOnSource, removedFromIndex });
|
|
395
403
|
state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
|
|
404
|
+
state.scanSucceeded = true;
|
|
396
405
|
return seen;
|
|
397
406
|
}
|
|
398
407
|
|
|
399
408
|
// The push direction of synchronization: everything we know that the source is missing (or holds an older copy of) is written to it — including deletions, as tombstone writes. This is what heals a source whose background writes failed (e.g. it was down): the next scan sees what's missing and re-sends it. A failing file is skipped, not fatal (immutable targets are handled by forceSetImmutable, and one unreadable value must not stop the rest of the pass) - only a run of consecutive failures (the source itself is down) aborts until the next scan cycle.
|
|
400
|
-
private async
|
|
409
|
+
private async pushSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
|
|
401
410
|
let { source, validWindows, route } = this.store.sources[sourceIndex];
|
|
402
411
|
let state = this.states[sourceIndex];
|
|
403
412
|
let acceptsWrites = windowsAcceptWrites(validWindows);
|
|
@@ -458,9 +467,7 @@ export class StoreSync {
|
|
|
458
467
|
if (failed) {
|
|
459
468
|
console.error(`Reconciling sync source ${source.getDebugName()} (store ${this.store.folder}): pushed ${pushed} files, ${failed} failed${aborted && ` before aborting the pass (${consecutiveFailures} consecutive failures - the source looks down; the next scan cycle retries)` || ""}. First errors: ${errors.join(" | ")}`);
|
|
460
469
|
}
|
|
461
|
-
|
|
462
|
-
logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
|
|
463
|
-
}
|
|
470
|
+
logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
|
|
464
471
|
}
|
|
465
472
|
|
|
466
473
|
private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
|
|
@@ -558,7 +565,6 @@ export class StoreSync {
|
|
|
558
565
|
try {
|
|
559
566
|
let nextIndex = 0;
|
|
560
567
|
let consecutiveFailures = 0;
|
|
561
|
-
let errors: string[] = [];
|
|
562
568
|
let copyWorker = async () => {
|
|
563
569
|
while (!aborted && !this.store.stopped.stop && !state.stopped.stop) {
|
|
564
570
|
let index = nextIndex++;
|
|
@@ -570,22 +576,31 @@ export class StoreSync {
|
|
|
570
576
|
copiedFiles++;
|
|
571
577
|
copiedBytes += copied.size;
|
|
572
578
|
this.store.noteSyncTransfer("sync get", key, copied.size);
|
|
573
|
-
// The copy carries the source's write time, and the index commits it under the normal ordering rule (>= the current time wins) -
|
|
579
|
+
// The copy carries the source's write time, and the index commits it under the normal ordering rule (>= the current time wins) - it refusing means a NEWER write landed while we copied, which must be said, not swallowed
|
|
574
580
|
if (!this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) })) {
|
|
575
|
-
|
|
581
|
+
logSyncEvent({ event: "fullSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
|
|
576
582
|
}
|
|
577
583
|
} else {
|
|
578
584
|
missingOnSource++;
|
|
579
|
-
|
|
585
|
+
// The source's listing vouched for the file but its read has nothing - and asking again forever is not an answer. If our own disk holds a copy, IT is the real holder, so the entry repoints there; if neither side has bytes, the entry was simply wrong, and it is purged - forgotten, not deleted, exactly like a read that comes up empty everywhere (a scan re-finds the file if it exists anywhere else).
|
|
586
|
+
let local = await this.store.sources[0].source.getInfo(key);
|
|
587
|
+
if (local) {
|
|
588
|
+
this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
589
|
+
logSyncEvent({ event: "fullSyncMissingOnSource", store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, resolution: "repointed to our local copy", localSize: local.size });
|
|
590
|
+
} else if (this.store.currentWriteTime(key) <= entry.writeTime) {
|
|
591
|
+
this.store.purgeIndexEntry(key);
|
|
592
|
+
logSyncEvent({ event: "fullSyncMissingOnSource", store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, resolution: "purged - neither the source nor our disk has it, so it does not exist" });
|
|
593
|
+
} else {
|
|
594
|
+
// A newer write landed while we were checking - the entry is no longer the one we found missing, so it is left alone
|
|
595
|
+
logSyncEvent({ event: "fullSyncMissingOnSource", store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, resolution: "kept - a newer write appeared while checking", currentWriteTime: this.store.currentWriteTime(key) });
|
|
596
|
+
}
|
|
580
597
|
}
|
|
581
598
|
consecutiveFailures = 0;
|
|
582
599
|
} catch (e) {
|
|
583
600
|
// One failing file must not abort the sync: it stays on its source (reads still reach it there), and the next sync pass retries it
|
|
584
601
|
failed++;
|
|
585
602
|
consecutiveFailures++;
|
|
586
|
-
|
|
587
|
-
errors.push(`${key}: ${(e as Error).stack ?? e}`);
|
|
588
|
-
}
|
|
603
|
+
logSyncEvent({ event: "fullSyncCopyFailed", store: this.store.folder, source: source.getDebugName(), path: key, size: entry.size, error: String((e as Error).stack ?? e).slice(0, 2000) });
|
|
589
604
|
if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
|
|
590
605
|
aborted = true;
|
|
591
606
|
return;
|
|
@@ -602,9 +617,6 @@ export class StoreSync {
|
|
|
602
617
|
workers.push(copyWorker());
|
|
603
618
|
}
|
|
604
619
|
await Promise.all(workers);
|
|
605
|
-
if (failed) {
|
|
606
|
-
console.error(`Full sync from ${source.getDebugName()} (store ${this.store.folder}): ${failed} of ${pending.length} files failed to copy${aborted && ` before aborting the pass (${SYNC_MAX_CONSECUTIVE_FAILURES} consecutive failures - the source looks down; the next sync pass retries)` || " (they stay on their source, and the next sync pass retries them)"}. First errors: ${errors.join(" | ")}`);
|
|
607
|
-
}
|
|
608
620
|
} finally {
|
|
609
621
|
clearInterval(progressTimer);
|
|
610
622
|
clearInterval(slowErrorTimer);
|