sliftutils 1.7.109 → 1.7.111
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 +8 -4
- package/package.json +1 -1
- package/storage/ArchivesDisk.ts +8 -0
- package/storage/archiveHelpers.d.ts +1 -1
- package/storage/archiveHelpers.ts +47 -26
- package/storage/dist/ArchivesDisk.ts.cache +11 -3
- package/storage/dist/archiveHelpers.ts.cache +52 -30
- package/storage/remoteStorage/blobStore.ts +17 -9
- package/storage/remoteStorage/createArchives.ts +5 -0
- package/storage/remoteStorage/dist/blobStore.ts.cache +21 -12
- package/storage/remoteStorage/dist/createArchives.ts.cache +8 -3
- package/storage/remoteStorage/dist/storageLogs.ts.cache +11 -5
- package/storage/remoteStorage/dist/storeSync.ts.cache +212 -162
- package/storage/remoteStorage/storageLogs.d.ts +6 -3
- package/storage/remoteStorage/storageLogs.ts +8 -2
- package/storage/remoteStorage/storeSync.d.ts +1 -0
- package/storage/remoteStorage/storeSync.ts +57 -12
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
import { LogFileInfo } from "../StreamingLogs";
|
|
4
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. Stream-only - one entry per write is exactly what the console does NOT need. */
|
|
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. Logged DELIBERATELY at two layers: the controller (which knows the account/bucket and the caller) AND BlobStore itself (which knows the folder, and sees the writes that never pass through the controller) - the redundancy is the point, because a write that only one layer saw is exactly the kind of masked issue these logs exist to expose. Stream-only - one entry per write is exactly what the console does NOT need. */
|
|
6
6
|
export declare function logMutation(entry: {
|
|
7
7
|
op: string;
|
|
8
|
-
account
|
|
9
|
-
bucketName
|
|
8
|
+
account?: string;
|
|
9
|
+
bucketName?: string;
|
|
10
10
|
store?: string;
|
|
11
|
+
folder?: string;
|
|
11
12
|
path: string;
|
|
12
13
|
toPath?: string;
|
|
13
14
|
size?: number;
|
|
@@ -24,6 +25,8 @@ export declare function logSyncEvent(entry: {
|
|
|
24
25
|
}): void;
|
|
25
26
|
/** One console.error is all an error takes (console.error and console.warn are HOOKED to feed the stream) - this just guarantees the hook is installed first, for very-early callers. */
|
|
26
27
|
export declare function logStorageError(message: string): void;
|
|
28
|
+
/** logStorageError at warn level: guarantees the console.warn hook is installed before warning, so warnings from before the first logged mutation still reach the stream. */
|
|
29
|
+
export declare function logStorageWarn(message: string): void;
|
|
27
30
|
/** The log files this server holds - see StreamingLogs.listFiles. Empty on processes with no storage folder. */
|
|
28
31
|
export declare function listStorageLogFiles(): Promise<LogFileInfo[]>;
|
|
29
32
|
/** One log file's bytes, always LZ4-compressed - see StreamingLogs.readFileCompressed (decode with decodeLogFile). */
|
|
@@ -66,8 +66,8 @@ function write(kind: string, entry: { [key: string]: unknown }, alsoConsole: boo
|
|
|
66
66
|
logs.log({ kind, time: Date.now(), ...baseFields(), ...entry });
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/** 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. Stream-only - one entry per write is exactly what the console does NOT need. */
|
|
70
|
-
export function logMutation(entry: { op: string; account
|
|
69
|
+
/** 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. Logged DELIBERATELY at two layers: the controller (which knows the account/bucket and the caller) AND BlobStore itself (which knows the folder, and sees the writes that never pass through the controller) - the redundancy is the point, because a write that only one layer saw is exactly the kind of masked issue these logs exist to expose. Stream-only - one entry per write is exactly what the console does NOT need. */
|
|
70
|
+
export function logMutation(entry: { op: string; account?: string; bucketName?: string; store?: string; folder?: string; path: string; toPath?: string; size?: number; writeTime?: number; callerId?: string; internal?: boolean }): void {
|
|
71
71
|
write("mutation", entry, false);
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -82,6 +82,12 @@ export function logStorageError(message: string): void {
|
|
|
82
82
|
console.error(message);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** logStorageError at warn level: guarantees the console.warn hook is installed before warning, so warnings from before the first logged mutation still reach the stream. */
|
|
86
|
+
export function logStorageWarn(message: string): void {
|
|
87
|
+
getLogs();
|
|
88
|
+
console.warn(message);
|
|
89
|
+
}
|
|
90
|
+
|
|
85
91
|
// Everything console.errored or console.warned also lands in the log stream (with a reentrancy guard: a failure INSIDE logging must not log itself forever)
|
|
86
92
|
let writingHooked = false;
|
|
87
93
|
const hookErrorLogging = lazy(() => {
|
|
@@ -44,6 +44,8 @@ const SYNC_FAILURE_DELAY = 1000 * 15;
|
|
|
44
44
|
// A full listing that comes back EMPTY - or under half of what we know the source holds - is treated as the other end being briefly broken, not as the truth: it is retried this many times, this far apart, before being believed. Believing a wrongly-shrunken listing purges every index entry the source held, which is how sync progress "goes backwards".
|
|
45
45
|
const SUSPICIOUS_SCAN_RETRIES = 3;
|
|
46
46
|
const SUSPICIOUS_SCAN_RETRY_DELAY = 1000 * 60;
|
|
47
|
+
// A source whose every window ended this long ago (and has none in the future) can never receive writes again - the window gates them, deletions included. Scanning such a source can only re-ingest history it will never be told to delete, which after tombstone expiry IS resurrection - so it is not scanned at all. The grace covers handoff stragglers around a window's end.
|
|
48
|
+
const SCAN_STALE_WINDOW_GRACE = 1000 * 60 * 60;
|
|
47
49
|
|
|
48
50
|
type SourceState = {
|
|
49
51
|
supportsChangesAfter: boolean;
|
|
@@ -258,6 +260,14 @@ export class StoreSync {
|
|
|
258
260
|
}
|
|
259
261
|
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, forceSetImmutable: true, noChecks: true, internal: true });
|
|
260
262
|
if (!copied) {
|
|
263
|
+
// Undefined is two cases (see copyArchiveFile): our own disk already holding something newer is the boundary scan working as intended (our writes since the boundary outrank the neighbor's), only a genuinely unreadable file is worth a warning
|
|
264
|
+
let local = await this.store.sources[0].source.getInfo(file.path);
|
|
265
|
+
if (local && local.writeTime > file.createTime) {
|
|
266
|
+
logSyncEvent({ event: "boundaryScanLocalNewer", store: this.store.folder, source: source.getDebugName(), path: file.path, theirWriteTime: new Date(file.createTime).toISOString(), theirSize: file.size, localWriteTime: new Date(local.writeTime).toISOString(), localSize: local.size });
|
|
267
|
+
this.store.setIndexEntry(file.path, { writeTime: local.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
268
|
+
tally.unchanged++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
261
271
|
console.warn(`Boundary scan could not copy ${file.path} from ${source.getDebugName()} (store ${this.store.folder}): its change feed listed it (${file.size} bytes, writeTime ${new Date(file.createTime).toISOString()}) but the read found nothing`);
|
|
262
272
|
continue;
|
|
263
273
|
}
|
|
@@ -279,6 +289,12 @@ export class StoreSync {
|
|
|
279
289
|
|
|
280
290
|
// ── per-source loops ──
|
|
281
291
|
|
|
292
|
+
// Slot 0 is exempt: our own disk answers for every window this store EVER held, and scanning it is what keeps the index honest about them. Read live each call - updateSources moves the windows on running slots, so a source goes stale (or comes back) while its loops run.
|
|
293
|
+
private windowsAllowScanning(sourceIndex: number): boolean {
|
|
294
|
+
if (sourceIndex === 0) return true;
|
|
295
|
+
return this.store.sources[sourceIndex].validWindows.some(w => w[1] > Date.now() - SCAN_STALE_WINDOW_GRACE);
|
|
296
|
+
}
|
|
297
|
+
|
|
282
298
|
private async startSourceSyncLoops(sourceIndex: number): Promise<void> {
|
|
283
299
|
await this.store.registerSlot(sourceIndex);
|
|
284
300
|
let sourceObj = this.store.sources[sourceIndex];
|
|
@@ -294,7 +310,23 @@ export class StoreSync {
|
|
|
294
310
|
state.initialScan.resolve(undefined);
|
|
295
311
|
return;
|
|
296
312
|
}
|
|
297
|
-
|
|
313
|
+
// Checked per tick, never decided once: the windows move on running slots (see updateSources), so a source goes stale mid-life and can come back if a config extends them. Logged only on each transition into staleness.
|
|
314
|
+
let loggedStale = false;
|
|
315
|
+
let skipStale = () => {
|
|
316
|
+
if (this.windowsAllowScanning(sourceIndex)) {
|
|
317
|
+
loggedStale = false;
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
state.scanComplete = true;
|
|
321
|
+
state.initialScan.resolve(undefined);
|
|
322
|
+
if (!loggedStale) {
|
|
323
|
+
loggedStale = true;
|
|
324
|
+
logSyncEvent({ event: "scanSkippedStaleWindows", store: this.store.folder, source: source.getDebugName(), validWindows: this.store.sources[sourceIndex].validWindows.map(w => [new Date(w[0]).toISOString(), new Date(w[1]).toISOString()]), graceMs: SCAN_STALE_WINDOW_GRACE });
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
};
|
|
328
|
+
// An already-stale source skips getConfig entirely: its endpoint is often long gone, and retrying that every 30s forever is noise about a source we would not scan anyway (if its windows are later extended, the full-round poll below picks it up - just without change polling until a restart)
|
|
329
|
+
while (!skipStale() && !this.store.stopped.stop && !state.stopped.stop) {
|
|
298
330
|
try {
|
|
299
331
|
let config = await source.getConfig();
|
|
300
332
|
state.supportsChangesAfter = !!config.supportsChangesAfter;
|
|
@@ -310,6 +342,7 @@ export class StoreSync {
|
|
|
310
342
|
// Both loops below run one at a time: a change-poll tick landing mid full round would otherwise start a second copy pass over the same pending list, downloading everything twice
|
|
311
343
|
let serial = runInSerial(async (fnc: () => Promise<void>) => await fnc());
|
|
312
344
|
await runInfinitePollCallAtStart(pollInterval, () => serial(async () => {
|
|
345
|
+
if (skipStale()) return;
|
|
313
346
|
while (!this.store.stopped.stop && !state.stopped.stop) {
|
|
314
347
|
try {
|
|
315
348
|
await this.syncSource(sourceIndex)("push");
|
|
@@ -326,6 +359,7 @@ export class StoreSync {
|
|
|
326
359
|
}), state.stopped);
|
|
327
360
|
if (state.supportsChangesAfter) {
|
|
328
361
|
runInfinitePoll(CHANGES_POLL_INTERVAL, () => serial(async () => {
|
|
362
|
+
if (skipStale()) return;
|
|
329
363
|
// 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
|
|
330
364
|
if (!state.scanSucceeded) {
|
|
331
365
|
await this.syncSource(sourceIndex)("push");
|
|
@@ -461,7 +495,17 @@ export class StoreSync {
|
|
|
461
495
|
let holder = await this.store.getEntryHolder(entry);
|
|
462
496
|
if (!holder) continue;
|
|
463
497
|
let copied = await copyArchiveFile({ from: holder, to: source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
|
|
464
|
-
if (!copied)
|
|
498
|
+
if (!copied) {
|
|
499
|
+
// Undefined is two cases (see copyArchiveFile). The source having a NEWER file than our index (the listing we pushed from was stale) is adopted exactly the way the pull direction adopts a listing entry - and pushing our stale copy stops. Otherwise the HOLDER could not produce the file, which the next round's pull re-resolves; either way, silence here was the bug.
|
|
500
|
+
let theirs = await source.getInfo(key);
|
|
501
|
+
if (theirs && theirs.writeTime > writeTime) {
|
|
502
|
+
this.store.setIndexEntry(key, { writeTime: theirs.writeTime, size: theirs.size, sourcesListIndex: targetSourcesListIndex });
|
|
503
|
+
logSyncEvent({ event: "pushFoundNewerOnSource", store: this.store.folder, source: source.getDebugName(), path: key, ourWriteTime: new Date(writeTime).toISOString(), theirWriteTime: new Date(theirs.writeTime).toISOString(), ourSize: entry.size, theirSize: theirs.size });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
logSyncEvent({ event: "pushCopyUnavailable", store: this.store.folder, source: source.getDebugName(), path: key, holder: holder.getDebugName(), expectedSize: entry.size, expectedWriteTime: new Date(writeTime).toISOString(), sourceHas: theirs && `${theirs.size} bytes at ${new Date(theirs.writeTime).toISOString()}` || "nothing" });
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
465
509
|
this.store.noteSyncTransfer("sync set", key, copied.size);
|
|
466
510
|
pushed++;
|
|
467
511
|
consecutiveFailures = 0;
|
|
@@ -550,11 +594,11 @@ export class StoreSync {
|
|
|
550
594
|
totalBytes,
|
|
551
595
|
};
|
|
552
596
|
this.activities.add(activity);
|
|
553
|
-
logSyncEvent({ event: "
|
|
597
|
+
logSyncEvent({ event: "deltaSyncStart", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, files: pending.length, bytes: totalBytes });
|
|
554
598
|
let progressLogged = false;
|
|
555
599
|
let logProgress = () => {
|
|
556
600
|
progressLogged = true;
|
|
557
|
-
console.log(magenta(`
|
|
601
|
+
console.log(magenta(`Delta sync from ${source.getDebugName()} (store ${this.store.folder}): ${activity.doneFiles}/${pending.length} files (${((activity.doneFiles || 0) / pending.length * 100).toFixed(1)}%), ${formatNumber(activity.doneBytes || 0)}B/${formatNumber(totalBytes)}B (${(totalBytes && (activity.doneBytes || 0) / totalBytes * 100 || 100).toFixed(1)}%)`));
|
|
558
602
|
};
|
|
559
603
|
let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
|
|
560
604
|
(progressTimer as { unref?: () => void }).unref?.();
|
|
@@ -569,7 +613,7 @@ export class StoreSync {
|
|
|
569
613
|
let remainingMs = remainingBytes / bytesPerSecond * 1000;
|
|
570
614
|
etaText = `${formatTime(remainingMs)} remaining, completing around ${new Date(Date.now() + remainingMs).toISOString()}`;
|
|
571
615
|
}
|
|
572
|
-
console.warn(`
|
|
616
|
+
console.warn(`Delta sync from ${source.getDebugName()} (store ${this.store.folder}) has been running for ${formatTime(elapsed)}: ${doneFiles}/${pending.length} files (${(doneFiles / pending.length * 100).toFixed(1)}%), ${formatNumber(doneBytes)}B/${formatNumber(totalBytes)}B (${(totalBytes && doneBytes / totalBytes * 100 || 100).toFixed(1)}%), ${formatNumber(bytesPerSecond)}B/s. Estimated ${etaText}.`);
|
|
573
617
|
}, FULL_SYNC_SLOW_ERROR_INTERVAL);
|
|
574
618
|
(slowErrorTimer as { unref?: () => void }).unref?.();
|
|
575
619
|
let failed = 0;
|
|
@@ -582,8 +626,9 @@ export class StoreSync {
|
|
|
582
626
|
let base = { event, store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, ...extra };
|
|
583
627
|
let local = await this.store.sources[0].source.getInfo(key);
|
|
584
628
|
if (local) {
|
|
585
|
-
|
|
586
|
-
|
|
629
|
+
// The local copy can be NEWER than the entry (which is exactly why copyArchiveFile refuses to overwrite it - see its destination check), so the repoint keeps the newer of the two times rather than rolling the index back
|
|
630
|
+
this.store.setIndexEntry(key, { writeTime: Math.max(entry.writeTime, local.writeTime), size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
631
|
+
logSyncEvent({ ...base, resolution: "repointed to our local copy", localSize: local.size, localWriteTime: new Date(local.writeTime).toISOString() });
|
|
587
632
|
return;
|
|
588
633
|
}
|
|
589
634
|
if (this.store.currentWriteTime(key) <= entry.writeTime) {
|
|
@@ -610,11 +655,11 @@ export class StoreSync {
|
|
|
610
655
|
this.store.noteSyncTransfer("sync get", key, copied.size);
|
|
611
656
|
// 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
|
|
612
657
|
if (!this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) })) {
|
|
613
|
-
logSyncEvent({ event: "
|
|
658
|
+
logSyncEvent({ event: "deltaSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
|
|
614
659
|
}
|
|
615
660
|
} else {
|
|
616
661
|
missingOnSource++;
|
|
617
|
-
await resolveUnavailable("
|
|
662
|
+
await resolveUnavailable("deltaSyncMissingOnSource", key, entry, {});
|
|
618
663
|
}
|
|
619
664
|
consecutiveFailures = 0;
|
|
620
665
|
} catch (e) {
|
|
@@ -622,9 +667,9 @@ export class StoreSync {
|
|
|
622
667
|
failed++;
|
|
623
668
|
consecutiveFailures++;
|
|
624
669
|
try {
|
|
625
|
-
await resolveUnavailable("
|
|
670
|
+
await resolveUnavailable("deltaSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
|
|
626
671
|
} catch (resolveError) {
|
|
627
|
-
logSyncEvent({ event: "
|
|
672
|
+
logSyncEvent({ event: "deltaSyncCopyFailed", store: this.store.folder, source: source.getDebugName(), path: key, error: String((e as Error).stack ?? e).slice(0, 2000), resolveError: String((resolveError as Error).stack ?? resolveError).slice(0, 2000) });
|
|
628
673
|
}
|
|
629
674
|
if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
|
|
630
675
|
aborted = true;
|
|
@@ -650,7 +695,7 @@ export class StoreSync {
|
|
|
650
695
|
if (progressLogged) {
|
|
651
696
|
logProgress();
|
|
652
697
|
}
|
|
653
|
-
logSyncEvent({ event: "
|
|
698
|
+
logSyncEvent({ event: "deltaSyncFinish", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, durationMs: Date.now() - activity.startTime, totalFiles: pending.length, totalBytes, processedFiles: activity.doneFiles, copiedFiles, copiedBytes, missingOnSource, failed, aborted });
|
|
654
699
|
}
|
|
655
700
|
}));
|
|
656
701
|
|