@indigoai-us/hq-cloud 6.15.63 → 6.15.65
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 +19 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +338 -58
- 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 +724 -17
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.test.js +2 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +11 -2
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +19 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/logger.test.js +3 -0
- package/dist/sync/logger.test.js.map +1 -1
- package/dist/sync/metrics.test.js +3 -0
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +21 -3
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +92 -59
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +169 -0
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/watcher.d.ts +17 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +74 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +57 -0
- 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, 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,63 @@ 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 DEFAULT_FAST_LANE_MAX_FILES = 32;
|
|
56
|
+
export const DEFAULT_FAST_LANE_MAX_BYTES = 4 * 1024 * 1024;
|
|
57
|
+
function positiveEnvInt(name, fallback) {
|
|
58
|
+
const value = Number.parseInt(process.env[name] ?? "", 10);
|
|
59
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
60
|
+
}
|
|
61
|
+
export function resolveFastLaneLimits() {
|
|
62
|
+
return {
|
|
63
|
+
maxFiles: positiveEnvInt(FAST_LANE_MAX_FILES_ENV, DEFAULT_FAST_LANE_MAX_FILES),
|
|
64
|
+
maxBytes: positiveEnvInt(FAST_LANE_MAX_BYTES_ENV, DEFAULT_FAST_LANE_MAX_BYTES),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** A missing/stat-failed path is never allowed to bypass the slow lane. */
|
|
68
|
+
export function isFastLocalBatch(paths, changes) {
|
|
69
|
+
const { maxFiles, maxBytes } = resolveFastLaneLimits();
|
|
70
|
+
let files = 0;
|
|
71
|
+
let bytes = 0;
|
|
72
|
+
for (const absolutePath of paths) {
|
|
73
|
+
files += 1;
|
|
74
|
+
if (files > maxFiles)
|
|
75
|
+
return false;
|
|
76
|
+
// `unlinkDir` arrives after its path is gone. Its scoped push expands the
|
|
77
|
+
// journal subtree through deleteScopeRoots, so its payload cannot be
|
|
78
|
+
// bounded by the absent directory's lstat result.
|
|
79
|
+
if (changes?.get(absolutePath)?.kind === "unlinkDir")
|
|
80
|
+
return false;
|
|
81
|
+
try {
|
|
82
|
+
const stat = fs.lstatSync(absolutePath);
|
|
83
|
+
// A directory-scoped operation may expand to an arbitrary payload. Do
|
|
84
|
+
// not guess: it belongs in the slow lane.
|
|
85
|
+
if (stat.isDirectory())
|
|
86
|
+
return false;
|
|
87
|
+
bytes += stat.isFile() ? stat.size : 0;
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// Deletes are zero-byte operations; every other stat failure is unknown
|
|
91
|
+
// payload and must retain normal serialization.
|
|
92
|
+
if (err.code !== "ENOENT")
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (bytes > maxBytes)
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return files > 0;
|
|
99
|
+
}
|
|
100
|
+
/** Receiver events carry no content length, so only bounded event batches qualify. */
|
|
101
|
+
export function isFastReceiverBatch(eventCount) {
|
|
102
|
+
return eventCount > 0 && eventCount <= resolveFastLaneLimits().maxFiles;
|
|
103
|
+
}
|
|
47
104
|
export function resolveEventBatchLimit(env = process.env) {
|
|
48
105
|
const raw = env[EVENT_BATCH_LIMIT_ENV];
|
|
49
106
|
if (raw === undefined || raw.trim() === "")
|
|
@@ -84,6 +141,8 @@ export function resolveFullReconcileMs(env = process.env) {
|
|
|
84
141
|
const ADAPTIVE_POLL_FLOOR_MS = 60_000;
|
|
85
142
|
const ADAPTIVE_POLL_CEIL_MS = 600_000;
|
|
86
143
|
const WATCH_IDLE_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
144
|
+
/** Covers coarse timestamp filesystems and scheduling just before watcher start. */
|
|
145
|
+
const WATCHER_WARMUP_SAFETY_MARGIN_MS = 2_000;
|
|
87
146
|
/** Initial poll-only recovery delay after a watcher conclusively stands down. */
|
|
88
147
|
export const WATCHER_REDISCOVERY_RETRY_MS = 5 * 60_000;
|
|
89
148
|
/** Ceiling for exponential retry delays after consecutive watcher stand-downs. */
|
|
@@ -222,6 +281,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
222
281
|
skipPersonalEnv === "yes",
|
|
223
282
|
};
|
|
224
283
|
const hqRoot = parsed.hqRoot;
|
|
284
|
+
const serviceStartedAt = Date.now();
|
|
225
285
|
// Delayed and daily: this must never be attached to the 15s poll cadence.
|
|
226
286
|
// Stage 0 remains dry-run only; the standalone hq-backup-prune command is
|
|
227
287
|
// the explicitly-gated apply surface.
|
|
@@ -260,8 +320,14 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
260
320
|
// mis-defaults an omitted --direction to "both" and picks the wrong
|
|
261
321
|
// occurrence on duplicates). Fall back to the parser's own default ("pull").
|
|
262
322
|
const originalDirection = parsed.direction ?? "pull";
|
|
263
|
-
const drainRunsPush = originalDirection === "both" || originalDirection === "push";
|
|
264
323
|
const drainRunsPull = originalDirection === "both" || originalDirection === "pull";
|
|
324
|
+
// `--event-push` is an explicit local-write delivery contract. The
|
|
325
|
+
// one-shot/cadence direction still controls ordinary reconciliation, but a
|
|
326
|
+
// watcher batch must always take its bounded scoped push path even when the
|
|
327
|
+
// parser's legacy default direction is pull. Otherwise the drain snapshots
|
|
328
|
+
// and clears a local batch while running only its pull leg, leaving the
|
|
329
|
+
// write for a later full reconcile.
|
|
330
|
+
const watcherDrainRunsPush = eventPush;
|
|
265
331
|
if (parsed.lockTimeoutSec === 0) {
|
|
266
332
|
try {
|
|
267
333
|
const handle = acquireOperationLock(hqRoot, "sync", {
|
|
@@ -279,7 +345,47 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
279
345
|
throw err;
|
|
280
346
|
}
|
|
281
347
|
}
|
|
282
|
-
|
|
348
|
+
// `activeSlowPass` is assigned before its task has necessarily acquired the
|
|
349
|
+
// cross-process lock (it may be waiting behind a rescue or a second runner).
|
|
350
|
+
// Fast work may bypass the lock only while this process *actually owns* it;
|
|
351
|
+
// otherwise it must participate in normal lock acquisition.
|
|
352
|
+
let processOwnsOperationLock = false;
|
|
353
|
+
let operationLockUsers = 0;
|
|
354
|
+
let resolveNoOperationLockUsers = null;
|
|
355
|
+
const acquireOperationLockUser = () => {
|
|
356
|
+
operationLockUsers += 1;
|
|
357
|
+
};
|
|
358
|
+
const releaseOperationLockUser = () => {
|
|
359
|
+
operationLockUsers -= 1;
|
|
360
|
+
if (operationLockUsers !== 0)
|
|
361
|
+
return;
|
|
362
|
+
const resolve = resolveNoOperationLockUsers;
|
|
363
|
+
resolveNoOperationLockUsers = null;
|
|
364
|
+
resolve?.();
|
|
365
|
+
};
|
|
366
|
+
const waitForOperationLockUsers = () => {
|
|
367
|
+
if (operationLockUsers === 0)
|
|
368
|
+
return null;
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
resolveNoOperationLockUsers = resolve;
|
|
371
|
+
});
|
|
372
|
+
};
|
|
373
|
+
const runPassWithLock = async (passArgvForRun, prepare, lane = "slow") => {
|
|
374
|
+
// The operation lock remains the cross-process sync/rescue exclusion
|
|
375
|
+
// boundary. A fast scoped pass is the one deliberate in-process exception:
|
|
376
|
+
// its keyed journal delta and per-object mutation are independent from the
|
|
377
|
+
// slow pass's bulk paths, and the slow pass already owns the root lock.
|
|
378
|
+
// Never bypass the lock when no slow pass is active.
|
|
379
|
+
if (lane === "fast" && processOwnsOperationLock) {
|
|
380
|
+
acquireOperationLockUser();
|
|
381
|
+
try {
|
|
382
|
+
prepare?.();
|
|
383
|
+
return await runPass(passArgvForRun);
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
releaseOperationLockUser();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
283
389
|
try {
|
|
284
390
|
const handle = acquireOperationLock(hqRoot, "sync", {
|
|
285
391
|
timeoutSec: 0,
|
|
@@ -287,10 +393,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
287
393
|
deferToWaiters: true,
|
|
288
394
|
});
|
|
289
395
|
try {
|
|
396
|
+
if (lane === "slow") {
|
|
397
|
+
processOwnsOperationLock = true;
|
|
398
|
+
acquireOperationLockUser();
|
|
399
|
+
}
|
|
290
400
|
prepare?.();
|
|
291
401
|
return await runPass(passArgvForRun);
|
|
292
402
|
}
|
|
293
403
|
finally {
|
|
404
|
+
if (lane === "slow") {
|
|
405
|
+
releaseOperationLockUser();
|
|
406
|
+
// The root lock protects a fast pass that was admitted while this
|
|
407
|
+
// slow pass owned it. Do not make it acquirable by rescue/another
|
|
408
|
+
// runner until every such in-process user has completed.
|
|
409
|
+
const remainingUsers = waitForOperationLockUsers();
|
|
410
|
+
if (remainingUsers)
|
|
411
|
+
await remainingUsers;
|
|
412
|
+
processOwnsOperationLock = false;
|
|
413
|
+
}
|
|
294
414
|
handle.release();
|
|
295
415
|
}
|
|
296
416
|
}
|
|
@@ -303,9 +423,24 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
303
423
|
}
|
|
304
424
|
}
|
|
305
425
|
try {
|
|
306
|
-
return await withOperationLock(hqRoot, "sync", () => {
|
|
307
|
-
|
|
308
|
-
|
|
426
|
+
return await withOperationLock(hqRoot, "sync", async () => {
|
|
427
|
+
if (lane === "slow") {
|
|
428
|
+
processOwnsOperationLock = true;
|
|
429
|
+
acquireOperationLockUser();
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
prepare?.();
|
|
433
|
+
return await runPass(passArgvForRun);
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
if (lane === "slow") {
|
|
437
|
+
releaseOperationLockUser();
|
|
438
|
+
const remainingUsers = waitForOperationLockUsers();
|
|
439
|
+
if (remainingUsers)
|
|
440
|
+
await remainingUsers;
|
|
441
|
+
processOwnsOperationLock = false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
309
444
|
}, { timeoutSec: parsed.lockTimeoutSec, deferToWaiters: true });
|
|
310
445
|
}
|
|
311
446
|
catch (err) {
|
|
@@ -407,51 +542,89 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
407
542
|
observedInterruptGeneration = interruptGeneration;
|
|
408
543
|
}
|
|
409
544
|
};
|
|
410
|
-
let
|
|
411
|
-
|
|
545
|
+
let activeSlowPass = null;
|
|
546
|
+
let activeFastPass = null;
|
|
547
|
+
const pendingSlowPasses = [];
|
|
548
|
+
const pendingFastPasses = [];
|
|
412
549
|
const resolveStoppedQueue = () => {
|
|
413
|
-
|
|
414
|
-
|
|
550
|
+
for (const queue of [pendingFastPasses, pendingSlowPasses]) {
|
|
551
|
+
while (queue.length > 0)
|
|
552
|
+
queue.shift()?.resolve(0);
|
|
415
553
|
}
|
|
416
554
|
};
|
|
417
555
|
const drainQueuedPasses = () => {
|
|
418
|
-
if (activePass !== null)
|
|
419
|
-
return;
|
|
420
556
|
if (stopped) {
|
|
421
557
|
resolveStoppedQueue();
|
|
422
558
|
return;
|
|
423
559
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
560
|
+
if (activeFastPass === null) {
|
|
561
|
+
const fast = pendingFastPasses.shift();
|
|
562
|
+
if (fast) {
|
|
563
|
+
const current = startGuardedTask(fast.task, "fast");
|
|
564
|
+
void current.then(fast.resolve, fast.reject);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// A fast pass that acquired the operation lock normally (rather than
|
|
568
|
+
// bypassing an in-process slow holder) still owns the cross-process
|
|
569
|
+
// boundary. Slow work must queue behind it; only fast work is allowed to
|
|
570
|
+
// overlap an already-lock-owning slow pass.
|
|
571
|
+
if (activeSlowPass === null && activeFastPass === null) {
|
|
572
|
+
const slow = pendingSlowPasses.shift();
|
|
573
|
+
if (slow) {
|
|
574
|
+
const current = startGuardedTask(slow.task, "slow");
|
|
575
|
+
void current.then(slow.resolve, slow.reject);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
429
578
|
};
|
|
430
|
-
const startGuardedTask = (task) => {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
579
|
+
const startGuardedTask = (task, lane) => {
|
|
580
|
+
// Claim the lane before invoking `task`: a task can synchronously activate
|
|
581
|
+
// a watcher/scheduler callback before returning its promise. Without this
|
|
582
|
+
// placeholder that re-entrant callback sees an idle lane and starts a
|
|
583
|
+
// second lock contender.
|
|
584
|
+
const placeholder = Promise.resolve(0);
|
|
585
|
+
if (lane === "fast")
|
|
586
|
+
activeFastPass = placeholder;
|
|
587
|
+
else
|
|
588
|
+
activeSlowPass = placeholder;
|
|
589
|
+
let current;
|
|
590
|
+
try {
|
|
591
|
+
current = stopped ? placeholder : task();
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
current = Promise.reject(err);
|
|
595
|
+
}
|
|
596
|
+
if (lane === "fast")
|
|
597
|
+
activeFastPass = current;
|
|
598
|
+
else
|
|
599
|
+
activeSlowPass = current;
|
|
435
600
|
void current
|
|
436
601
|
.finally(() => {
|
|
437
|
-
if (
|
|
438
|
-
|
|
602
|
+
if (lane === "fast" && activeFastPass === current) {
|
|
603
|
+
activeFastPass = null;
|
|
604
|
+
drainQueuedPasses();
|
|
605
|
+
}
|
|
606
|
+
else if (lane === "slow" && activeSlowPass === current) {
|
|
607
|
+
activeSlowPass = null;
|
|
439
608
|
drainQueuedPasses();
|
|
440
609
|
}
|
|
441
610
|
})
|
|
442
611
|
.catch(() => undefined);
|
|
443
612
|
return current;
|
|
444
613
|
};
|
|
445
|
-
const runGuardedTask = (task) => {
|
|
446
|
-
|
|
447
|
-
|
|
614
|
+
const runGuardedTask = (task, lane = "slow") => {
|
|
615
|
+
const active = lane === "fast"
|
|
616
|
+
? activeFastPass
|
|
617
|
+
: activeSlowPass ?? activeFastPass;
|
|
618
|
+
const queued = lane === "fast" ? pendingFastPasses : pendingSlowPasses;
|
|
619
|
+
if (active === null && queued.length === 0) {
|
|
620
|
+
return startGuardedTask(task, lane);
|
|
448
621
|
}
|
|
449
622
|
return new Promise((resolve, reject) => {
|
|
450
|
-
|
|
623
|
+
queued.push({ task, lane, resolve, reject });
|
|
451
624
|
drainQueuedPasses();
|
|
452
625
|
});
|
|
453
626
|
};
|
|
454
|
-
const runGuarded = (passArgvForRun, prepare) => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare));
|
|
627
|
+
const runGuarded = (passArgvForRun, prepare, lane = "slow") => runGuardedTask(() => runPassWithLock(passArgvForRun, prepare, lane), lane);
|
|
455
628
|
let watcher = null;
|
|
456
629
|
let eventPushSurfacesActivated = false;
|
|
457
630
|
let watcherUnavailableWarned = false;
|
|
@@ -536,6 +709,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
536
709
|
let pendingWatcherBatchGeneration = null;
|
|
537
710
|
const lastPublishedWatcherBatchGenerationByRoute = new Map();
|
|
538
711
|
let pendingWatcherBareChange = false;
|
|
712
|
+
let pendingWatcherWarmupCatchup = false;
|
|
713
|
+
let pendingWatcherWarmupCatchupFailed = false;
|
|
539
714
|
let pendingWatcherOverflowed = false;
|
|
540
715
|
let pendingWatcherDroppedPaths = 0;
|
|
541
716
|
let pendingWatcherDroppedBytes = 0;
|
|
@@ -544,26 +719,28 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
544
719
|
// pending state carried them; one hint-less overflow poisons the union.
|
|
545
720
|
const pendingWatcherDroppedRouteHints = new Set();
|
|
546
721
|
let pendingWatcherDroppedRoutesUnknown = false;
|
|
722
|
+
const selectWatchBatch = (batch) => {
|
|
723
|
+
const paths = new Map();
|
|
724
|
+
const changes = new Map();
|
|
725
|
+
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
726
|
+
const route = routeChangeToTarget(relativePath);
|
|
727
|
+
if (!route || !isWatchRouteSelected(route, watchRouteSelection))
|
|
728
|
+
continue;
|
|
729
|
+
paths.set(absolutePath, relativePath);
|
|
730
|
+
const change = batch.changes?.get(absolutePath);
|
|
731
|
+
if (change)
|
|
732
|
+
changes.set(absolutePath, change);
|
|
733
|
+
}
|
|
734
|
+
if (paths.size === 0)
|
|
735
|
+
return null;
|
|
736
|
+
return { ...batch, paths, changes };
|
|
737
|
+
};
|
|
547
738
|
const addPendingWatcherChange = (changedRelPath, batch) => {
|
|
548
739
|
if (batch) {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
552
|
-
const route = routeChangeToTarget(relativePath);
|
|
553
|
-
if (!route || !isWatchRouteSelected(route, watchRouteSelection))
|
|
554
|
-
continue;
|
|
555
|
-
paths.set(absolutePath, relativePath);
|
|
556
|
-
const change = batch.changes?.get(absolutePath);
|
|
557
|
-
if (change)
|
|
558
|
-
changes.set(absolutePath, change);
|
|
559
|
-
}
|
|
560
|
-
if (paths.size === 0)
|
|
740
|
+
const selectedBatch = selectWatchBatch(batch);
|
|
741
|
+
if (!selectedBatch)
|
|
561
742
|
return false;
|
|
562
|
-
batch =
|
|
563
|
-
...batch,
|
|
564
|
-
paths,
|
|
565
|
-
changes,
|
|
566
|
-
};
|
|
743
|
+
batch = selectedBatch;
|
|
567
744
|
}
|
|
568
745
|
else if (changedRelPath) {
|
|
569
746
|
const route = routeChangeToTarget(changedRelPath);
|
|
@@ -645,18 +822,22 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
645
822
|
}
|
|
646
823
|
: null;
|
|
647
824
|
const bare = pendingWatcherBareChange;
|
|
825
|
+
const warmupCatchup = pendingWatcherWarmupCatchup;
|
|
826
|
+
const warmupCatchupFailed = pendingWatcherWarmupCatchupFailed;
|
|
648
827
|
const generation = pendingWatcherBatchGeneration;
|
|
649
828
|
pendingWatcherPaths.clear();
|
|
650
829
|
pendingWatcherChanges.clear();
|
|
651
830
|
pendingWatcherOriginalBatch = null;
|
|
652
831
|
pendingWatcherBareChange = false;
|
|
832
|
+
pendingWatcherWarmupCatchup = false;
|
|
833
|
+
pendingWatcherWarmupCatchupFailed = false;
|
|
653
834
|
pendingWatcherOverflowed = false;
|
|
654
835
|
pendingWatcherDroppedPaths = 0;
|
|
655
836
|
pendingWatcherDroppedBytes = 0;
|
|
656
837
|
pendingWatcherDroppedRouteHints.clear();
|
|
657
838
|
pendingWatcherDroppedRoutesUnknown = false;
|
|
658
839
|
pendingWatcherBatchGeneration = null;
|
|
659
|
-
return { batch, bare, generation };
|
|
840
|
+
return { batch, bare, warmupCatchup, warmupCatchupFailed, generation };
|
|
660
841
|
};
|
|
661
842
|
const restorePendingWatcherPaths = (paths, generation) => {
|
|
662
843
|
pendingWatcherBatchGeneration ??=
|
|
@@ -835,6 +1016,20 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
835
1016
|
}
|
|
836
1017
|
return snapshots;
|
|
837
1018
|
};
|
|
1019
|
+
const warmupJournalPaths = () => {
|
|
1020
|
+
const paths = [];
|
|
1021
|
+
for (const { slug, journal } of listJournals()) {
|
|
1022
|
+
const route = slug === PERSONAL_VAULT_JOURNAL_SLUG || slug === "personal"
|
|
1023
|
+
? { kind: "personal" }
|
|
1024
|
+
: { kind: "company", slug };
|
|
1025
|
+
if (!isWatchRouteSelected(route, watchRouteSelection))
|
|
1026
|
+
continue;
|
|
1027
|
+
for (const key of Object.keys(journal.files)) {
|
|
1028
|
+
paths.push(route.kind === "personal" ? key : `companies/${route.slug}/${key}`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return paths;
|
|
1032
|
+
};
|
|
838
1033
|
const prepareWatcherChanges = (changes) => {
|
|
839
1034
|
const journals = new Map();
|
|
840
1035
|
const dirty = new Set();
|
|
@@ -933,12 +1128,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
933
1128
|
}
|
|
934
1129
|
return false;
|
|
935
1130
|
};
|
|
936
|
-
const runScopedDrain = async (batch, watcherBatchGeneration) => {
|
|
1131
|
+
const runScopedDrain = async (batch, watcherBatchGeneration, wakeOnPushFailure = false) => {
|
|
937
1132
|
const grouped = batch && batch.paths.size > 0 ? groupBatchByRoute(batch) : null;
|
|
938
1133
|
const skipCompanies = grouped ? resolveSkipCompanies() : null;
|
|
939
1134
|
const isPausedRoute = (route) => route.kind === "company" &&
|
|
940
1135
|
(skipCompanies?.has(route.slug.toLowerCase()) ?? false);
|
|
941
|
-
if (
|
|
1136
|
+
if (watcherDrainRunsPush && grouped) {
|
|
942
1137
|
for (const group of grouped.values()) {
|
|
943
1138
|
// Paused workspaces: drop the scoped push without uploading or
|
|
944
1139
|
// publishing event-sync metadata (path/hash must not leak while Off).
|
|
@@ -947,9 +1142,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
947
1142
|
}
|
|
948
1143
|
const scopedArgv = buildScopedPushArgv(group.route, group.relPaths, passArgv, group.deleteScopeRoots);
|
|
949
1144
|
try {
|
|
1145
|
+
const lane = isFastLocalBatch(group.paths.keys(), group.changes)
|
|
1146
|
+
? "fast"
|
|
1147
|
+
: "slow";
|
|
950
1148
|
const result = await runGuarded(scopedArgv, group.changes.size > 0
|
|
951
1149
|
? () => prepareWatcherChanges(group.changes)
|
|
952
|
-
: undefined);
|
|
1150
|
+
: undefined, lane);
|
|
953
1151
|
if (passExitCode(result) === 0) {
|
|
954
1152
|
const refusedPaths = new Set(typeof result === "number"
|
|
955
1153
|
? []
|
|
@@ -974,11 +1172,15 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
974
1172
|
}
|
|
975
1173
|
else {
|
|
976
1174
|
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
1175
|
+
if (wakeOnPushFailure)
|
|
1176
|
+
interruptWait();
|
|
977
1177
|
}
|
|
978
1178
|
}
|
|
979
1179
|
catch (err) {
|
|
980
1180
|
restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
|
|
981
1181
|
process.stderr.write(`watch scoped push failed, will retry next tick: ${describeError(err)}\n`);
|
|
1182
|
+
if (wakeOnPushFailure)
|
|
1183
|
+
interruptWait();
|
|
982
1184
|
}
|
|
983
1185
|
}
|
|
984
1186
|
}
|
|
@@ -1006,7 +1208,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1006
1208
|
continue;
|
|
1007
1209
|
ranPersonalDrain = true;
|
|
1008
1210
|
}
|
|
1009
|
-
const exit = passExitCode(await runGuarded(pullArgv));
|
|
1211
|
+
const exit = passExitCode(await runGuarded(pullArgv, undefined, isFastLocalBatch(group.paths.keys(), group.changes) ? "fast" : "slow"));
|
|
1010
1212
|
if (exit !== 0 && worstExit === 0)
|
|
1011
1213
|
worstExit = exit;
|
|
1012
1214
|
}
|
|
@@ -1048,7 +1250,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1048
1250
|
skipCompanies.has(route.slug.toLowerCase())) {
|
|
1049
1251
|
continue;
|
|
1050
1252
|
}
|
|
1051
|
-
if (
|
|
1253
|
+
if (watcherDrainRunsPush) {
|
|
1052
1254
|
// Whole-route push (no --scope-path): the dropped paths are unknown,
|
|
1053
1255
|
// so the route's full walk is the narrowest sound scope.
|
|
1054
1256
|
const exit = passExitCode(await runGuarded(buildScopedPushArgv(route, [], passArgv)));
|
|
@@ -1140,6 +1342,31 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1140
1342
|
watcher.onChange((changedRelPath, batch) => {
|
|
1141
1343
|
if (stopped)
|
|
1142
1344
|
return;
|
|
1345
|
+
// A complete, small watcher batch is dispatched immediately while a
|
|
1346
|
+
// bulk pass is in flight. Leaving it in the next poll iteration would
|
|
1347
|
+
// still make it wait for that pass even though the fast lane exists.
|
|
1348
|
+
const selectedBatch = batch ? selectWatchBatch(batch) : null;
|
|
1349
|
+
if (selectedBatch &&
|
|
1350
|
+
!selectedBatch.overflowed &&
|
|
1351
|
+
activeSlowPass !== null &&
|
|
1352
|
+
isFastLocalBatch(selectedBatch.paths.keys(), selectedBatch.changes)) {
|
|
1353
|
+
const generation = ++nextWatcherBatchGeneration;
|
|
1354
|
+
// Fast dispatch bypasses the pending-batch ingress, so preserve both
|
|
1355
|
+
// of its contracts here: only selected routes may run, and V2 sees
|
|
1356
|
+
// the same dirty signal as the compatibility drain.
|
|
1357
|
+
realtimeState.scheduler?.signal("watcher");
|
|
1358
|
+
void runScopedDrain(selectedBatch, generation, true).then((exit) => {
|
|
1359
|
+
if (exit === 0)
|
|
1360
|
+
return;
|
|
1361
|
+
restorePendingWatcherPaths(selectedBatch.paths, generation);
|
|
1362
|
+
interruptWait();
|
|
1363
|
+
}).catch((err) => {
|
|
1364
|
+
restorePendingWatcherPaths(selectedBatch.paths, generation);
|
|
1365
|
+
process.stderr.write(`watch fast-lane scoped push failed, will retry next tick: ${describeError(err)}\n`);
|
|
1366
|
+
interruptWait();
|
|
1367
|
+
});
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1143
1370
|
if (addPendingWatcherChange(changedRelPath, batch)) {
|
|
1144
1371
|
// The V1 watcher batch keeps its existing compatibility behavior. A
|
|
1145
1372
|
// V2-enrolled scope observes the same dirty hint through its own
|
|
@@ -1172,6 +1399,30 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1172
1399
|
// initial walk will finish.
|
|
1173
1400
|
watcherRediscoveryRetries = 0;
|
|
1174
1401
|
process.stderr.write(`hq-sync-runner: event-push watcher active (watched-paths=${watchedPaths ?? "unknown"})\n`);
|
|
1402
|
+
const startedAt = Date.now();
|
|
1403
|
+
try {
|
|
1404
|
+
const catchup = watcher.collectWarmupCatchup?.(serviceStartedAt - WATCHER_WARMUP_SAFETY_MARGIN_MS, warmupJournalPaths());
|
|
1405
|
+
if (!catchup)
|
|
1406
|
+
return;
|
|
1407
|
+
const queued = addPendingWatcherChange(undefined, catchup.batch);
|
|
1408
|
+
pendingWatcherWarmupCatchup ||= queued;
|
|
1409
|
+
process.stderr.write(JSON.stringify({
|
|
1410
|
+
type: "watcher-warmup-catchup",
|
|
1411
|
+
reason: "watch-set-ready",
|
|
1412
|
+
pathsFound: catchup.batch.paths.size,
|
|
1413
|
+
scannedPaths: catchup.scannedPaths,
|
|
1414
|
+
elapsedMs: Date.now() - startedAt,
|
|
1415
|
+
queued,
|
|
1416
|
+
}) + "\n");
|
|
1417
|
+
if (queued)
|
|
1418
|
+
interruptWait();
|
|
1419
|
+
}
|
|
1420
|
+
catch (err) {
|
|
1421
|
+
process.stderr.write(`hq-sync-runner: watcher warm-up catch-up failed: ${describeError(err)}\n`);
|
|
1422
|
+
// A scan failure must not silently extend the warm-up blind spot.
|
|
1423
|
+
pendingWatcherWarmupCatchupFailed = true;
|
|
1424
|
+
interruptWait();
|
|
1425
|
+
}
|
|
1175
1426
|
};
|
|
1176
1427
|
if (watcher.onReady)
|
|
1177
1428
|
watcher.onReady(reportWatcherReady);
|
|
@@ -1209,20 +1460,27 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1209
1460
|
// lock.
|
|
1210
1461
|
interruptWait();
|
|
1211
1462
|
const pathsByRoute = new Map();
|
|
1463
|
+
let eligibleEventCount = 0;
|
|
1212
1464
|
for (const event of events) {
|
|
1213
1465
|
const route = routeChangeToTarget(event.relativePath);
|
|
1214
1466
|
if (!route)
|
|
1215
1467
|
continue;
|
|
1468
|
+
eligibleEventCount += 1;
|
|
1216
1469
|
const key = routeKey(route);
|
|
1217
1470
|
const group = pathsByRoute.get(key) ?? { route, paths: [] };
|
|
1218
1471
|
group.paths.push(event.relativePath);
|
|
1219
1472
|
pathsByRoute.set(key, group);
|
|
1220
1473
|
}
|
|
1474
|
+
// One receiver delivery can fan out to many routes. Apply the advertised
|
|
1475
|
+
// cap to its whole eligible payload, not independently to each route.
|
|
1476
|
+
const lane = isFastReceiverBatch(eligibleEventCount)
|
|
1477
|
+
? "fast"
|
|
1478
|
+
: "slow";
|
|
1221
1479
|
for (const { route, paths } of pathsByRoute.values()) {
|
|
1222
1480
|
// One guarded targeted pass per affected route, with every coalesced
|
|
1223
1481
|
// path for that route expressed through the watcher's existing argv seam.
|
|
1224
1482
|
const targetedArgv = buildTargetedPullArgv(route, passArgv, paths);
|
|
1225
|
-
const result = await runGuarded(targetedArgv);
|
|
1483
|
+
const result = await runGuarded(targetedArgv, undefined, lane);
|
|
1226
1484
|
if (passExitCode(result) !== 0) {
|
|
1227
1485
|
throw new Error(`targeted pull failed with exit code ${passExitCode(result)}`);
|
|
1228
1486
|
}
|
|
@@ -1240,10 +1498,23 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1240
1498
|
void deps.startRealtimeScheduler({
|
|
1241
1499
|
hqRoot,
|
|
1242
1500
|
runGuarded: async (drain) => {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1501
|
+
let outcome;
|
|
1502
|
+
try {
|
|
1503
|
+
outcome = await runGuardedTask(() => runTaskWithLock(async () => {
|
|
1504
|
+
await drain();
|
|
1505
|
+
return 0;
|
|
1506
|
+
}));
|
|
1507
|
+
}
|
|
1508
|
+
catch (err) {
|
|
1509
|
+
// Scheduler clients may have already been disposed when a queued
|
|
1510
|
+
// drain finally reaches the lock. Surface the late failure, but do
|
|
1511
|
+
// not leak an unhandled rejection after shutdown has completed.
|
|
1512
|
+
if (stopped) {
|
|
1513
|
+
process.stderr.write("realtime scheduler: cancelled drain failed after shutdown\n");
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
throw err;
|
|
1517
|
+
}
|
|
1247
1518
|
if (passExitCode(outcome) !== 0) {
|
|
1248
1519
|
throw new Error(`realtime scheduler drain exited ${passExitCode(outcome)}`);
|
|
1249
1520
|
}
|
|
@@ -1385,6 +1656,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1385
1656
|
const eventPushSurfacesInactive = eventPush && !eventPushSurfacesActivated;
|
|
1386
1657
|
const firstTick = pollTick === 1;
|
|
1387
1658
|
const bareWake = pending.bare;
|
|
1659
|
+
const watcherWarmupCatchup = pending.warmupCatchup;
|
|
1660
|
+
const watcherWarmupCatchupFailed = pending.warmupCatchupFailed;
|
|
1388
1661
|
const overflowRoutesUnknown = pending.batch?.overflowed === true && !overflowWithKnownRoutes;
|
|
1389
1662
|
const scheduledInterval = fullReconcileIntervalMs === undefined
|
|
1390
1663
|
? pollTick % FULL_RECONCILE_EVERY_TICKS === 0
|
|
@@ -1396,6 +1669,8 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1396
1669
|
"bare-wake": bareWake,
|
|
1397
1670
|
"batch-over-limit": batchOverLimit,
|
|
1398
1671
|
"overflow-routes-unknown": overflowRoutesUnknown,
|
|
1672
|
+
"watcher-warmup-catch-up": watcherWarmupCatchup,
|
|
1673
|
+
"watcher-warmup-catch-up-failed": watcherWarmupCatchupFailed,
|
|
1399
1674
|
"scheduled-interval": scheduledInterval,
|
|
1400
1675
|
};
|
|
1401
1676
|
const reasons = Object.entries(triggers)
|
|
@@ -1406,6 +1681,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1406
1681
|
bareWake ||
|
|
1407
1682
|
batchOverLimit ||
|
|
1408
1683
|
overflowRoutesUnknown ||
|
|
1684
|
+
watcherWarmupCatchupFailed ||
|
|
1409
1685
|
scheduledInterval;
|
|
1410
1686
|
const emitDecision = (mode, decisionReasons) => {
|
|
1411
1687
|
emitFullReconcileDecision({
|
|
@@ -1441,7 +1717,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
|
|
|
1441
1717
|
// were dropped); ordinary batches remain path-scoped. Label those
|
|
1442
1718
|
// distinct scopes so operators can distinguish them from a full
|
|
1443
1719
|
// all-vault reconcile.
|
|
1444
|
-
emitDecision(
|
|
1720
|
+
emitDecision(watcherWarmupCatchup
|
|
1721
|
+
? "catch-up"
|
|
1722
|
+
: overflowWithKnownRoutes
|
|
1723
|
+
? "route"
|
|
1724
|
+
: "scoped", reasons);
|
|
1445
1725
|
}
|
|
1446
1726
|
catch (err) {
|
|
1447
1727
|
// Never silently drop a change: any failure inside the targeted
|