sliftutils 1.7.106 → 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 +7 -8
- package/package.json +1 -1
- package/storage/ArchivesDisk.ts +1 -1
- package/storage/archiveHelpers.d.ts +1 -4
- package/storage/archiveHelpers.ts +6 -13
- package/storage/dist/ArchivesDisk.ts.cache +3 -3
- package/storage/dist/archiveHelpers.ts.cache +9 -12
- package/storage/remoteStorage/dist/storageLogs.ts.cache +26 -17
- package/storage/remoteStorage/dist/storeSync.ts.cache +91 -59
- package/storage/remoteStorage/storageLogs.d.ts +1 -1
- package/storage/remoteStorage/storageLogs.ts +27 -17
- package/storage/remoteStorage/storeSync.d.ts +5 -3
- package/storage/remoteStorage/storeSync.ts +86 -53
|
@@ -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;
|
|
@@ -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 {
|
|
@@ -56,9 +66,9 @@ function write(kind: string, entry: { [key: string]: unknown }, alsoConsole: boo
|
|
|
56
66
|
logs.log({ kind, time: Date.now(), ...baseFields(), ...entry });
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
/** 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. */
|
|
60
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 {
|
|
61
|
-
write("mutation", entry,
|
|
71
|
+
write("mutation", entry, false);
|
|
62
72
|
}
|
|
63
73
|
|
|
64
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,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
|
-
private
|
|
46
|
-
private
|
|
47
|
-
private
|
|
47
|
+
private startSourceSyncLoops;
|
|
48
|
+
private pullSource;
|
|
49
|
+
private pushSource;
|
|
48
50
|
private updateScanIndex;
|
|
49
51
|
private pollChanges;
|
|
50
52
|
private copySourceFiles;
|
|
@@ -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;
|
|
@@ -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 },
|
|
@@ -94,7 +97,7 @@ export class StoreSync {
|
|
|
94
97
|
public start(): void {
|
|
95
98
|
for (let i = 0; i < this.store.sources.length; i++) {
|
|
96
99
|
if (!this.isLive(i)) continue;
|
|
97
|
-
void this.
|
|
100
|
+
void this.startSourceSyncLoops(i);
|
|
98
101
|
}
|
|
99
102
|
runInfinitePoll(CONFIG_POLL_INTERVAL, () => this.pollRoutingConfig(), this.store.stopped);
|
|
100
103
|
runInfinitePoll(TOMBSTONE_CLEANUP_INTERVAL, () => this.cleanupTombstones(), this.store.stopped);
|
|
@@ -144,7 +147,7 @@ export class StoreSync {
|
|
|
144
147
|
public addSource(slot: number): void {
|
|
145
148
|
this.states[slot] = newSourceState();
|
|
146
149
|
if (this.store.syncStarted) {
|
|
147
|
-
void this.
|
|
150
|
+
void this.startSourceSyncLoops(slot);
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -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. */
|
|
@@ -241,7 +255,7 @@ export class StoreSync {
|
|
|
241
255
|
tally.tombstone++;
|
|
242
256
|
continue;
|
|
243
257
|
}
|
|
244
|
-
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 });
|
|
245
259
|
if (!copied) {
|
|
246
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`);
|
|
247
261
|
continue;
|
|
@@ -264,15 +278,15 @@ export class StoreSync {
|
|
|
264
278
|
|
|
265
279
|
// ── per-source loops ──
|
|
266
280
|
|
|
267
|
-
private async
|
|
281
|
+
private async startSourceSyncLoops(sourceIndex: number): Promise<void> {
|
|
268
282
|
await this.store.registerSlot(sourceIndex);
|
|
269
|
-
let
|
|
283
|
+
let sourceObj = this.store.sources[sourceIndex];
|
|
284
|
+
let source = sourceObj.source;
|
|
270
285
|
let state = this.states[sourceIndex];
|
|
271
286
|
// If a slot ever gets TWO of these, its loops are doubled - every poll and full sync runs twice
|
|
272
287
|
logSyncEvent({ event: "sourceSyncStart", store: this.store.folder, source: source.getDebugName(), sourceIndex, url: this.store.sources[sourceIndex].url, intermediate: this.store.sources[sourceIndex].intermediate });
|
|
273
288
|
// Read live for every pass, not captured - the store's source list can change while loops run
|
|
274
289
|
let noFullSync = () => this.store.sources[sourceIndex].noFullSync;
|
|
275
|
-
let listing: Map<string, number> | undefined;
|
|
276
290
|
// 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
291
|
if (this.store.sources[sourceIndex].intermediate) {
|
|
278
292
|
state.scanComplete = true;
|
|
@@ -283,47 +297,43 @@ export class StoreSync {
|
|
|
283
297
|
try {
|
|
284
298
|
let config = await source.getConfig();
|
|
285
299
|
state.supportsChangesAfter = !!config.supportsChangesAfter;
|
|
286
|
-
listing = await this.scanSource(sourceIndex);
|
|
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
|
}
|
|
293
|
-
state.scanComplete = true;
|
|
294
|
-
state.initialScan.resolve(undefined);
|
|
295
306
|
if (this.store.stopped.stop || state.stopped.stop) return;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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;
|
|
304
321
|
}
|
|
305
|
-
}
|
|
322
|
+
}, state.stopped);
|
|
306
323
|
if (state.supportsChangesAfter) {
|
|
307
324
|
runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
|
|
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
|
|
326
|
+
if (!state.scanSucceeded) {
|
|
327
|
+
await this.syncSource(sourceIndex, "push");
|
|
328
|
+
}
|
|
308
329
|
await this.pollChanges(sourceIndex);
|
|
309
330
|
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
310
331
|
}, 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
332
|
}
|
|
323
333
|
}
|
|
324
334
|
|
|
325
|
-
//
|
|
326
|
-
private async
|
|
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.
|
|
336
|
+
private async pullSource(sourceIndex: number): Promise<Map<string, number>> {
|
|
327
337
|
let { source, route } = this.store.sources[sourceIndex];
|
|
328
338
|
let state = this.states[sourceIndex];
|
|
329
339
|
let scanStart = Date.now();
|
|
@@ -375,13 +385,22 @@ export class StoreSync {
|
|
|
375
385
|
tally[this.updateScanIndex(sourceIndex, file)]++;
|
|
376
386
|
}
|
|
377
387
|
state.scannedCount = files.length;
|
|
378
|
-
// 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.
|
|
379
389
|
let removedFromIndex = 0;
|
|
390
|
+
let repointedToLocal = 0;
|
|
380
391
|
let missingOnSource = 0;
|
|
381
392
|
let scannedSourcesListIndex = this.store.sourcesListIndexOfSlot(sourceIndex);
|
|
382
393
|
for (let [key, entry] of this.store.indexEntries()) {
|
|
383
394
|
if (seen.has(key)) continue;
|
|
384
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
|
+
}
|
|
385
404
|
this.store.purgeIndexEntry(key);
|
|
386
405
|
removedFromIndex++;
|
|
387
406
|
continue;
|
|
@@ -391,13 +410,14 @@ export class StoreSync {
|
|
|
391
410
|
if (!routeContains(route, getRoute(key))) continue;
|
|
392
411
|
missingOnSource++;
|
|
393
412
|
}
|
|
394
|
-
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 });
|
|
395
414
|
state.changesAfterTime = Math.max(state.changesAfterTime, scanStart - CHANGES_POLL_OVERLAP);
|
|
415
|
+
state.scanSucceeded = true;
|
|
396
416
|
return seen;
|
|
397
417
|
}
|
|
398
418
|
|
|
399
419
|
// 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
|
|
420
|
+
private async pushSource(sourceIndex: number, listing: Map<string, number>): Promise<void> {
|
|
401
421
|
let { source, validWindows, route } = this.store.sources[sourceIndex];
|
|
402
422
|
let state = this.states[sourceIndex];
|
|
403
423
|
let acceptsWrites = windowsAcceptWrites(validWindows);
|
|
@@ -436,7 +456,7 @@ export class StoreSync {
|
|
|
436
456
|
}
|
|
437
457
|
let holder = await this.store.getEntryHolder(entry);
|
|
438
458
|
if (!holder) continue;
|
|
439
|
-
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 });
|
|
440
460
|
if (!copied) continue;
|
|
441
461
|
this.store.noteSyncTransfer("sync set", key, copied.size);
|
|
442
462
|
pushed++;
|
|
@@ -458,9 +478,7 @@ export class StoreSync {
|
|
|
458
478
|
if (failed) {
|
|
459
479
|
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
480
|
}
|
|
461
|
-
|
|
462
|
-
logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
|
|
463
|
-
}
|
|
481
|
+
logSyncEvent({ event: "reconcileFinish", store: this.store.folder, source: source.getDebugName(), pushed, failed, aborted });
|
|
464
482
|
}
|
|
465
483
|
|
|
466
484
|
private updateScanIndex(sourceIndex: number, file: ArchiveFileInfo): ScanOutcome {
|
|
@@ -555,36 +573,54 @@ export class StoreSync {
|
|
|
555
573
|
let copiedBytes = 0;
|
|
556
574
|
let missingOnSource = 0;
|
|
557
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
|
+
};
|
|
558
593
|
try {
|
|
559
594
|
let nextIndex = 0;
|
|
560
595
|
let consecutiveFailures = 0;
|
|
561
|
-
let errors: string[] = [];
|
|
562
596
|
let copyWorker = async () => {
|
|
563
597
|
while (!aborted && !this.store.stopped.stop && !state.stopped.stop) {
|
|
564
598
|
let index = nextIndex++;
|
|
565
599
|
if (index >= pending.length) return;
|
|
566
600
|
let { key, entry } = pending[index];
|
|
567
601
|
try {
|
|
568
|
-
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 });
|
|
569
603
|
if (copied) {
|
|
570
604
|
copiedFiles++;
|
|
571
605
|
copiedBytes += copied.size;
|
|
572
606
|
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) -
|
|
607
|
+
// 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
608
|
if (!this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) })) {
|
|
575
|
-
|
|
609
|
+
logSyncEvent({ event: "fullSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
|
|
576
610
|
}
|
|
577
611
|
} else {
|
|
578
612
|
missingOnSource++;
|
|
579
|
-
|
|
613
|
+
await resolveUnavailable("fullSyncMissingOnSource", key, entry, {});
|
|
580
614
|
}
|
|
581
615
|
consecutiveFailures = 0;
|
|
582
616
|
} catch (e) {
|
|
583
|
-
//
|
|
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
|
|
584
618
|
failed++;
|
|
585
619
|
consecutiveFailures++;
|
|
586
|
-
|
|
587
|
-
|
|
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) });
|
|
588
624
|
}
|
|
589
625
|
if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
|
|
590
626
|
aborted = true;
|
|
@@ -602,9 +638,6 @@ export class StoreSync {
|
|
|
602
638
|
workers.push(copyWorker());
|
|
603
639
|
}
|
|
604
640
|
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
641
|
} finally {
|
|
609
642
|
clearInterval(progressTimer);
|
|
610
643
|
clearInterval(slowErrorTimer);
|