sliftutils 1.7.107 → 1.7.109
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 -7
- 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 +2 -2
- package/storage/remoteStorage/storeSync.ts +75 -50
|
@@ -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. */
|
|
@@ -37,14 +37,14 @@ 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. */
|
|
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. Only one round per SOURCE runs at a time (cache keys the serializer by source index). */
|
|
41
41
|
private syncSource;
|
|
42
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. */
|
|
43
43
|
boundaryScanRemote(source: IArchives, config: {
|
|
44
44
|
since: number;
|
|
45
45
|
route?: [number, number];
|
|
46
46
|
}): Promise<void>;
|
|
47
|
-
private
|
|
47
|
+
private startSourceSyncLoops;
|
|
48
48
|
private pullSource;
|
|
49
49
|
private pushSource;
|
|
50
50
|
private updateScanIndex;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { runInfinitePoll, delay } from "socket-function/src/batching";
|
|
1
|
+
import { runInfinitePoll, delay, runInfinitePollCallAtStart, runInSerial } from "socket-function/src/batching";
|
|
2
|
+
import { cache } from "socket-function/src/caching";
|
|
2
3
|
import { timeInMinute, sort, promiseObj } from "socket-function/src/misc";
|
|
3
4
|
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
4
5
|
import {
|
|
@@ -21,7 +22,7 @@ const CHANGES_POLL_INTERVAL = 1000 * 60;
|
|
|
21
22
|
const CONFIG_POLL_INTERVAL = 1000 * 60 * 5;
|
|
22
23
|
// 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
24
|
const FULL_RESCAN_INTERVAL = 1000 * 60 * 60;
|
|
24
|
-
const
|
|
25
|
+
const FULL_RESCAN_NON_REMOTE_INTERVAL = 1000 * 60 * 60 * 6;
|
|
25
26
|
// Change polls re-request this much overlap, so clock skew between us and a source can't drop changes
|
|
26
27
|
const CHANGES_POLL_OVERLAP = timeInMinute;
|
|
27
28
|
const SCAN_RETRY_DELAY = 1000 * 30;
|
|
@@ -97,7 +98,7 @@ export class StoreSync {
|
|
|
97
98
|
public start(): void {
|
|
98
99
|
for (let i = 0; i < this.store.sources.length; i++) {
|
|
99
100
|
if (!this.isLive(i)) continue;
|
|
100
|
-
void this.
|
|
101
|
+
void this.startSourceSyncLoops(i);
|
|
101
102
|
}
|
|
102
103
|
runInfinitePoll(CONFIG_POLL_INTERVAL, () => this.pollRoutingConfig(), this.store.stopped);
|
|
103
104
|
runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.store.stopped);
|
|
@@ -147,7 +148,7 @@ export class StoreSync {
|
|
|
147
148
|
public addSource(slot: number): void {
|
|
148
149
|
this.states[slot] = newSourceState();
|
|
149
150
|
if (this.store.syncStarted) {
|
|
150
|
-
void this.
|
|
151
|
+
void this.startSourceSyncLoops(slot);
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
154
|
|
|
@@ -219,11 +220,11 @@ export class StoreSync {
|
|
|
219
220
|
|
|
220
221
|
/** 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. */
|
|
221
222
|
public async rescanBase(): Promise<void> {
|
|
222
|
-
await this.syncSource(0);
|
|
223
|
+
await this.syncSource(0)();
|
|
223
224
|
}
|
|
224
225
|
|
|
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
|
|
226
|
+
/** 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. Only one round per SOURCE runs at a time (cache keys the serializer by source index). */
|
|
227
|
+
private syncSource = cache((sourceIndex: number) => runInSerial(async (push?: "push"): Promise<void> => {
|
|
227
228
|
let listing = await this.pullSource(sourceIndex);
|
|
228
229
|
let state = this.states[sourceIndex];
|
|
229
230
|
state.scanComplete = true;
|
|
@@ -231,7 +232,7 @@ export class StoreSync {
|
|
|
231
232
|
if (push && !this.store.stopped.stop && !state.stopped.stop) {
|
|
232
233
|
await this.pushSource(sourceIndex, listing);
|
|
233
234
|
}
|
|
234
|
-
}
|
|
235
|
+
}));
|
|
235
236
|
|
|
236
237
|
/** 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. */
|
|
237
238
|
public async boundaryScanRemote(source: IArchives, config: { since: number; route?: [number, number] }): Promise<void> {
|
|
@@ -255,7 +256,7 @@ export class StoreSync {
|
|
|
255
256
|
tally.tombstone++;
|
|
256
257
|
continue;
|
|
257
258
|
}
|
|
258
|
-
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path,
|
|
259
|
+
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: file.path, forceSetImmutable: true, noChecks: true, internal: true });
|
|
259
260
|
if (!copied) {
|
|
260
261
|
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
262
|
continue;
|
|
@@ -278,9 +279,10 @@ export class StoreSync {
|
|
|
278
279
|
|
|
279
280
|
// ── per-source loops ──
|
|
280
281
|
|
|
281
|
-
private async
|
|
282
|
+
private async startSourceSyncLoops(sourceIndex: number): Promise<void> {
|
|
282
283
|
await this.store.registerSlot(sourceIndex);
|
|
283
|
-
let
|
|
284
|
+
let sourceObj = this.store.sources[sourceIndex];
|
|
285
|
+
let source = sourceObj.source;
|
|
284
286
|
let state = this.states[sourceIndex];
|
|
285
287
|
// If a slot ever gets TWO of these, its loops are doubled - every poll and full sync runs twice
|
|
286
288
|
logSyncEvent({ event: "sourceSyncStart", store: this.store.folder, source: source.getDebugName(), sourceIndex, url: this.store.sources[sourceIndex].url, intermediate: this.store.sources[sourceIndex].intermediate });
|
|
@@ -296,38 +298,42 @@ export class StoreSync {
|
|
|
296
298
|
try {
|
|
297
299
|
let config = await source.getConfig();
|
|
298
300
|
state.supportsChangesAfter = !!config.supportsChangesAfter;
|
|
299
|
-
await this.syncSource(sourceIndex, "push");
|
|
300
301
|
break;
|
|
301
302
|
} catch (e) {
|
|
302
303
|
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
304
|
await delay(SCAN_RETRY_DELAY);
|
|
304
305
|
}
|
|
305
306
|
}
|
|
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
307
|
if (this.store.stopped.stop || state.stopped.stop) return;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
308
|
+
// Hourly for our own disk (no sourceConfig - a cheap local walk, and how a sibling process's writes into the shared folder are found) and for remote peers (index-backed, cheap listings); the slow interval is only for sources where a full listing is genuinely expensive (backblaze)
|
|
309
|
+
let pollInterval = (!sourceObj.sourceConfig || sourceObj.sourceConfig.type === "remote") && FULL_RESCAN_INTERVAL || FULL_RESCAN_NON_REMOTE_INTERVAL;
|
|
310
|
+
// 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
|
+
let serial = runInSerial(async (fnc: () => Promise<void>) => await fnc());
|
|
312
|
+
await runInfinitePollCallAtStart(pollInterval, () => serial(async () => {
|
|
313
|
+
while (!this.store.stopped.stop && !state.stopped.stop) {
|
|
314
|
+
try {
|
|
315
|
+
await this.syncSource(sourceIndex)("push");
|
|
316
|
+
if (!noFullSync()) await this.copySourceFiles(sourceIndex)();
|
|
317
|
+
} catch (e) {
|
|
318
|
+
logSyncEvent({ event: "scanFailed", store: this.store.folder, source: source.getDebugName(), retryInMs: SCAN_RETRY_DELAY, error: String((e as Error).stack ?? e).slice(0, 2000) });
|
|
319
|
+
await delay(SCAN_RETRY_DELAY);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
state.scanComplete = true;
|
|
323
|
+
state.initialScan.resolve(undefined);
|
|
324
|
+
break;
|
|
315
325
|
}
|
|
316
|
-
}
|
|
326
|
+
}), state.stopped);
|
|
317
327
|
if (state.supportsChangesAfter) {
|
|
318
|
-
runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
|
|
328
|
+
runInfinitePoll(CHANGES_POLL_INTERVAL, () => serial(async () => {
|
|
319
329
|
// 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
330
|
if (!state.scanSucceeded) {
|
|
321
|
-
await this.syncSource(sourceIndex
|
|
331
|
+
await this.syncSource(sourceIndex)("push");
|
|
322
332
|
}
|
|
323
333
|
await this.pollChanges(sourceIndex);
|
|
324
|
-
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
325
|
-
}, state.stopped);
|
|
334
|
+
if (!noFullSync()) await this.copySourceFiles(sourceIndex)();
|
|
335
|
+
}), state.stopped);
|
|
326
336
|
}
|
|
327
|
-
runInfinitePoll(FULL_RESCAN_UNINDEXED_INTERVAL, async () => {
|
|
328
|
-
await this.syncSource(sourceIndex, "push");
|
|
329
|
-
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
330
|
-
}, state.stopped);
|
|
331
337
|
}
|
|
332
338
|
|
|
333
339
|
// 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 +389,22 @@ export class StoreSync {
|
|
|
383
389
|
tally[this.updateScanIndex(sourceIndex, file)]++;
|
|
384
390
|
}
|
|
385
391
|
state.scannedCount = files.length;
|
|
386
|
-
// Entries this source was the holder of, but that its listing did not mention,
|
|
392
|
+
// 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
393
|
let removedFromIndex = 0;
|
|
394
|
+
let repointedToLocal = 0;
|
|
388
395
|
let missingOnSource = 0;
|
|
389
396
|
let scannedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
|
|
390
397
|
for (let [key, entry] of this.store.indexEntries()) {
|
|
391
398
|
if (seen.has(key)) continue;
|
|
392
399
|
if (entry.sourcesListIndex === scannedSourcesListIndex && entry.changedAt < scanStart) {
|
|
400
|
+
if (sourceIndex !== 0) {
|
|
401
|
+
let local = await this.store.sources[0].source.getInfo(key);
|
|
402
|
+
if (local) {
|
|
403
|
+
this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
404
|
+
repointedToLocal++;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
393
408
|
this.store.purgeIndexEntry(key);
|
|
394
409
|
removedFromIndex++;
|
|
395
410
|
continue;
|
|
@@ -399,7 +414,7 @@ export class StoreSync {
|
|
|
399
414
|
if (!routeContains(route, getRoute(key))) continue;
|
|
400
415
|
missingOnSource++;
|
|
401
416
|
}
|
|
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 });
|
|
417
|
+
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
418
|
state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
|
|
404
419
|
state.scanSucceeded = true;
|
|
405
420
|
return seen;
|
|
@@ -445,7 +460,7 @@ export class StoreSync {
|
|
|
445
460
|
}
|
|
446
461
|
let holder = await this.store.getEntryHolder(entry);
|
|
447
462
|
if (!holder) continue;
|
|
448
|
-
let copied = await copyArchiveFile({ from: holder, to: source, path: key,
|
|
463
|
+
let copied = await copyArchiveFile({ from: holder, to: source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
|
|
449
464
|
if (!copied) continue;
|
|
450
465
|
this.store.noteSyncTransfer("sync set", key, copied.size);
|
|
451
466
|
pushed++;
|
|
@@ -511,8 +526,8 @@ export class StoreSync {
|
|
|
511
526
|
state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
|
|
512
527
|
}
|
|
513
528
|
|
|
514
|
-
// Downloads the files a source currently holds onto our own base source (the local disk), preserving their modified times — so a newer local write always wins. Skipped for noFullSync sources (fronting a large database without copying it); reads still down-cache lazily.
|
|
515
|
-
private
|
|
529
|
+
// Downloads the files a source currently holds onto our own base source (the local disk), preserving their modified times — so a newer local write always wins. Skipped for noFullSync sources (fronting a large database without copying it); reads still down-cache lazily. Only one pass per SOURCE runs at a time (cache keys the serializer by source index) - two passes over the same pending list would download everything twice.
|
|
530
|
+
private copySourceFiles = cache((sourceIndex: number) => runInSerial(async (): Promise<void> => {
|
|
516
531
|
if (sourceIndex === 0) return;
|
|
517
532
|
let { source } = this.store.sources[sourceIndex];
|
|
518
533
|
let state = this.states[sourceIndex];
|
|
@@ -562,6 +577,23 @@ export class StoreSync {
|
|
|
562
577
|
let copiedBytes = 0;
|
|
563
578
|
let missingOnSource = 0;
|
|
564
579
|
let aborted = false;
|
|
580
|
+
// 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.
|
|
581
|
+
let resolveUnavailable = async (event: string, key: string, entry: IndexEntry, extra: { [key: string]: unknown }) => {
|
|
582
|
+
let base = { event, store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, ...extra };
|
|
583
|
+
let local = await this.store.sources[0].source.getInfo(key);
|
|
584
|
+
if (local) {
|
|
585
|
+
this.store.setIndexEntry(key, { writeTime: entry.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
586
|
+
logSyncEvent({ ...base, resolution: "repointed to our local copy", localSize: local.size });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (this.store.currentWriteTime(key) <= entry.writeTime) {
|
|
590
|
+
this.store.purgeIndexEntry(key);
|
|
591
|
+
logSyncEvent({ ...base, resolution: "purged - neither the source nor our disk has it, so it does not exist" });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// A newer write landed while we were checking - the entry is no longer the one we found unavailable, so it is left alone
|
|
595
|
+
logSyncEvent({ ...base, resolution: "kept - a newer write appeared while checking", currentWriteTime: this.store.currentWriteTime(key) });
|
|
596
|
+
};
|
|
565
597
|
try {
|
|
566
598
|
let nextIndex = 0;
|
|
567
599
|
let consecutiveFailures = 0;
|
|
@@ -571,7 +603,7 @@ export class StoreSync {
|
|
|
571
603
|
if (index >= pending.length) return;
|
|
572
604
|
let { key, entry } = pending[index];
|
|
573
605
|
try {
|
|
574
|
-
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key,
|
|
606
|
+
let copied = await copyArchiveFile({ from: source, to: this.store.sources[0].source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
|
|
575
607
|
if (copied) {
|
|
576
608
|
copiedFiles++;
|
|
577
609
|
copiedBytes += copied.size;
|
|
@@ -582,25 +614,18 @@ export class StoreSync {
|
|
|
582
614
|
}
|
|
583
615
|
} else {
|
|
584
616
|
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
|
-
}
|
|
617
|
+
await resolveUnavailable("fullSyncMissingOnSource", key, entry, {});
|
|
597
618
|
}
|
|
598
619
|
consecutiveFailures = 0;
|
|
599
620
|
} catch (e) {
|
|
600
|
-
//
|
|
621
|
+
// 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
622
|
failed++;
|
|
602
623
|
consecutiveFailures++;
|
|
603
|
-
|
|
624
|
+
try {
|
|
625
|
+
await resolveUnavailable("fullSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
|
|
626
|
+
} catch (resolveError) {
|
|
627
|
+
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) });
|
|
628
|
+
}
|
|
604
629
|
if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
|
|
605
630
|
aborted = true;
|
|
606
631
|
return;
|
|
@@ -627,7 +652,7 @@ export class StoreSync {
|
|
|
627
652
|
}
|
|
628
653
|
logSyncEvent({ event: "fullSyncFinish", 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 });
|
|
629
654
|
}
|
|
630
|
-
}
|
|
655
|
+
}));
|
|
631
656
|
|
|
632
657
|
// ── maintenance ──
|
|
633
658
|
|