@indigoai-us/hq-cloud 6.15.64 → 6.15.66
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/dist/bin/sync-runner-watch-loop.d.ts +41 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +466 -72
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +8 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +686 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +4 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +2 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +8 -1
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +20 -8
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +24 -0
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/watcher.d.ts +35 -2
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +213 -3
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +120 -7
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@ import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
|
|
|
12
12
|
import { getOrCreateMachineId } from "../lib/machine-id.js";
|
|
13
13
|
import { localPathForVaultKey } from "../local-path-codec.js";
|
|
14
14
|
import { TreeWatcher, resolveEventDebounceConfig, systemClock, } from "../watcher.js";
|
|
15
|
-
import { clearLocalDeleteIntent, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, openJournalStoreSession, readJournal, writeJournal, } from "../journal.js";
|
|
15
|
+
import { clearLocalDeleteIntent, getStateDir, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, openJournalStoreSession, listJournals, readJournal, writeJournal, } from "../journal.js";
|
|
16
16
|
import { NoopPushReceiver, } from "../sync/push-receiver.js";
|
|
17
17
|
import { resolveEventSync, startEventSync as defaultStartEventSync, } from "../sync/event-sync.js";
|
|
18
18
|
import { buildRoutePullArgv, buildScopedPushArgv, buildScopedDrainPullArgv, buildTargetedPullArgv, isWatchRouteSelected, routeChangeToTarget, routeKey, } from "./sync-runner-watch-routes.js";
|
|
@@ -44,6 +44,132 @@ function emitFullReconcileDecision({ mode, reasons, triggers, pollTick, configur
|
|
|
44
44
|
*/
|
|
45
45
|
export const DEFAULT_EVENT_BATCH_LIMIT = 10_000;
|
|
46
46
|
export const EVENT_BATCH_LIMIT_ENV = "HQ_SYNC_EVENT_BATCH_LIMIT";
|
|
47
|
+
/**
|
|
48
|
+
* Fast-lane admission is deliberately conservative: a normal editor save is
|
|
49
|
+
* a handful of files and well below 4 MiB, whereas session-log catch-up is
|
|
50
|
+
* neither. Operators may tune both limits without changing scheduling
|
|
51
|
+
* semantics. Invalid values retain the safe defaults.
|
|
52
|
+
*/
|
|
53
|
+
export const FAST_LANE_MAX_FILES_ENV = "HQ_SYNC_FAST_LANE_MAX_FILES";
|
|
54
|
+
export const FAST_LANE_MAX_BYTES_ENV = "HQ_SYNC_FAST_LANE_MAX_BYTES";
|
|
55
|
+
export const FAST_LANE_MAX_FILE_BYTES_ENV = "HQ_SYNC_FAST_LANE_MAX_FILE_BYTES";
|
|
56
|
+
export const DEFAULT_FAST_LANE_MAX_FILES = 32;
|
|
57
|
+
export const DEFAULT_FAST_LANE_MAX_BYTES = 4 * 1024 * 1024;
|
|
58
|
+
/**
|
|
59
|
+
* A per-file ceiling keeps a single medium-sized transfer from consuming the
|
|
60
|
+
* realtime lane even when the batch aggregate is otherwise below its cap.
|
|
61
|
+
* 256 KiB comfortably covers ordinary notes and control-plane artifacts while
|
|
62
|
+
* leaving session logs and other transfer-heavy files in the slow lane.
|
|
63
|
+
*/
|
|
64
|
+
export const DEFAULT_FAST_LANE_MAX_FILE_BYTES = 256 * 1024;
|
|
65
|
+
function positiveEnvInt(name, fallback) {
|
|
66
|
+
const value = Number.parseInt(process.env[name] ?? "", 10);
|
|
67
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
68
|
+
}
|
|
69
|
+
export function resolveFastLaneLimits() {
|
|
70
|
+
return {
|
|
71
|
+
maxFiles: positiveEnvInt(FAST_LANE_MAX_FILES_ENV, DEFAULT_FAST_LANE_MAX_FILES),
|
|
72
|
+
maxBytes: positiveEnvInt(FAST_LANE_MAX_BYTES_ENV, DEFAULT_FAST_LANE_MAX_BYTES),
|
|
73
|
+
maxFileBytes: positiveEnvInt(FAST_LANE_MAX_FILE_BYTES_ENV, DEFAULT_FAST_LANE_MAX_FILE_BYTES),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Select a bounded realtime prefix from already coalesced work. A path appears
|
|
78
|
+
* exactly once in this input, so a later change to the same path has already
|
|
79
|
+
* replaced its earlier revision and cannot straddle lanes.
|
|
80
|
+
*/
|
|
81
|
+
function partitionByKnownSizes(items, sizeOf) {
|
|
82
|
+
const { maxFiles, maxBytes, maxFileBytes } = resolveFastLaneLimits();
|
|
83
|
+
const fast = [];
|
|
84
|
+
const slow = [];
|
|
85
|
+
let bytes = 0;
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
const size = sizeOf(item);
|
|
88
|
+
if (size === null ||
|
|
89
|
+
size > maxFileBytes ||
|
|
90
|
+
fast.length >= maxFiles ||
|
|
91
|
+
bytes + size > maxBytes) {
|
|
92
|
+
slow.push(item);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
fast.push(item);
|
|
96
|
+
bytes += size;
|
|
97
|
+
}
|
|
98
|
+
return { fast, slow };
|
|
99
|
+
}
|
|
100
|
+
function localPathSize(absolutePath, changes) {
|
|
101
|
+
// `unlinkDir` has an unbounded journal expansion; never guess its payload.
|
|
102
|
+
if (changes?.get(absolutePath)?.kind === "unlinkDir")
|
|
103
|
+
return null;
|
|
104
|
+
try {
|
|
105
|
+
const stat = fs.lstatSync(absolutePath);
|
|
106
|
+
if (stat.isDirectory())
|
|
107
|
+
return null;
|
|
108
|
+
return stat.isFile() ? stat.size : 0;
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// A missing file is an exact delete and has no content transfer. Any other
|
|
112
|
+
// stat error leaves its payload unknown and therefore slow.
|
|
113
|
+
return err.code === "ENOENT" ? 0 : null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Partition one selected watcher batch. The returned batches retain the same
|
|
118
|
+
* route/filter metadata, but no latest path can appear in both lane maps.
|
|
119
|
+
*/
|
|
120
|
+
export function partitionLocalBatch(batch) {
|
|
121
|
+
const partition = partitionByKnownSizes([...batch.paths.entries()], ([absolutePath]) => localPathSize(absolutePath, batch.changes));
|
|
122
|
+
const makeBatch = (entries) => {
|
|
123
|
+
if (entries.length === 0)
|
|
124
|
+
return null;
|
|
125
|
+
const paths = new Map(entries);
|
|
126
|
+
const changes = new Map();
|
|
127
|
+
for (const [absolutePath] of entries) {
|
|
128
|
+
const change = batch.changes?.get(absolutePath);
|
|
129
|
+
if (change)
|
|
130
|
+
changes.set(absolutePath, change);
|
|
131
|
+
}
|
|
132
|
+
return { ...batch, paths, ...(changes.size > 0 ? { changes } : {}) };
|
|
133
|
+
};
|
|
134
|
+
return { fast: makeBatch(partition.fast), slow: makeBatch(partition.slow) };
|
|
135
|
+
}
|
|
136
|
+
/** A missing/stat-failed path is never allowed to bypass the slow lane. */
|
|
137
|
+
export function isFastLocalBatch(paths, changes) {
|
|
138
|
+
const { maxFiles, maxBytes } = resolveFastLaneLimits();
|
|
139
|
+
let files = 0;
|
|
140
|
+
let bytes = 0;
|
|
141
|
+
for (const absolutePath of paths) {
|
|
142
|
+
files += 1;
|
|
143
|
+
if (files > maxFiles)
|
|
144
|
+
return false;
|
|
145
|
+
// `unlinkDir` arrives after its path is gone. Its scoped push expands the
|
|
146
|
+
// journal subtree through deleteScopeRoots, so its payload cannot be
|
|
147
|
+
// bounded by the absent directory's lstat result.
|
|
148
|
+
if (changes?.get(absolutePath)?.kind === "unlinkDir")
|
|
149
|
+
return false;
|
|
150
|
+
try {
|
|
151
|
+
const stat = fs.lstatSync(absolutePath);
|
|
152
|
+
// A directory-scoped operation may expand to an arbitrary payload. Do
|
|
153
|
+
// not guess: it belongs in the slow lane.
|
|
154
|
+
if (stat.isDirectory())
|
|
155
|
+
return false;
|
|
156
|
+
bytes += stat.isFile() ? stat.size : 0;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
// Deletes are zero-byte operations; every other stat failure is unknown
|
|
160
|
+
// payload and must retain normal serialization.
|
|
161
|
+
if (err.code !== "ENOENT")
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (bytes > maxBytes)
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return files > 0;
|
|
168
|
+
}
|
|
169
|
+
/** A receiver event needs a known small upsert size before it can go realtime. */
|
|
170
|
+
export function isFastReceiverBatch(eventCount) {
|
|
171
|
+
return eventCount > 0 && eventCount <= resolveFastLaneLimits().maxFiles;
|
|
172
|
+
}
|
|
47
173
|
export function resolveEventBatchLimit(env = process.env) {
|
|
48
174
|
const raw = env[EVENT_BATCH_LIMIT_ENV];
|
|
49
175
|
if (raw === undefined || raw.trim() === "")
|
|
@@ -84,6 +210,8 @@ export function resolveFullReconcileMs(env = process.env) {
|
|
|
84
210
|
const ADAPTIVE_POLL_FLOOR_MS = 60_000;
|
|
85
211
|
const ADAPTIVE_POLL_CEIL_MS = 600_000;
|
|
86
212
|
const WATCH_IDLE_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
213
|
+
/** Covers coarse timestamp filesystems and scheduling just before watcher start. */
|
|
214
|
+
const WATCHER_WARMUP_SAFETY_MARGIN_MS = 2_000;
|
|
87
215
|
/** Initial poll-only recovery delay after a watcher conclusively stands down. */
|
|
88
216
|
export const WATCHER_REDISCOVERY_RETRY_MS = 5 * 60_000;
|
|
89
217
|
/** Ceiling for exponential retry delays after consecutive watcher stand-downs. */
|
|
@@ -222,6 +350,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
222
350
|
skipPersonalEnv === "yes",
|
|
223
351
|
};
|
|
224
352
|
const hqRoot = parsed.hqRoot;
|
|
353
|
+
const serviceStartedAt = Date.now();
|
|
225
354
|
// Delayed and daily: this must never be attached to the 15s poll cadence.
|
|
226
355
|
// Stage 0 remains dry-run only; the standalone hq-backup-prune command is
|
|
227
356
|
// the explicitly-gated apply surface.
|
|
@@ -285,7 +414,47 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
285
414
|
throw err;
|
|
286
415
|
}
|
|
287
416
|
}
|
|
288
|
-
|
|
417
|
+
// `activeSlowPass` is assigned before its task has necessarily acquired the
|
|
418
|
+
// cross-process lock (it may be waiting behind a rescue or a second runner).
|
|
419
|
+
// Fast work may bypass the lock only while this process *actually owns* it;
|
|
420
|
+
// otherwise it must participate in normal lock acquisition.
|
|
421
|
+
let processOwnsOperationLock = false;
|
|
422
|
+
let operationLockUsers = 0;
|
|
423
|
+
let resolveNoOperationLockUsers = null;
|
|
424
|
+
const acquireOperationLockUser = () => {
|
|
425
|
+
operationLockUsers += 1;
|
|
426
|
+
};
|
|
427
|
+
const releaseOperationLockUser = () => {
|
|
428
|
+
operationLockUsers -= 1;
|
|
429
|
+
if (operationLockUsers !== 0)
|
|
430
|
+
return;
|
|
431
|
+
const resolve = resolveNoOperationLockUsers;
|
|
432
|
+
resolveNoOperationLockUsers = null;
|
|
433
|
+
resolve?.();
|
|
434
|
+
};
|
|
435
|
+
const waitForOperationLockUsers = () => {
|
|
436
|
+
if (operationLockUsers === 0)
|
|
437
|
+
return null;
|
|
438
|
+
return new Promise((resolve) => {
|
|
439
|
+
resolveNoOperationLockUsers = resolve;
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
const runPassWithLock = async (passArgvForRun, prepare, lane = "slow") => {
|
|
443
|
+
// The operation lock remains the cross-process sync/rescue exclusion
|
|
444
|
+
// boundary. A fast scoped pass is the one deliberate in-process exception:
|
|
445
|
+
// its keyed journal delta and per-object mutation are independent from the
|
|
446
|
+
// slow pass's bulk paths, and the slow pass already owns the root lock.
|
|
447
|
+
// Never bypass the lock when no slow pass is active.
|
|
448
|
+
if (lane === "fast" && processOwnsOperationLock) {
|
|
449
|
+
acquireOperationLockUser();
|
|
450
|
+
try {
|
|
451
|
+
prepare?.();
|
|
452
|
+
return await runPass(passArgvForRun);
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
releaseOperationLockUser();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
289
458
|
try {
|
|
290
459
|
const handle = acquireOperationLock(hqRoot, "sync", {
|
|
291
460
|
timeoutSec: 0,
|
|
@@ -293,10 +462,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
293
462
|
deferToWaiters: true,
|
|
294
463
|
});
|
|
295
464
|
try {
|
|
465
|
+
if (lane === "slow") {
|
|
466
|
+
processOwnsOperationLock = true;
|
|
467
|
+
acquireOperationLockUser();
|
|
468
|
+
}
|
|
296
469
|
prepare?.();
|
|
297
470
|
return await runPass(passArgvForRun);
|
|
298
471
|
}
|
|
299
472
|
finally {
|
|
473
|
+
if (lane === "slow") {
|
|
474
|
+
releaseOperationLockUser();
|
|
475
|
+
// The root lock protects a fast pass that was admitted while this
|
|
476
|
+
// slow pass owned it. Do not make it acquirable by rescue/another
|
|
477
|
+
// runner until every such in-process user has completed.
|
|
478
|
+
const remainingUsers = waitForOperationLockUsers();
|
|
479
|
+
if (remainingUsers)
|
|
480
|
+
await remainingUsers;
|
|
481
|
+
processOwnsOperationLock = false;
|
|
482
|
+
}
|
|
300
483
|
handle.release();
|
|
301
484
|
}
|
|
302
485
|
}
|
|
@@ -309,9 +492,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
309
492
|
}
|
|
310
493
|
}
|
|
311
494
|
try {
|
|
312
|
-
return await withOperationLock(hqRoot, "sync", () => {
|
|
313
|
-
|
|
314
|
-
|
|
495
|
+
return await withOperationLock(hqRoot, "sync", async () => {
|
|
496
|
+
if (lane === "slow") {
|
|
497
|
+
processOwnsOperationLock = true;
|
|
498
|
+
acquireOperationLockUser();
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
prepare?.();
|
|
502
|
+
return await runPass(passArgvForRun);
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
if (lane === "slow") {
|
|
506
|
+
releaseOperationLockUser();
|
|
507
|
+
const remainingUsers = waitForOperationLockUsers();
|
|
508
|
+
if (remainingUsers)
|
|
509
|
+
await remainingUsers;
|
|
510
|
+
processOwnsOperationLock = false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
315
513
|
}, { timeoutSec: parsed.lockTimeoutSec, deferToWaiters: true });
|
|
316
514
|
}
|
|
317
515
|
catch (err) {
|
|
@@ -413,51 +611,89 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
413
611
|
observedInterruptGeneration = interruptGeneration;
|
|
414
612
|
}
|
|
415
613
|
};
|
|
416
|
-
let
|
|
417
|
-
|
|
614
|
+
let activeSlowPass = null;
|
|
615
|
+
let activeFastPass = null;
|
|
616
|
+
const pendingSlowPasses = [];
|
|
617
|
+
const pendingFastPasses = [];
|
|
418
618
|
const resolveStoppedQueue = () => {
|
|
419
|
-
|
|
420
|
-
|
|
619
|
+
for (const queue of [pendingFastPasses, pendingSlowPasses]) {
|
|
620
|
+
while (queue.length > 0)
|
|
621
|
+
queue.shift()?.resolve(0);
|
|
421
622
|
}
|
|
422
623
|
};
|
|
423
624
|
const drainQueuedPasses = () => {
|
|
424
|
-
if (activePass !== null)
|
|
425
|
-
return;
|
|
426
625
|
if (stopped) {
|
|
427
626
|
resolveStoppedQueue();
|
|
428
627
|
return;
|
|
429
628
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
629
|
+
if (activeFastPass === null) {
|
|
630
|
+
const fast = pendingFastPasses.shift();
|
|
631
|
+
if (fast) {
|
|
632
|
+
const current = startGuardedTask(fast.task, "fast");
|
|
633
|
+
void current.then(fast.resolve, fast.reject);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// A fast pass that acquired the operation lock normally (rather than
|
|
637
|
+
// bypassing an in-process slow holder) still owns the cross-process
|
|
638
|
+
// boundary. Slow work must queue behind it; only fast work is allowed to
|
|
639
|
+
// overlap an already-lock-owning slow pass.
|
|
640
|
+
if (activeSlowPass === null && activeFastPass === null) {
|
|
641
|
+
const slow = pendingSlowPasses.shift();
|
|
642
|
+
if (slow) {
|
|
643
|
+
const current = startGuardedTask(slow.task, "slow");
|
|
644
|
+
void current.then(slow.resolve, slow.reject);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
435
647
|
};
|
|
436
|
-
const startGuardedTask = (task) => {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
648
|
+
const startGuardedTask = (task, lane) => {
|
|
649
|
+
// Claim the lane before invoking `task`: a task can synchronously activate
|
|
650
|
+
// a watcher/scheduler callback before returning its promise. Without this
|
|
651
|
+
// placeholder that re-entrant callback sees an idle lane and starts a
|
|
652
|
+
// second lock contender.
|
|
653
|
+
const placeholder = Promise.resolve(0);
|
|
654
|
+
if (lane === "fast")
|
|
655
|
+
activeFastPass = placeholder;
|
|
656
|
+
else
|
|
657
|
+
activeSlowPass = placeholder;
|
|
658
|
+
let current;
|
|
659
|
+
try {
|
|
660
|
+
current = stopped ? placeholder : task();
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
current = Promise.reject(err);
|
|
664
|
+
}
|
|
665
|
+
if (lane === "fast")
|
|
666
|
+
activeFastPass = current;
|
|
667
|
+
else
|
|
668
|
+
activeSlowPass = current;
|
|
441
669
|
void current
|
|
442
670
|
.finally(() => {
|
|
443
|
-
if (
|
|
444
|
-
|
|
671
|
+
if (lane === "fast" && activeFastPass === current) {
|
|
672
|
+
activeFastPass = null;
|
|
673
|
+
drainQueuedPasses();
|
|
674
|
+
}
|
|
675
|
+
else if (lane === "slow" && activeSlowPass === current) {
|
|
676
|
+
activeSlowPass = null;
|
|
445
677
|
drainQueuedPasses();
|
|
446
678
|
}
|
|
447
679
|
})
|
|
448
680
|
.catch(() => undefined);
|
|
449
681
|
return current;
|
|
450
682
|
};
|
|
451
|
-
const runGuardedTask = (task) => {
|
|
452
|
-
|
|
453
|
-
|
|
683
|
+
const runGuardedTask = (task, lane = "slow") => {
|
|
684
|
+
const active = lane === "fast"
|
|
685
|
+
? activeFastPass
|
|
686
|
+
: activeSlowPass ?? activeFastPass;
|
|
687
|
+
const queued = lane === "fast" ? pendingFastPasses : pendingSlowPasses;
|
|
688
|
+
if (active === null && queued.length === 0) {
|
|
689
|
+
return startGuardedTask(task, lane);
|
|
454
690
|
}
|
|
455
691
|
return new Promise((resolve, reject) => {
|
|
456
|
-
|
|
692
|
+
queued.push({ task, lane, resolve, reject });
|
|
457
693
|
drainQueuedPasses();
|
|
458
694
|
});
|
|
459
695
|
};
|
|
460
|
-
const runGuarded = (passArgvForRun, prepare) => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare));
|
|
696
|
+
const runGuarded = (passArgvForRun, prepare, lane = "slow") => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare, lane), lane);
|
|
461
697
|
let watcher = null;
|
|
462
698
|
let eventPushSurfacesActivated = false;
|
|
463
699
|
let watcherUnavailableWarned = false;
|
|
@@ -542,6 +778,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
542
778
|
let pendingWatcherBatchGeneration = null;
|
|
543
779
|
const lastPublishedWatcherBatchGenerationByRoute = new Map();
|
|
544
780
|
let pendingWatcherBareChange = false;
|
|
781
|
+
let pendingWatcherWarmupCatchup = false;
|
|
782
|
+
let pendingWatcherWarmupCatchupFailed = false;
|
|
545
783
|
let pendingWatcherOverflowed = false;
|
|
546
784
|
let pendingWatcherDroppedPaths = 0;
|
|
547
785
|
let pendingWatcherDroppedBytes = 0;
|
|
@@ -550,26 +788,28 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
550
788
|
// pending state carried them; one hint-less overflow poisons the union.
|
|
551
789
|
const pendingWatcherDroppedRouteHints = new Set();
|
|
552
790
|
let pendingWatcherDroppedRoutesUnknown = false;
|
|
791
|
+
const selectWatchBatch = (batch) => {
|
|
792
|
+
const paths = new Map();
|
|
793
|
+
const changes = new Map();
|
|
794
|
+
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
795
|
+
const route = routeChangeToTarget(relativePath);
|
|
796
|
+
if (!route || !isWatchRouteSelected(route, watchRouteSelection))
|
|
797
|
+
continue;
|
|
798
|
+
paths.set(absolutePath, relativePath);
|
|
799
|
+
const change = batch.changes?.get(absolutePath);
|
|
800
|
+
if (change)
|
|
801
|
+
changes.set(absolutePath, change);
|
|
802
|
+
}
|
|
803
|
+
if (paths.size === 0)
|
|
804
|
+
return null;
|
|
805
|
+
return { ...batch, paths, changes };
|
|
806
|
+
};
|
|
553
807
|
const addPendingWatcherChange = (changedRelPath, batch) => {
|
|
554
808
|
if (batch) {
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
558
|
-
const route = routeChangeToTarget(relativePath);
|
|
559
|
-
if (!route || !isWatchRouteSelected(route, watchRouteSelection))
|
|
560
|
-
continue;
|
|
561
|
-
paths.set(absolutePath, relativePath);
|
|
562
|
-
const change = batch.changes?.get(absolutePath);
|
|
563
|
-
if (change)
|
|
564
|
-
changes.set(absolutePath, change);
|
|
565
|
-
}
|
|
566
|
-
if (paths.size === 0)
|
|
809
|
+
const selectedBatch = selectWatchBatch(batch);
|
|
810
|
+
if (!selectedBatch)
|
|
567
811
|
return false;
|
|
568
|
-
batch =
|
|
569
|
-
...batch,
|
|
570
|
-
paths,
|
|
571
|
-
changes,
|
|
572
|
-
};
|
|
812
|
+
batch = selectedBatch;
|
|
573
813
|
}
|
|
574
814
|
else if (changedRelPath) {
|
|
575
815
|
const route = routeChangeToTarget(changedRelPath);
|
|
@@ -651,18 +891,22 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
651
891
|
}
|
|
652
892
|
: null;
|
|
653
893
|
const bare = pendingWatcherBareChange;
|
|
894
|
+
const warmupCatchup = pendingWatcherWarmupCatchup;
|
|
895
|
+
const warmupCatchupFailed = pendingWatcherWarmupCatchupFailed;
|
|
654
896
|
const generation = pendingWatcherBatchGeneration;
|
|
655
897
|
pendingWatcherPaths.clear();
|
|
656
898
|
pendingWatcherChanges.clear();
|
|
657
899
|
pendingWatcherOriginalBatch = null;
|
|
658
900
|
pendingWatcherBareChange = false;
|
|
901
|
+
pendingWatcherWarmupCatchup = false;
|
|
902
|
+
pendingWatcherWarmupCatchupFailed = false;
|
|
659
903
|
pendingWatcherOverflowed = false;
|
|
660
904
|
pendingWatcherDroppedPaths = 0;
|
|
661
905
|
pendingWatcherDroppedBytes = 0;
|
|
662
906
|
pendingWatcherDroppedRouteHints.clear();
|
|
663
907
|
pendingWatcherDroppedRoutesUnknown = false;
|
|
664
908
|
pendingWatcherBatchGeneration = null;
|
|
665
|
-
return { batch, bare, generation };
|
|
909
|
+
return { batch, bare, warmupCatchup, warmupCatchupFailed, generation };
|
|
666
910
|
};
|
|
667
911
|
const restorePendingWatcherPaths = (paths, generation) => {
|
|
668
912
|
pendingWatcherBatchGeneration ??=
|
|
@@ -730,6 +974,22 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
730
974
|
: relativePath.slice(prefix.length + 1),
|
|
731
975
|
};
|
|
732
976
|
};
|
|
977
|
+
/**
|
|
978
|
+
* A receiver event is eligible only when its upsert bytes are known small.
|
|
979
|
+
* Current publishers do not require a size field, so a locally journaled
|
|
980
|
+
* version is the backwards-compatible HEAD-size source. An event with no
|
|
981
|
+
* declared or journal-known size is bulk: guessing would let a large legacy
|
|
982
|
+
* transfer consume the realtime lane.
|
|
983
|
+
*/
|
|
984
|
+
const receiverEventSize = (event) => {
|
|
985
|
+
if (event.kind !== "upsert")
|
|
986
|
+
return null;
|
|
987
|
+
const coordinates = journalCoordinates(event.relativePath);
|
|
988
|
+
if (!coordinates)
|
|
989
|
+
return null;
|
|
990
|
+
const size = readJournal(coordinates.slug).files[coordinates.key]?.size;
|
|
991
|
+
return Number.isSafeInteger(size) && size >= 0 ? size : null;
|
|
992
|
+
};
|
|
733
993
|
const captureLocalDeleteSnapshots = (relativePath, kind) => {
|
|
734
994
|
const coordinates = journalCoordinates(relativePath);
|
|
735
995
|
const route = routeChangeToTarget(relativePath);
|
|
@@ -841,6 +1101,41 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
841
1101
|
}
|
|
842
1102
|
return snapshots;
|
|
843
1103
|
};
|
|
1104
|
+
const warmupJournalPaths = () => {
|
|
1105
|
+
const paths = [];
|
|
1106
|
+
for (const { slug, journal } of listJournals()) {
|
|
1107
|
+
const route = slug === PERSONAL_VAULT_JOURNAL_SLUG || slug === "personal"
|
|
1108
|
+
? { kind: "personal" }
|
|
1109
|
+
: { kind: "company", slug };
|
|
1110
|
+
if (!isWatchRouteSelected(route, watchRouteSelection))
|
|
1111
|
+
continue;
|
|
1112
|
+
for (const key of Object.keys(journal.files)) {
|
|
1113
|
+
paths.push(route.kind === "personal" ? key : `companies/${route.slug}/${key}`);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return paths;
|
|
1117
|
+
};
|
|
1118
|
+
const publishedContentHashBaseline = () => {
|
|
1119
|
+
const hashes = {};
|
|
1120
|
+
for (const { slug, journal } of listJournals()) {
|
|
1121
|
+
const isPersonal = slug === PERSONAL_VAULT_JOURNAL_SLUG || slug === "personal";
|
|
1122
|
+
const route = isPersonal
|
|
1123
|
+
? { kind: "personal" }
|
|
1124
|
+
: { kind: "company", slug };
|
|
1125
|
+
if (!isWatchRouteSelected(route, watchRouteSelection))
|
|
1126
|
+
continue;
|
|
1127
|
+
for (const [key, entry] of Object.entries(journal.files)) {
|
|
1128
|
+
// Tombstones and link records have no live regular-file body for the
|
|
1129
|
+
// PushEventEmitter's SHA-256 comparison, so they must not seed it.
|
|
1130
|
+
if (entry.removedAt !== undefined ||
|
|
1131
|
+
entry.kind === "symlink" ||
|
|
1132
|
+
entry.localDiverges)
|
|
1133
|
+
continue;
|
|
1134
|
+
hashes[isPersonal ? key : `companies/${slug}/${key}`] = `sha256:${entry.hash}`;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return hashes;
|
|
1138
|
+
};
|
|
844
1139
|
const prepareWatcherChanges = (changes) => {
|
|
845
1140
|
const journals = new Map();
|
|
846
1141
|
const dirty = new Set();
|
|
@@ -939,7 +1234,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
939
1234
|
}
|
|
940
1235
|
return false;
|
|
941
1236
|
};
|
|
942
|
-
const runScopedDrain = async (batch, watcherBatchGeneration) => {
|
|
1237
|
+
const runScopedDrain = async (batch, watcherBatchGeneration, wakeOnPushFailure = false) => {
|
|
943
1238
|
const grouped = batch && batch.paths.size > 0 ? groupBatchByRoute(batch) : null;
|
|
944
1239
|
const skipCompanies = grouped ? resolveSkipCompanies() : null;
|
|
945
1240
|
const isPausedRoute = (route) => route.kind === "company" &&
|
|
@@ -953,9 +1248,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
953
1248
|
}
|
|
954
1249
|
const scopedArgv = buildScopedPushArgv(group.route, group.relPaths, passArgv, group.deleteScopeRoots);
|
|
955
1250
|
try {
|
|
1251
|
+
const lane = isFastLocalBatch(group.paths.keys(), group.changes)
|
|
1252
|
+
? "fast"
|
|
1253
|
+
: "slow";
|
|
956
1254
|
const result = await runGuarded(scopedArgv, group.changes.size > 0
|
|
957
1255
|
? () => prepareWatcherChanges(group.changes)
|
|
958
|
-
: undefined);
|
|
1256
|
+
: undefined, lane);
|
|
959
1257
|
if (passExitCode(result) === 0) {
|
|
960
1258
|
const refusedPaths = new Set(typeof result === "number"
|
|
961
1259
|
? []
|
|
@@ -980,11 +1278,15 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
980
1278
|
}
|
|
981
1279
|
else {
|
|
982
1280
|
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
1281
|
+
if (wakeOnPushFailure)
|
|
1282
|
+
interruptWait();
|
|
983
1283
|
}
|
|
984
1284
|
}
|
|
985
1285
|
catch (err) {
|
|
986
1286
|
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
987
1287
|
process.stderr.write(`watch scoped push failed, will retry next tick: ${describeError(err)}\n`);
|
|
1288
|
+
if (wakeOnPushFailure)
|
|
1289
|
+
interruptWait();
|
|
988
1290
|
}
|
|
989
1291
|
}
|
|
990
1292
|
}
|
|
@@ -1012,7 +1314,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1012
1314
|
continue;
|
|
1013
1315
|
ranPersonalDrain = true;
|
|
1014
1316
|
}
|
|
1015
|
-
const exit = passExitCode(await runGuarded(pullArgv));
|
|
1317
|
+
const exit = passExitCode(await runGuarded(pullArgv, undefined, isFastLocalBatch(group.paths.keys(), group.changes) ? "fast" : "slow"));
|
|
1016
1318
|
if (exit !== 0 && worstExit === 0)
|
|
1017
1319
|
worstExit = exit;
|
|
1018
1320
|
}
|
|
@@ -1146,6 +1448,36 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1146
1448
|
watcher.onChange((changedRelPath, batch) => {
|
|
1147
1449
|
if (stopped)
|
|
1148
1450
|
return;
|
|
1451
|
+
// Peel a bounded realtime sub-batch from a mixed watcher batch while a
|
|
1452
|
+
// bulk pass is in flight. The residue enters the ordinary pending drain
|
|
1453
|
+
// immediately; a latest path appears in exactly one of these maps.
|
|
1454
|
+
const selectedBatch = batch ? selectWatchBatch(batch) : null;
|
|
1455
|
+
const lanes = selectedBatch && !selectedBatch.overflowed
|
|
1456
|
+
? partitionLocalBatch(selectedBatch)
|
|
1457
|
+
: null;
|
|
1458
|
+
if (lanes?.fast &&
|
|
1459
|
+
activeSlowPass !== null) {
|
|
1460
|
+
const generation = ++nextWatcherBatchGeneration;
|
|
1461
|
+
// Fast dispatch bypasses the pending-batch ingress, so preserve both
|
|
1462
|
+
// of its contracts here: only selected routes may run, and V2 sees
|
|
1463
|
+
// the same dirty signal as the compatibility drain.
|
|
1464
|
+
realtimeState.scheduler?.signal("watcher");
|
|
1465
|
+
void runScopedDrain(lanes.fast, generation, true).then((exit) => {
|
|
1466
|
+
if (exit === 0)
|
|
1467
|
+
return;
|
|
1468
|
+
restorePendingWatcherPaths(lanes.fast.paths, generation);
|
|
1469
|
+
interruptWait();
|
|
1470
|
+
}).catch((err) => {
|
|
1471
|
+
restorePendingWatcherPaths(lanes.fast.paths, generation);
|
|
1472
|
+
process.stderr.write(`watch fast-lane scoped push failed, will retry next tick: ${describeError(err)}\n`);
|
|
1473
|
+
interruptWait();
|
|
1474
|
+
});
|
|
1475
|
+
if (lanes.slow && addPendingWatcherChange(undefined, lanes.slow)) {
|
|
1476
|
+
realtimeState.scheduler?.signal("watcher");
|
|
1477
|
+
interruptWait();
|
|
1478
|
+
}
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1149
1481
|
if (addPendingWatcherChange(changedRelPath, batch)) {
|
|
1150
1482
|
// The V1 watcher batch keeps its existing compatibility behavior. A
|
|
1151
1483
|
// V2-enrolled scope observes the same dirty hint through its own
|
|
@@ -1178,6 +1510,30 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1178
1510
|
// initial walk will finish.
|
|
1179
1511
|
watcherRediscoveryRetries = 0;
|
|
1180
1512
|
process.stderr.write(`hq-sync-runner: event-push watcher active (watched-paths=${watchedPaths ?? "unknown"})\n`);
|
|
1513
|
+
const startedAt = Date.now();
|
|
1514
|
+
try {
|
|
1515
|
+
const catchup = watcher.collectWarmupCatchup?.(serviceStartedAt - WATCHER_WARMUP_SAFETY_MARGIN_MS, warmupJournalPaths());
|
|
1516
|
+
if (!catchup)
|
|
1517
|
+
return;
|
|
1518
|
+
const queued = addPendingWatcherChange(undefined, catchup.batch);
|
|
1519
|
+
pendingWatcherWarmupCatchup ||= queued;
|
|
1520
|
+
process.stderr.write(JSON.stringify({
|
|
1521
|
+
type: "watcher-warmup-catchup",
|
|
1522
|
+
reason: "watch-set-ready",
|
|
1523
|
+
pathsFound: catchup.batch.paths.size,
|
|
1524
|
+
scannedPaths: catchup.scannedPaths,
|
|
1525
|
+
elapsedMs: Date.now() - startedAt,
|
|
1526
|
+
queued,
|
|
1527
|
+
}) + "\n");
|
|
1528
|
+
if (queued)
|
|
1529
|
+
interruptWait();
|
|
1530
|
+
}
|
|
1531
|
+
catch (err) {
|
|
1532
|
+
process.stderr.write(`hq-sync-runner: watcher warm-up catch-up failed: ${describeError(err)}\n`);
|
|
1533
|
+
// A scan failure must not silently extend the warm-up blind spot.
|
|
1534
|
+
pendingWatcherWarmupCatchupFailed = true;
|
|
1535
|
+
interruptWait();
|
|
1536
|
+
}
|
|
1181
1537
|
};
|
|
1182
1538
|
if (watcher.onReady)
|
|
1183
1539
|
watcher.onReady(reportWatcherReady);
|
|
@@ -1214,25 +1570,39 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1214
1570
|
// so it cannot overlap the pass that may currently own the operation
|
|
1215
1571
|
// lock.
|
|
1216
1572
|
interruptWait();
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
const
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const result = await runGuarded(targetedArgv);
|
|
1232
|
-
if (passExitCode(result) !== 0) {
|
|
1233
|
-
throw new Error(`targeted pull failed with exit code ${passExitCode(result)}`);
|
|
1573
|
+
// Apply the caps once across the whole delivery, then retain the residue
|
|
1574
|
+
// in the normal lane. The receiver already coalesces to one latest event
|
|
1575
|
+
// per path, so the partition cannot split revisions of one path.
|
|
1576
|
+
const lanes = partitionByKnownSizes(events, receiverEventSize);
|
|
1577
|
+
const drainLane = async (lane, laneEvents) => {
|
|
1578
|
+
const pathsByRoute = new Map();
|
|
1579
|
+
for (const event of laneEvents) {
|
|
1580
|
+
const route = routeChangeToTarget(event.relativePath);
|
|
1581
|
+
if (!route)
|
|
1582
|
+
continue;
|
|
1583
|
+
const key = routeKey(route);
|
|
1584
|
+
const group = pathsByRoute.get(key) ?? { route, paths: [] };
|
|
1585
|
+
group.paths.push(event.relativePath);
|
|
1586
|
+
pathsByRoute.set(key, group);
|
|
1234
1587
|
}
|
|
1235
|
-
|
|
1588
|
+
for (const { route, paths } of pathsByRoute.values()) {
|
|
1589
|
+
const targetedArgv = buildTargetedPullArgv(route, passArgv, paths);
|
|
1590
|
+
const result = await runGuarded(targetedArgv, undefined, lane);
|
|
1591
|
+
if (passExitCode(result) !== 0) {
|
|
1592
|
+
throw new Error(`targeted pull failed with exit code ${passExitCode(result)}`);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
// Dispatch realtime work before awaiting its bulk residue. This lets an
|
|
1597
|
+
// established slow pass continue while the small scoped pull uses its
|
|
1598
|
+
// ref-counted lock carve-out; the residue remains serialized and cannot
|
|
1599
|
+
// starve the slow lane.
|
|
1600
|
+
await drainLane("fast", lanes.fast);
|
|
1601
|
+
await drainLane("slow", lanes.slow);
|
|
1602
|
+
};
|
|
1603
|
+
receiverSyncBatchFn.partition = (events) => {
|
|
1604
|
+
const lanes = partitionByKnownSizes(events, receiverEventSize);
|
|
1605
|
+
return [lanes.fast, lanes.slow].filter((lane) => lane.length > 0);
|
|
1236
1606
|
};
|
|
1237
1607
|
const createReceiver = deps.createReceiver ?? (() => new NoopPushReceiver());
|
|
1238
1608
|
receiver = createReceiver({ syncBatchFn: receiverSyncBatchFn, hqRoot });
|
|
@@ -1246,10 +1616,23 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1246
1616
|
void deps.startRealtimeScheduler({
|
|
1247
1617
|
hqRoot,
|
|
1248
1618
|
runGuarded: async (drain) => {
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1619
|
+
let outcome;
|
|
1620
|
+
try {
|
|
1621
|
+
outcome = await runGuardedTask(() => runTaskWithLock(async () => {
|
|
1622
|
+
await drain();
|
|
1623
|
+
return 0;
|
|
1624
|
+
}));
|
|
1625
|
+
}
|
|
1626
|
+
catch (err) {
|
|
1627
|
+
// Scheduler clients may have already been disposed when a queued
|
|
1628
|
+
// drain finally reaches the lock. Surface the late failure, but do
|
|
1629
|
+
// not leak an unhandled rejection after shutdown has completed.
|
|
1630
|
+
if (stopped) {
|
|
1631
|
+
process.stderr.write("realtime scheduler: cancelled drain failed after shutdown\n");
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
throw err;
|
|
1635
|
+
}
|
|
1253
1636
|
if (passExitCode(outcome) !== 0) {
|
|
1254
1637
|
throw new Error(`realtime scheduler drain exited ${passExitCode(outcome)}`);
|
|
1255
1638
|
}
|
|
@@ -1297,6 +1680,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1297
1680
|
log: (m) => process.stderr.write(`${m}\n`),
|
|
1298
1681
|
telemetryClient: eventSyncClient,
|
|
1299
1682
|
telemetryClaims: claims,
|
|
1683
|
+
publishedContentHashStateDir: getStateDir(),
|
|
1684
|
+
initialPublishedContentHashes: publishedContentHashBaseline(),
|
|
1300
1685
|
});
|
|
1301
1686
|
if (!handles)
|
|
1302
1687
|
return;
|
|
@@ -1391,6 +1776,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1391
1776
|
const eventPushSurfacesInactive = eventPush && !eventPushSurfacesActivated;
|
|
1392
1777
|
const firstTick = pollTick === 1;
|
|
1393
1778
|
const bareWake = pending.bare;
|
|
1779
|
+
const watcherWarmupCatchup = pending.warmupCatchup;
|
|
1780
|
+
const watcherWarmupCatchupFailed = pending.warmupCatchupFailed;
|
|
1394
1781
|
const overflowRoutesUnknown = pending.batch?.overflowed === true && !overflowWithKnownRoutes;
|
|
1395
1782
|
const scheduledInterval = fullReconcileIntervalMs === undefined
|
|
1396
1783
|
? pollTick % FULL_RECONCILE_EVERY_TICKS === 0
|
|
@@ -1402,6 +1789,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1402
1789
|
"bare-wake": bareWake,
|
|
1403
1790
|
"batch-over-limit": batchOverLimit,
|
|
1404
1791
|
"overflow-routes-unknown": overflowRoutesUnknown,
|
|
1792
|
+
"watcher-warmup-catch-up": watcherWarmupCatchup,
|
|
1793
|
+
"watcher-warmup-catch-up-failed": watcherWarmupCatchupFailed,
|
|
1405
1794
|
"scheduled-interval": scheduledInterval,
|
|
1406
1795
|
};
|
|
1407
1796
|
const reasons = Object.entries(triggers)
|
|
@@ -1412,6 +1801,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1412
1801
|
bareWake ||
|
|
1413
1802
|
batchOverLimit ||
|
|
1414
1803
|
overflowRoutesUnknown ||
|
|
1804
|
+
watcherWarmupCatchupFailed ||
|
|
1415
1805
|
scheduledInterval;
|
|
1416
1806
|
const emitDecision = (mode, decisionReasons) => {
|
|
1417
1807
|
emitFullReconcileDecision({
|
|
@@ -1447,7 +1837,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1447
1837
|
// were dropped); ordinary batches remain path-scoped. Label those
|
|
1448
1838
|
// distinct scopes so operators can distinguish them from a full
|
|
1449
1839
|
// all-vault reconcile.
|
|
1450
|
-
emitDecision(
|
|
1840
|
+
emitDecision(watcherWarmupCatchup
|
|
1841
|
+
? "catch-up"
|
|
1842
|
+
: overflowWithKnownRoutes
|
|
1843
|
+
? "route"
|
|
1844
|
+
: "scoped", reasons);
|
|
1451
1845
|
}
|
|
1452
1846
|
catch (err) {
|
|
1453
1847
|
// Never silently drop a change: any failure inside the targeted
|