@indigoai-us/hq-cloud 6.15.75 → 6.15.76

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/watcher.js CHANGED
@@ -30,8 +30,18 @@ const DEBOUNCE_MS = 2000;
30
30
  */
31
31
  export const DEFAULT_EVENT_DEBOUNCE_MS = 15_000;
32
32
  export const DEFAULT_EVENT_MAX_WAIT_MS = 120_000;
33
+ /**
34
+ * A small, quiet save should not pay the full burst-coalescing window. This
35
+ * is deliberately still long enough to absorb editor atomic-save chatter.
36
+ */
37
+ export const DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS = 2_500;
38
+ export const DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS = 8;
39
+ export const DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES = 1024 * 1024;
33
40
  export const EVENT_DEBOUNCE_MS_ENV = "HQ_SYNC_EVENT_DEBOUNCE_MS";
34
41
  export const EVENT_MAX_WAIT_MS_ENV = "HQ_SYNC_EVENT_MAX_WAIT_MS";
42
+ export const EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_DEBOUNCE_MS";
43
+ export const EVENT_SMALL_BATCH_MAX_PATHS_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_MAX_PATHS";
44
+ export const EVENT_SMALL_BATCH_MAX_BYTES_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_MAX_BYTES";
35
45
  /**
36
46
  * Resolve the event-pass debounce configuration from the environment.
37
47
  * `HQ_SYNC_EVENT_DEBOUNCE_MS` / `HQ_SYNC_EVENT_MAX_WAIT_MS`; 0, negative,
@@ -51,7 +61,15 @@ export function resolveEventDebounceConfig(env = process.env) {
51
61
  };
52
62
  const debounceMs = read(EVENT_DEBOUNCE_MS_ENV, DEFAULT_EVENT_DEBOUNCE_MS);
53
63
  const maxWaitMs = Math.max(debounceMs, read(EVENT_MAX_WAIT_MS_ENV, DEFAULT_EVENT_MAX_WAIT_MS));
54
- return { debounceMs, maxWaitMs };
64
+ return {
65
+ debounceMs,
66
+ maxWaitMs,
67
+ // Never let a tuning error turn the small-batch path into a longer delay
68
+ // than the ordinary debounce window.
69
+ smallBatchDebounceMs: Math.min(debounceMs, read(EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV, DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS)),
70
+ smallBatchMaxPaths: read(EVENT_SMALL_BATCH_MAX_PATHS_ENV, DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS),
71
+ smallBatchMaxBytes: read(EVENT_SMALL_BATCH_MAX_BYTES_ENV, DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES),
72
+ };
55
73
  }
56
74
  /**
57
75
  * A Linux chokidar watcher derives its directory budget from the host's real
@@ -907,6 +925,9 @@ function estimatePendingEntryBytes(absolutePath, relativePath) {
907
925
  export class TreeWatcher {
908
926
  hqRoot;
909
927
  debounceMs;
928
+ smallBatchDebounceMs;
929
+ smallBatchMaxPaths;
930
+ smallBatchMaxBytes;
910
931
  maxWaitMs;
911
932
  clock;
912
933
  shouldEmit;
@@ -935,6 +956,8 @@ export class TreeWatcher {
935
956
  /** Paths accumulated for the current (in-flight) debounce window. */
936
957
  pending = new Map();
937
958
  pendingChanges = new Map();
959
+ /** Latest known payload bytes per path; null makes the whole batch bulk. */
960
+ pendingPayloadSizes = new Map();
938
961
  knownKinds = new Map();
939
962
  pendingBytes = 0;
940
963
  overflowed = false;
@@ -956,6 +979,12 @@ export class TreeWatcher {
956
979
  constructor(opts) {
957
980
  this.hqRoot = opts.hqRoot;
958
981
  this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
982
+ this.smallBatchDebounceMs =
983
+ opts.smallBatchDebounceMs !== undefined && opts.smallBatchDebounceMs > 0
984
+ ? Math.min(this.debounceMs, Math.floor(opts.smallBatchDebounceMs))
985
+ : null;
986
+ this.smallBatchMaxPaths = Math.max(1, Math.floor(opts.smallBatchMaxPaths ?? 1));
987
+ this.smallBatchMaxBytes = Math.max(0, Math.floor(opts.smallBatchMaxBytes ?? 0));
959
988
  this.maxWaitMs =
960
989
  opts.maxWaitMs !== undefined && opts.maxWaitMs > 0
961
990
  ? Math.max(opts.maxWaitMs, this.debounceMs)
@@ -1343,6 +1372,7 @@ export class TreeWatcher {
1343
1372
  this.arm();
1344
1373
  return;
1345
1374
  }
1375
+ this.pendingPayloadSizes.set(abs, this.payloadSizeFor(abs, kind));
1346
1376
  const deleteSnapshotEpoch = (this.deleteSnapshotEpochs.get(abs) ?? 0) + 1;
1347
1377
  this.deleteSnapshotEpochs.set(abs, deleteSnapshotEpoch);
1348
1378
  let deleteSnapshots = [];
@@ -1371,6 +1401,7 @@ export class TreeWatcher {
1371
1401
  // A newer live revision supersedes any queued unlink capture for this
1372
1402
  // path before that job is allowed to acquire and mutate the journal.
1373
1403
  this.deleteSnapshotEpochs.set(abs, (this.deleteSnapshotEpochs.get(abs) ?? 0) + 1);
1404
+ this.pendingPayloadSizes.set(abs, this.payloadSizeFor(abs, kind));
1374
1405
  this.pendingChanges.set(abs, { kind });
1375
1406
  // Only directories are needed to disambiguate a native recursive
1376
1407
  // rename-after-delete. Unknown paths already correctly default to an
@@ -1495,11 +1526,14 @@ export class TreeWatcher {
1495
1526
  if (this.windowDeadlineAt === null && Number.isFinite(this.maxWaitMs)) {
1496
1527
  this.windowDeadlineAt = now + this.maxWaitMs;
1497
1528
  }
1529
+ const quietWindowMs = this.isSmallPendingBatch()
1530
+ ? this.smallBatchDebounceMs
1531
+ : this.debounceMs;
1498
1532
  // Quiet-period debounce, capped by the window's max-wait deadline so a
1499
1533
  // continuous event stream cannot postpone the emit forever.
1500
1534
  const delayMs = this.windowDeadlineAt === null
1501
- ? this.debounceMs
1502
- : Math.max(0, Math.min(this.debounceMs, this.windowDeadlineAt - now));
1535
+ ? quietWindowMs
1536
+ : Math.max(0, Math.min(quietWindowMs, this.windowDeadlineAt - now));
1503
1537
  this.timer = this.clock.setTimeout(() => {
1504
1538
  this.timer = null;
1505
1539
  this.emit();
@@ -1541,6 +1575,7 @@ export class TreeWatcher {
1541
1575
  this.pendingStartedAt = null;
1542
1576
  this.pending.clear();
1543
1577
  this.pendingChanges.clear();
1578
+ this.pendingPayloadSizes.clear();
1544
1579
  this.pendingBytes = 0;
1545
1580
  this.overflowed = false;
1546
1581
  this.overflowLogged = false;
@@ -1549,6 +1584,39 @@ export class TreeWatcher {
1549
1584
  this.droppedRouteHints.clear();
1550
1585
  this.droppedRoutesUnknown = false;
1551
1586
  }
1587
+ payloadSizeFor(absolutePath, kind) {
1588
+ // Exact deletes transfer no content; directory operations can expand to an
1589
+ // arbitrary subtree and therefore never take the latency shortcut.
1590
+ if (kind === "unlink")
1591
+ return 0;
1592
+ if (kind === "unlinkDir" || kind === "addDir")
1593
+ return null;
1594
+ try {
1595
+ const stat = fs.lstatSync(absolutePath);
1596
+ return stat.isFile() ? stat.size : null;
1597
+ }
1598
+ catch {
1599
+ // An upsert whose bytes cannot be read is not proven small.
1600
+ return null;
1601
+ }
1602
+ }
1603
+ isSmallPendingBatch() {
1604
+ if (this.smallBatchDebounceMs === null ||
1605
+ this.overflowed ||
1606
+ this.pending.size === 0 ||
1607
+ this.pending.size > this.smallBatchMaxPaths) {
1608
+ return false;
1609
+ }
1610
+ let bytes = 0;
1611
+ for (const size of this.pendingPayloadSizes.values()) {
1612
+ if (size === null)
1613
+ return false;
1614
+ bytes += size;
1615
+ if (bytes > this.smallBatchMaxBytes)
1616
+ return false;
1617
+ }
1618
+ return true;
1619
+ }
1552
1620
  /**
1553
1621
  * Recover mutations that happened while chokidar was building its initial
1554
1622
  * watch set. `ignoreInitial` intentionally suppresses those initial events,
@@ -1709,6 +1777,19 @@ async function computeContentHash(absolutePath) {
1709
1777
  }
1710
1778
  /** Maximum paths remembered by one emitter to suppress unchanged announcements. */
1711
1779
  export const DEFAULT_PUBLISHED_CONTENT_HASH_MAX_PATHS = 50_000;
1780
+ /** Minimum delay between successful upsert announcements for one path. */
1781
+ export const DEFAULT_PUBLISH_MIN_INTERVAL_MS = 60_000;
1782
+ export const PUBLISH_MIN_INTERVAL_MS_ENV = "HQ_SYNC_PUBLISH_MIN_INTERVAL_MS";
1783
+ /** Resolve the optional per-path announcement interval without accepting invalid values. */
1784
+ export function resolvePublishMinIntervalMs(env = process.env) {
1785
+ const raw = env[PUBLISH_MIN_INTERVAL_MS_ENV];
1786
+ if (raw === undefined || raw.trim() === "")
1787
+ return DEFAULT_PUBLISH_MIN_INTERVAL_MS;
1788
+ const value = Number(raw);
1789
+ return Number.isFinite(value) && value >= 0
1790
+ ? Math.floor(value)
1791
+ : DEFAULT_PUBLISH_MIN_INTERVAL_MS;
1792
+ }
1712
1793
  const PUBLISHED_CONTENT_HASH_RECORD_SET = 0x5031;
1713
1794
  const PUBLISHED_CONTENT_HASH_RECORD_DELETE = 0x5032;
1714
1795
  /**
@@ -1797,6 +1878,7 @@ function reduceDurablePublishedContentHashState(state, record) {
1797
1878
  class BoundedPublishedContentHashes {
1798
1879
  maxPaths;
1799
1880
  hashes = new Map();
1881
+ publishedAt = new Map();
1800
1882
  constructor(maxPaths) {
1801
1883
  this.maxPaths = Math.max(1, Math.floor(maxPaths ?? DEFAULT_PUBLISHED_CONTENT_HASH_MAX_PATHS));
1802
1884
  }
@@ -1808,18 +1890,27 @@ class BoundedPublishedContentHashes {
1808
1890
  this.hashes.set(relativePath, hash);
1809
1891
  return hash;
1810
1892
  }
1811
- set(relativePath, contentHash) {
1893
+ set(relativePath, contentHash, publishedAt) {
1812
1894
  this.hashes.delete(relativePath);
1813
1895
  this.hashes.set(relativePath, contentHash);
1896
+ if (publishedAt !== undefined)
1897
+ this.publishedAt.set(relativePath, publishedAt);
1898
+ else
1899
+ this.publishedAt.delete(relativePath);
1814
1900
  while (this.hashes.size > this.maxPaths) {
1815
1901
  const oldest = this.hashes.keys().next().value;
1816
1902
  if (oldest === undefined)
1817
1903
  return;
1818
1904
  this.hashes.delete(oldest);
1905
+ this.publishedAt.delete(oldest);
1819
1906
  }
1820
1907
  }
1908
+ getPublishedAt(relativePath) {
1909
+ return this.publishedAt.get(relativePath);
1910
+ }
1821
1911
  delete(relativePath) {
1822
1912
  this.hashes.delete(relativePath);
1913
+ this.publishedAt.delete(relativePath);
1823
1914
  }
1824
1915
  }
1825
1916
  /** Successful attempts retained in the batch outcome record for context. */
@@ -1879,6 +1970,7 @@ export class PushEventEmitter {
1879
1970
  transport;
1880
1971
  flagProvider;
1881
1972
  now;
1973
+ clock;
1882
1974
  onError;
1883
1975
  onPublishOutcome;
1884
1976
  logger;
@@ -1887,6 +1979,15 @@ export class PushEventEmitter {
1887
1979
  lastPublishedContentHashes;
1888
1980
  durablePublishedContentHashes;
1889
1981
  lastPublishedContentHashMaxPaths;
1982
+ publishMinIntervalMs;
1983
+ /**
1984
+ * A captured event whose bytes were already uploaded by the scoped push that
1985
+ * called emitForBatch. Never retain only a path here: a later local rewrite
1986
+ * can otherwise make the trailing announcement describe bytes absent from S3.
1987
+ */
1988
+ deferredPublishes = new Map();
1989
+ deferredPublishTimers = new Map();
1990
+ disposed = false;
1890
1991
  internalSeq = 0;
1891
1992
  nextSeq;
1892
1993
  publishTail = Promise.resolve();
@@ -1896,6 +1997,7 @@ export class PushEventEmitter {
1896
1997
  this.transport = opts.transport;
1897
1998
  this.flagProvider = opts.flagProvider;
1898
1999
  this.now = opts.now ?? (() => new Date());
2000
+ this.clock = opts.clock ?? systemClock;
1899
2001
  this.logger = opts.logger;
1900
2002
  this.telemetryClient = opts.telemetryClient;
1901
2003
  this.telemetryClaims = opts.telemetryClaims;
@@ -1905,6 +2007,11 @@ export class PushEventEmitter {
1905
2007
  this.lastPublishedContentHashMaxPaths = Math.max(1, Math.floor(opts.lastPublishedContentHashMaxPaths ??
1906
2008
  DEFAULT_PUBLISHED_CONTENT_HASH_MAX_PATHS));
1907
2009
  this.lastPublishedContentHashes = new BoundedPublishedContentHashes(this.lastPublishedContentHashMaxPaths);
2010
+ const configuredPublishMinIntervalMs = opts.publishMinIntervalMs ?? resolvePublishMinIntervalMs();
2011
+ this.publishMinIntervalMs =
2012
+ Number.isFinite(configuredPublishMinIntervalMs) && configuredPublishMinIntervalMs >= 0
2013
+ ? Math.floor(configuredPublishMinIntervalMs)
2014
+ : DEFAULT_PUBLISH_MIN_INTERVAL_MS;
1908
2015
  this.durablePublishedContentHashes = opts.lastPublishedContentHashStateDir
1909
2016
  ? this.openDurablePublishedContentHashes(opts.lastPublishedContentHashStateDir, opts.initialPublishedContentHashes ?? {})
1910
2017
  : null;
@@ -1942,7 +2049,7 @@ export class PushEventEmitter {
1942
2049
  * poll covers any miss).
1943
2050
  */
1944
2051
  async emitForBatch(batch) {
1945
- if (!this.enabled)
2052
+ if (this.disposed || !this.enabled)
1946
2053
  return;
1947
2054
  const entriesByRel = new Map();
1948
2055
  for (const [absolutePath, relativePath] of batch.paths.entries()) {
@@ -1960,6 +2067,9 @@ export class PushEventEmitter {
1960
2067
  const settled = await Promise.all(chunk.map(([absolutePath, relativePath]) => this.emitOne(absolutePath, relativePath)));
1961
2068
  attempts.push(...settled.filter((attempt) => attempt !== undefined));
1962
2069
  }
2070
+ this.reportPublishOutcome(attempts);
2071
+ }
2072
+ reportPublishOutcome(attempts) {
1963
2073
  try {
1964
2074
  this.onPublishOutcome(summarizePublishBatchOutcome(attempts));
1965
2075
  }
@@ -1977,13 +2087,12 @@ export class PushEventEmitter {
1977
2087
  return undefined;
1978
2088
  const contentHash = await computeContentHash(absolutePath);
1979
2089
  if (this.wasPublished(relativePath, contentHash)) {
2090
+ // This later, successfully-pushed revision restores the current
2091
+ // publication. Its stale deferred predecessor must not announce after
2092
+ // the scoped push has already replaced its S3 bytes.
2093
+ this.clearDeferredPublish(relativePath);
1980
2094
  return undefined;
1981
2095
  }
1982
- // A different observed revision breaks the old publication guarantee even
1983
- // if this transport attempt fails. Clearing first makes a later revert
1984
- // re-announce instead of silently treating it as the old content.
1985
- this.lastPublishedContentHashes.delete(relativePath);
1986
- await this.deleteDurablePublishedContentHash(relativePath);
1987
2096
  event = {
1988
2097
  kind: "upsert",
1989
2098
  relativePath,
@@ -1994,6 +2103,17 @@ export class PushEventEmitter {
1994
2103
  sequenceNumber: this.nextSeq(),
1995
2104
  eventTimestamp: this.now().toISOString(),
1996
2105
  };
2106
+ const lastPublishedAt = this.lastPublishedContentHashes.getPublishedAt(relativePath);
2107
+ if (lastPublishedAt !== undefined &&
2108
+ this.clock.now() - lastPublishedAt < this.publishMinIntervalMs) {
2109
+ this.deferUpsert(event, lastPublishedAt);
2110
+ return undefined;
2111
+ }
2112
+ // A different observed revision breaks the old publication guarantee even
2113
+ // if this transport attempt fails. Clearing first makes a later revert
2114
+ // re-announce instead of silently treating it as the old content.
2115
+ this.lastPublishedContentHashes.delete(relativePath);
2116
+ await this.deleteDurablePublishedContentHash(relativePath);
1997
2117
  }
1998
2118
  catch (err) {
1999
2119
  const code = err && typeof err === "object" && "code" in err
@@ -2011,6 +2131,7 @@ export class PushEventEmitter {
2011
2131
  // targeted pull and let the vault-confirmed tombstone path remove it.
2012
2132
  // Invalidate before the attempt for the same failure/recreation posture
2013
2133
  // as a changed upsert above.
2134
+ this.clearDeferredPublish(relativePath);
2014
2135
  this.lastPublishedContentHashes.delete(relativePath);
2015
2136
  await this.deleteDurablePublishedContentHash(relativePath);
2016
2137
  event = {
@@ -2022,6 +2143,9 @@ export class PushEventEmitter {
2022
2143
  eventTimestamp: this.now().toISOString(),
2023
2144
  };
2024
2145
  }
2146
+ return this.publishEvent(event);
2147
+ }
2148
+ async publishEvent(event) {
2025
2149
  // US-011: 1st link of the 3-log diagnostic chain. Stamps the same
2026
2150
  // `sequenceNumber` the server `push.receive` log and the client
2027
2151
  // `fanout.receive` log carry, so an operator can walk one event
@@ -2040,14 +2164,14 @@ export class PushEventEmitter {
2040
2164
  try {
2041
2165
  await this.transport.publish(event);
2042
2166
  if (event.kind === "upsert" && event.contentHash !== undefined) {
2043
- this.lastPublishedContentHashes.set(relativePath, event.contentHash);
2044
- await this.setDurablePublishedContentHash(relativePath, event.contentHash);
2167
+ this.lastPublishedContentHashes.set(event.relativePath, event.contentHash, this.clock.now());
2168
+ await this.setDurablePublishedContentHash(event.relativePath, event.contentHash);
2045
2169
  }
2046
2170
  else {
2047
2171
  // A later recreation with the same bytes is a real state transition,
2048
2172
  // so a successfully published tombstone must never suppress it.
2049
- this.lastPublishedContentHashes.delete(relativePath);
2050
- await this.deleteDurablePublishedContentHash(relativePath);
2173
+ this.lastPublishedContentHashes.delete(event.relativePath);
2174
+ await this.deleteDurablePublishedContentHash(event.relativePath);
2051
2175
  }
2052
2176
  const outcome = getPublishSuccessOutcome(event);
2053
2177
  return {
@@ -2059,7 +2183,7 @@ export class PushEventEmitter {
2059
2183
  // Push failure (network / non-2xx / timeout). The cadence poll is the
2060
2184
  // safety net — log + continue, never throw.
2061
2185
  this.onError(err instanceof Error ? err : new Error(String(err)), {
2062
- relativePath,
2186
+ relativePath: event.relativePath,
2063
2187
  });
2064
2188
  this.emitPublishFailureTelemetry(event.kind, "publish");
2065
2189
  return {
@@ -2117,6 +2241,53 @@ export class PushEventEmitter {
2117
2241
  });
2118
2242
  }
2119
2243
  }
2244
+ /** Remember one latest-wins uploaded revision until its publish interval ends. */
2245
+ deferUpsert(event, lastPublishedAt) {
2246
+ const relativePath = event.relativePath;
2247
+ this.deferredPublishes.delete(relativePath);
2248
+ this.deferredPublishes.set(relativePath, event);
2249
+ while (this.deferredPublishes.size > this.lastPublishedContentHashMaxPaths) {
2250
+ const oldest = this.deferredPublishes.keys().next().value;
2251
+ if (oldest === undefined)
2252
+ break;
2253
+ this.clearDeferredPublish(oldest);
2254
+ }
2255
+ if (this.deferredPublishTimers.has(relativePath))
2256
+ return;
2257
+ const delay = Math.max(0, lastPublishedAt + this.publishMinIntervalMs - this.clock.now());
2258
+ const timer = this.clock.setTimeout(() => this.flushDeferredPublish(relativePath), delay);
2259
+ this.deferredPublishTimers.set(relativePath, timer);
2260
+ }
2261
+ flushDeferredPublish(relativePath) {
2262
+ const deferred = this.deferredPublishes.get(relativePath);
2263
+ this.clearDeferredPublish(relativePath);
2264
+ if (this.disposed || !deferred)
2265
+ return;
2266
+ const run = this.publishTail.then(async () => {
2267
+ this.reportPublishOutcome([await this.publishEvent(deferred)]);
2268
+ }, async () => {
2269
+ this.reportPublishOutcome([await this.publishEvent(deferred)]);
2270
+ });
2271
+ this.publishTail = run.catch(() => undefined);
2272
+ }
2273
+ clearDeferredPublish(relativePath) {
2274
+ this.deferredPublishes.delete(relativePath);
2275
+ const timer = this.deferredPublishTimers.get(relativePath);
2276
+ if (timer !== undefined)
2277
+ this.clock.clearTimeout(timer);
2278
+ this.deferredPublishTimers.delete(relativePath);
2279
+ }
2280
+ /** Cancel every trailing publish and make future batch delivery inert. */
2281
+ dispose() {
2282
+ if (this.disposed)
2283
+ return;
2284
+ this.disposed = true;
2285
+ for (const timer of this.deferredPublishTimers.values()) {
2286
+ this.clock.clearTimeout(timer);
2287
+ }
2288
+ this.deferredPublishTimers.clear();
2289
+ this.deferredPublishes.clear();
2290
+ }
2120
2291
  emitPublishFailureTelemetry(kind, stage) {
2121
2292
  void emitCloudTelemetry(this.telemetryClient, {
2122
2293
  eventName: "push_event_failed",