sliftutils 1.7.107 → 1.7.108
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 +3 -6
- package/package.json +1 -1
- package/storage/archiveHelpers.d.ts +1 -4
- package/storage/archiveHelpers.ts +6 -13
- package/storage/dist/ArchivesDisk.ts.cache +2 -2
- package/storage/dist/archiveHelpers.ts.cache +9 -12
- package/storage/remoteStorage/dist/storageLogs.ts.cache +5 -5
- package/storage/remoteStorage/dist/storeSync.ts.cache +67 -46
- package/storage/remoteStorage/storageLogs.d.ts +1 -1
- package/storage/remoteStorage/storageLogs.ts +2 -2
- package/storage/remoteStorage/storeSync.d.ts +1 -1
- package/storage/remoteStorage/storeSync.ts +60 -39
|
@@ -2,7 +2,7 @@
|
|
|
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.
|
|
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. */
|
|
6
6
|
export declare function logMutation(entry: {
|
|
7
7
|
op: string;
|
|
8
8
|
account: string;
|
|
@@ -66,9 +66,9 @@ 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.
|
|
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
70
|
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 {
|
|
71
|
-
write("mutation", entry,
|
|
71
|
+
write("mutation", entry, false);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/** 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. Also printed to the console. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runInfinitePoll, delay } from "socket-function/src/batching";
|
|
1
|
+
import { runInfinitePoll, delay, runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
2
2
|
import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
|
|
3
3
|
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
4
4
|
import {
|
|
@@ -21,7 +21,7 @@ const CHANGES_POLL_INTERVAL = 1000 * 60;
|
|
|
21
21
|
const CONFIG_POLL_INTERVAL = 1000 * 60 * 5;
|
|
22
22
|
// Full metadata rescans. supportsChangesAfter is the heuristic for "one of our own storage servers": their index-backed listings are cheap, so hourly is fine. Everything else (backblaze, plain disk) pays the full listing cost, so it rescans much less often.
|
|
23
23
|
const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
|
|
24
|
-
const
|
|
24
|
+
const FULL_RESCAN_NON_REMOTE_INTERVAL = 1000 * 60 * 60 * 6;
|
|
25
25
|
// Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
|
|
26
26
|
const CHANGES_POLL_OVERLAP = timeInMinute;
|
|
27
27
|
const SCAN_RETRY_DELAY = 1000 * 30;
|
|
@@ -97,7 +97,7 @@ export class StoreSync {
|
|
|
97
97
|
public start(): void {
|
|
98
98
|
for (let i = 0; i < this.store.sources.length; i++) {
|
|
99
99
|
if (!this.isLive(i)) continue;
|
|
100
|
-
void this.
|
|
100
|
+
void this.startSourceSyncLoops(i);
|
|
101
101
|
}
|
|
102
102
|
runInfinitePoll(CONFIG_POLL_INTERVAL, () => this.pollRoutingConfig(), this.store.stopped);
|
|
103
103
|
runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.store.stopped);
|
|
@@ -147,7 +147,7 @@ export class StoreSync {
|
|
|
147
147
|
public addSource(slot: number): void {
|
|
148
148
|
this.states[slot] = newSourceState();
|
|
149
149
|
if (this.store.syncStarted) {
|
|
150
|
-
void this.
|
|
150
|
+
void this.startSourceSyncLoops(slot);
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
@@ -255,7 +255,7 @@ export class StoreSync {
|
|
|
255
255
|
tally.tombstone++;
|
|
256
256
|
continue;
|
|
257
257
|
}
|
|
258
|
-
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path,
|
|
258
|
+
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, forceSetImmutable: true, noChecks: true, internal: true });
|
|
259
259
|
if (!copied) {
|
|
260
260
|
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`);
|
|
261
261
|
continue;
|
|
@@ -278,9 +278,10 @@ export class StoreSync {
|
|
|
278
278
|
|
|
279
279
|
// ── per-source loops ──
|
|
280
280
|
|
|
281
|
-
private async
|
|
281
|
+
private async startSourceSyncLoops(sourceIndex: number): Promise<void> {
|
|
282
282
|
await this.store.registerSlot(sourceIndex);
|
|
283
|
-
let
|
|
283
|
+
let sourceObj = this.store.sources[sourceIndex];
|
|
284
|
+
let source = sourceObj.source;
|
|
284
285
|
let state = this.states[sourceIndex];
|
|
285
286
|
// If a slot ever gets TWO of these, its loops are doubled - every poll and full sync runs twice
|
|
286
287
|
logSyncEvent({ event: "sourceSyncStart", store: this.store.folder, source: source.getDebugName(), sourceIndex, url: this.store.sources[sourceIndex].url, intermediate: this.store.sources[sourceIndex].intermediate });
|
|
@@ -296,24 +297,29 @@ export class StoreSync {
|
|
|
296
297
|
try {
|
|
297
298
|
let config = await source.getConfig();
|
|
298
299
|
state.supportsChangesAfter = !!config.supportsChangesAfter;
|
|
299
|
-
await this.syncSource(sourceIndex, "push");
|
|
300
300
|
break;
|
|
301
301
|
} catch (e) {
|
|
302
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) });
|
|
303
303
|
await delay(SCAN_RETRY_DELAY);
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
|
-
// The loop can also exit by the stop tokens, and nothing may wait forever on a stopped source's initial scan
|
|
307
|
-
state.scanComplete = true;
|
|
308
|
-
state.initialScan.resolve(undefined);
|
|
309
306
|
if (this.store.stopped.stop || state.stopped.stop) return;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
307
|
+
let pollInterval = sourceObj.sourceConfig?.type === "remote" ? FULL_RESCAN_INTERVAL : FULL_RESCAN_NON_REMOTE_INTERVAL;
|
|
308
|
+
await runInfinitePollCallAtStart(pollInterval, async () => {
|
|
309
|
+
while (!this.store.stopped.stop && !state.stopped.stop) {
|
|
310
|
+
try {
|
|
311
|
+
await this.syncSource(sourceIndex, "push");
|
|
312
|
+
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
logSyncEvent({ event: "scanFailed", store: this.store.folder, source: source.getDebugName(), retryInMs: SCAN_RETRY_DELAY, error: String((e as Error).stack ?? e).slice(0, 2000) });
|
|
315
|
+
await delay(SCAN_RETRY_DELAY);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
state.scanComplete = true;
|
|
319
|
+
state.initialScan.resolve(undefined);
|
|
320
|
+
break;
|
|
315
321
|
}
|
|
316
|
-
}
|
|
322
|
+
}, state.stopped);
|
|
317
323
|
if (state.supportsChangesAfter) {
|
|
318
324
|
runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
|
|
319
325
|
// 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
|
|
@@ -324,10 +330,6 @@ export class StoreSync {
|
|
|
324
330
|
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
325
331
|
}, state.stopped);
|
|
326
332
|
}
|
|
327
|
-
runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
|
|
328
|
-
await this.syncSource(sourceIndex, "push");
|
|
329
|
-
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
330
|
-
}, state.stopped);
|
|
331
333
|
}
|
|
332
334
|
|
|
333
335
|
// 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.
|
|
@@ -383,13 +385,22 @@ export class StoreSync {
|
|
|
383
385
|
tally[this.updateScanIndex(sourceIndex, file)]++;
|
|
384
386
|
}
|
|
385
387
|
state.scannedCount = files.length;
|
|
386
|
-
// Entries this source was the holder of, but that its listing did not mention,
|
|
388
|
+
// Entries this source was the holder of, but that its listing did not mention: our own disk takes over as holder when it has a copy, and only with no local copy either is the entry forgotten - we were wrong about where the file is, which is not the same as it having been deleted. Entries changed after the scan started are kept: the listing may simply predate them. Tombstones are not walked here at all, because they are not files a listing could vouch for.
|
|
387
389
|
let removedFromIndex = 0;
|
|
390
|
+
let repointedToLocal = 0;
|
|
388
391
|
let missingOnSource = 0;
|
|
389
392
|
let scannedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
|
|
390
393
|
for (let [key, entry] of this.store.indexEntries()) {
|
|
391
394
|
if (seen.has(key)) continue;
|
|
392
395
|
if (entry.sourcesListIndex === scannedSourcesListIndex && entry.changedAt < scanStart) {
|
|
396
|
+
if (sourceIndex !== 0) {
|
|
397
|
+
let local = await this.store.sources[0].source.getInfo(key);
|
|
398
|
+
if (local) {
|
|
399
|
+
this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
400
|
+
repointedToLocal++;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
393
404
|
this.store.purgeIndexEntry(key);
|
|
394
405
|
removedFromIndex++;
|
|
395
406
|
continue;
|
|
@@ -399,7 +410,7 @@ export class StoreSync {
|
|
|
399
410
|
if (!routeContains(route, getRoute(key))) continue;
|
|
400
411
|
missingOnSource++;
|
|
401
412
|
}
|
|
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 });
|
|
413
|
+
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, repointedToLocal, removedFromIndex });
|
|
403
414
|
state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
|
|
404
415
|
state.scanSucceeded = true;
|
|
405
416
|
return seen;
|
|
@@ -445,7 +456,7 @@ export class StoreSync {
|
|
|
445
456
|
}
|
|
446
457
|
let holder = await this.store.getEntryHolder(entry);
|
|
447
458
|
if (!holder) continue;
|
|
448
|
-
let copied = await copyArchiveFile({ from: holder, to: source, path: key,
|
|
459
|
+
let copied = await copyArchiveFile({ from: holder, to: source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
|
|
449
460
|
if (!copied) continue;
|
|
450
461
|
this.store.noteSyncTransfer("sync set", key, copied.size);
|
|
451
462
|
pushed++;
|
|
@@ -562,6 +573,23 @@ export class StoreSync {
|
|
|
562
573
|
let copiedBytes = 0;
|
|
563
574
|
let missingOnSource = 0;
|
|
564
575
|
let aborted = false;
|
|
576
|
+
// The source cannot give us the file (its read had nothing, or it errored). Retrying the same entry forever is not an answer: if our own disk holds a copy, IT becomes the holder; with no local copy either, the entry is purged - forgotten, not deleted, exactly like a read that comes up empty everywhere - and the next full pull re-finds it if it exists anywhere.
|
|
577
|
+
let resolveUnavailable = async (event: string, key: string, entry: IndexEntry, extra: { [key: string]: unknown }) => {
|
|
578
|
+
let base = { event, store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, ...extra };
|
|
579
|
+
let local = await this.store.sources[0].source.getInfo(key);
|
|
580
|
+
if (local) {
|
|
581
|
+
this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
582
|
+
logSyncEvent({ ...base, resolution: "repointed to our local copy", localSize: local.size });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (this.store.currentWriteTime(key) <= entry.writeTime) {
|
|
586
|
+
this.store.purgeIndexEntry(key);
|
|
587
|
+
logSyncEvent({ ...base, resolution: "purged - neither the source nor our disk has it, so it does not exist" });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// A newer write landed while we were checking - the entry is no longer the one we found unavailable, so it is left alone
|
|
591
|
+
logSyncEvent({ ...base, resolution: "kept - a newer write appeared while checking", currentWriteTime: this.store.currentWriteTime(key) });
|
|
592
|
+
};
|
|
565
593
|
try {
|
|
566
594
|
let nextIndex = 0;
|
|
567
595
|
let consecutiveFailures = 0;
|
|
@@ -571,7 +599,7 @@ export class StoreSync {
|
|
|
571
599
|
if (index >= pending.length) return;
|
|
572
600
|
let { key, entry } = pending[index];
|
|
573
601
|
try {
|
|
574
|
-
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key,
|
|
602
|
+
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
|
|
575
603
|
if (copied) {
|
|
576
604
|
copiedFiles++;
|
|
577
605
|
copiedBytes += copied.size;
|
|
@@ -582,25 +610,18 @@ export class StoreSync {
|
|
|
582
610
|
}
|
|
583
611
|
} else {
|
|
584
612
|
missingOnSource++;
|
|
585
|
-
|
|
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
|
-
}
|
|
613
|
+
await resolveUnavailable("fullSyncMissingOnSource", key, entry, {});
|
|
597
614
|
}
|
|
598
615
|
consecutiveFailures = 0;
|
|
599
616
|
} catch (e) {
|
|
600
|
-
//
|
|
617
|
+
// A failing file resolves exactly like a missing one (the source cannot give it to us either way), so it never wedges the pass on retries forever - and the next full pull re-finds it if the source recovers
|
|
601
618
|
failed++;
|
|
602
619
|
consecutiveFailures++;
|
|
603
|
-
|
|
620
|
+
try {
|
|
621
|
+
await resolveUnavailable("fullSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
|
|
622
|
+
} catch (resolveError) {
|
|
623
|
+
logSyncEvent({ event: "fullSyncCopyFailed", 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) });
|
|
624
|
+
}
|
|
604
625
|
if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
|
|
605
626
|
aborted = true;
|
|
606
627
|
return;
|