sliftutils 1.7.108 → 1.7.110
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 -5
- 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 +183 -161
- package/storage/remoteStorage/storageLogs.d.ts +6 -3
- package/storage/remoteStorage/storageLogs.ts +8 -2
- package/storage/remoteStorage/storeSync.d.ts +1 -1
- package/storage/remoteStorage/storeSync.ts +51 -28
|
@@ -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(() => {
|
|
@@ -37,7 +37,7 @@ 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: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { runInfinitePoll, delay, runInfinitePollCallAtStart } 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 {
|
|
@@ -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> {
|
|
@@ -257,6 +258,14 @@ export class StoreSync {
|
|
|
257
258
|
}
|
|
258
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) {
|
|
261
|
+
// 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
|
|
262
|
+
let local = await this.store.sources[0].source.getInfo(file.path);
|
|
263
|
+
if (local && local.writeTime > file.createTime) {
|
|
264
|
+
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 });
|
|
265
|
+
this.store.setIndexEntry(file.path, { writeTime: local.writeTime, size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
266
|
+
tally.unchanged++;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
260
269
|
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
270
|
continue;
|
|
262
271
|
}
|
|
@@ -304,12 +313,15 @@ export class StoreSync {
|
|
|
304
313
|
}
|
|
305
314
|
}
|
|
306
315
|
if (this.store.stopped.stop || state.stopped.stop) return;
|
|
307
|
-
|
|
308
|
-
|
|
316
|
+
// 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)
|
|
317
|
+
let pollInterval = (!sourceObj.sourceConfig || sourceObj.sourceConfig.type === "remote") && FULL_RESCAN_INTERVAL || FULL_RESCAN_NON_REMOTE_INTERVAL;
|
|
318
|
+
// 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
|
|
319
|
+
let serial = runInSerial(async (fnc: () => Promise<void>) => await fnc());
|
|
320
|
+
await runInfinitePollCallAtStart(pollInterval, () => serial(async () => {
|
|
309
321
|
while (!this.store.stopped.stop && !state.stopped.stop) {
|
|
310
322
|
try {
|
|
311
|
-
await this.syncSource(sourceIndex
|
|
312
|
-
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
323
|
+
await this.syncSource(sourceIndex)("push");
|
|
324
|
+
if (!noFullSync()) await this.copySourceFiles(sourceIndex)();
|
|
313
325
|
} catch (e) {
|
|
314
326
|
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
327
|
await delay(SCAN_RETRY_DELAY);
|
|
@@ -319,16 +331,16 @@ export class StoreSync {
|
|
|
319
331
|
state.initialScan.resolve(undefined);
|
|
320
332
|
break;
|
|
321
333
|
}
|
|
322
|
-
}, state.stopped);
|
|
334
|
+
}), state.stopped);
|
|
323
335
|
if (state.supportsChangesAfter) {
|
|
324
|
-
runInfinitePoll(CHANGES_POLL_INTERVAL, async () => {
|
|
336
|
+
runInfinitePoll(CHANGES_POLL_INTERVAL, () => serial(async () => {
|
|
325
337
|
// 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
338
|
if (!state.scanSucceeded) {
|
|
327
|
-
await this.syncSource(sourceIndex
|
|
339
|
+
await this.syncSource(sourceIndex)("push");
|
|
328
340
|
}
|
|
329
341
|
await this.pollChanges(sourceIndex);
|
|
330
|
-
if (!noFullSync()) await this.copySourceFiles(sourceIndex);
|
|
331
|
-
}, state.stopped);
|
|
342
|
+
if (!noFullSync()) await this.copySourceFiles(sourceIndex)();
|
|
343
|
+
}), state.stopped);
|
|
332
344
|
}
|
|
333
345
|
}
|
|
334
346
|
|
|
@@ -457,7 +469,17 @@ export class StoreSync {
|
|
|
457
469
|
let holder = await this.store.getEntryHolder(entry);
|
|
458
470
|
if (!holder) continue;
|
|
459
471
|
let copied = await copyArchiveFile({ from: holder, to: source, path: key, forceSetImmutable: true, noChecks: true, internal: true });
|
|
460
|
-
if (!copied)
|
|
472
|
+
if (!copied) {
|
|
473
|
+
// 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.
|
|
474
|
+
let theirs = await source.getInfo(key);
|
|
475
|
+
if (theirs && theirs.writeTime > writeTime) {
|
|
476
|
+
this.store.setIndexEntry(key, { writeTime: theirs.writeTime, size: theirs.size, sourcesListIndex: targetSourcesListIndex });
|
|
477
|
+
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 });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
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" });
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
461
483
|
this.store.noteSyncTransfer("sync set", key, copied.size);
|
|
462
484
|
pushed++;
|
|
463
485
|
consecutiveFailures = 0;
|
|
@@ -522,8 +544,8 @@ export class StoreSync {
|
|
|
522
544
|
state.changesAfterTime = pollStart - CHANGES_POLL_OVERLAP;
|
|
523
545
|
}
|
|
524
546
|
|
|
525
|
-
// 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.
|
|
526
|
-
private
|
|
547
|
+
// 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.
|
|
548
|
+
private copySourceFiles = cache((sourceIndex: number) => runInSerial(async (): Promise<void> => {
|
|
527
549
|
if (sourceIndex === 0) return;
|
|
528
550
|
let { source } = this.store.sources[sourceIndex];
|
|
529
551
|
let state = this.states[sourceIndex];
|
|
@@ -546,11 +568,11 @@ export class StoreSync {
|
|
|
546
568
|
totalBytes,
|
|
547
569
|
};
|
|
548
570
|
this.activities.add(activity);
|
|
549
|
-
logSyncEvent({ event: "
|
|
571
|
+
logSyncEvent({ event: "deltaSyncStart", store: this.store.folder, source: source.getDebugName(), diffBased: state.supportsChangesAfter, files: pending.length, bytes: totalBytes });
|
|
550
572
|
let progressLogged = false;
|
|
551
573
|
let logProgress = () => {
|
|
552
574
|
progressLogged = true;
|
|
553
|
-
console.log(magenta(`
|
|
575
|
+
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)}%)`));
|
|
554
576
|
};
|
|
555
577
|
let progressTimer = setInterval(logProgress, SYNC_PROGRESS_LOG_INTERVAL);
|
|
556
578
|
(progressTimer as { unref?: () => void }).unref?.();
|
|
@@ -565,7 +587,7 @@ export class StoreSync {
|
|
|
565
587
|
let remainingMs = remainingBytes / bytesPerSecond * 1000;
|
|
566
588
|
etaText = `${formatTime(remainingMs)} remaining, completing around ${new Date(Date.now() + remainingMs).toISOString()}`;
|
|
567
589
|
}
|
|
568
|
-
console.warn(`
|
|
590
|
+
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}.`);
|
|
569
591
|
}, FULL_SYNC_SLOW_ERROR_INTERVAL);
|
|
570
592
|
(slowErrorTimer as { unref?: () => void }).unref?.();
|
|
571
593
|
let failed = 0;
|
|
@@ -578,8 +600,9 @@ export class StoreSync {
|
|
|
578
600
|
let base = { event, store: this.store.folder, source: source.getDebugName(), path: key, expectedSize: entry.size, expectedWriteTime: entry.writeTime, ...extra };
|
|
579
601
|
let local = await this.store.sources[0].source.getInfo(key);
|
|
580
602
|
if (local) {
|
|
581
|
-
|
|
582
|
-
|
|
603
|
+
// 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
|
|
604
|
+
this.store.setIndexEntry(key, { writeTime: Math.max(entry.writeTime, local.writeTime), size: local.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) });
|
|
605
|
+
logSyncEvent({ ...base, resolution: "repointed to our local copy", localSize: local.size, localWriteTime: new Date(local.writeTime).toISOString() });
|
|
583
606
|
return;
|
|
584
607
|
}
|
|
585
608
|
if (this.store.currentWriteTime(key) <= entry.writeTime) {
|
|
@@ -606,11 +629,11 @@ export class StoreSync {
|
|
|
606
629
|
this.store.noteSyncTransfer("sync get", key, copied.size);
|
|
607
630
|
// 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
|
|
608
631
|
if (!this.store.setIndexEntry(key, { writeTime: copied.writeTime, size: copied.size, sourcesListIndex: this.store.sourcesListIndexOfSlot(0) })) {
|
|
609
|
-
logSyncEvent({ event: "
|
|
632
|
+
logSyncEvent({ event: "deltaSyncCommitSuperseded", store: this.store.folder, source: source.getDebugName(), path: key, copiedWriteTime: copied.writeTime, currentWriteTime: this.store.currentWriteTime(key) });
|
|
610
633
|
}
|
|
611
634
|
} else {
|
|
612
635
|
missingOnSource++;
|
|
613
|
-
await resolveUnavailable("
|
|
636
|
+
await resolveUnavailable("deltaSyncMissingOnSource", key, entry, {});
|
|
614
637
|
}
|
|
615
638
|
consecutiveFailures = 0;
|
|
616
639
|
} catch (e) {
|
|
@@ -618,9 +641,9 @@ export class StoreSync {
|
|
|
618
641
|
failed++;
|
|
619
642
|
consecutiveFailures++;
|
|
620
643
|
try {
|
|
621
|
-
await resolveUnavailable("
|
|
644
|
+
await resolveUnavailable("deltaSyncCopyFailed", key, entry, { error: String((e as Error).stack ?? e).slice(0, 2000) });
|
|
622
645
|
} catch (resolveError) {
|
|
623
|
-
logSyncEvent({ event: "
|
|
646
|
+
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) });
|
|
624
647
|
}
|
|
625
648
|
if (consecutiveFailures >= SYNC_MAX_CONSECUTIVE_FAILURES) {
|
|
626
649
|
aborted = true;
|
|
@@ -646,9 +669,9 @@ export class StoreSync {
|
|
|
646
669
|
if (progressLogged) {
|
|
647
670
|
logProgress();
|
|
648
671
|
}
|
|
649
|
-
logSyncEvent({ event: "
|
|
672
|
+
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 });
|
|
650
673
|
}
|
|
651
|
-
}
|
|
674
|
+
}));
|
|
652
675
|
|
|
653
676
|
// ── maintenance ──
|
|
654
677
|
|