cross-tab-worker-databus 0.20.57 → 0.20.59

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/CHANGELOG.md CHANGED
@@ -1,4 +1,21 @@
1
1
  # Changelog
2
+ ## [0.20.59] - 2026-09-04
3
+
4
+ ### Added
5
+ - Extended `CrossTabDataBus.getRecoveryStats()` with `generation` (monotonic counter incremented on every successful transport open) and `lastSuccessAt` (timestamp of the most recent successful open, or `null` until the transport reaches `ready`).
6
+ - Added a unit test asserting generation/lastSuccessAt advance on the initial start and after a recovery, and stay stable across failed recovery attempts.
7
+
8
+ ## [0.20.58] - 2026-09-04
9
+
10
+ ### Changed
11
+ - Bounded the publish route-owner cache to a configurable LRU cap (default 256), evicting the oldest entry on overflow.
12
+ - Surfaced route-owner cache diagnostics (`size`, `max`, `hits`, `misses`) on `WorkerClusterRuntime.getSnapshot()` so callers can observe warm vs. cold route resolution.
13
+ - Fixed a correctness bug where `wildcardPublishCache`'s `null` entry short-circuited the remote-owner publish path; topics with no local wildcard subscription now still consult the route-owner cache and forward to the remote owner.
14
+
15
+ ### Added
16
+ - Added `routeOwnerCacheMax` to `WorkerClusterOptions` for tuning the route-owner cache size per workload.
17
+ - Added unit tests for LRU eviction, TTL-based cache invalidation, owner migration, and remote-owner cache hits.
18
+
2
19
  ## [0.20.57] - 2026-09-04
3
20
 
4
21
  ### Added
@@ -26,8 +43,6 @@
26
43
 
27
44
  - Extended `getRecoveryStats()` with a safe `errorMessage` summary for diagnostics without exposing the raw error object.
28
45
 
29
- ## [Unreleased]
30
-
31
46
  ## [0.20.52] - 2026-09-04
32
47
 
33
48
  ### Added
@@ -2,7 +2,7 @@ import {
2
2
  CrossTabDataBus,
3
3
  parseDataBusPublication,
4
4
  selectWorkerBackend
5
- } from "./chunk-WAQJT3EE.js";
5
+ } from "./chunk-RAUX7RZC.js";
6
6
 
7
7
  // src/centrifuge-session.ts
8
8
  import { Centrifuge } from "centrifuge";
@@ -337,6 +337,18 @@ var WorkerClusterRuntime = class {
337
337
  // (or local self-subscribe), never via the reverse cache.
338
338
  assignedTopics = /* @__PURE__ */ new Map();
339
339
  routeOwnerCache = /* @__PURE__ */ new Map();
340
+ routeOwnerCacheMax;
341
+ routeOwnerCacheHits = 0;
342
+ routeOwnerCacheMisses = 0;
343
+ touchRouteOwnerCache(topicKey, value) {
344
+ if (this.routeOwnerCache.has(topicKey)) this.routeOwnerCache.delete(topicKey);
345
+ this.routeOwnerCache.set(topicKey, value);
346
+ while (this.routeOwnerCache.size > this.routeOwnerCacheMax) {
347
+ const oldest = this.routeOwnerCache.keys().next().value;
348
+ if (oldest === void 0) break;
349
+ this.routeOwnerCache.delete(oldest);
350
+ }
351
+ }
340
352
  wildcardPublishCache = /* @__PURE__ */ new Map();
341
353
  // Reverse mapping: opaque topicKey → plaintext topic. A bounded cache with
342
354
  // FIFO eviction — NOT authoritative. It can hold a topicKey that is also in
@@ -355,6 +367,7 @@ var WorkerClusterRuntime = class {
355
367
  this.handlers = options.handlers;
356
368
  this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
357
369
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
370
+ this.routeOwnerCacheMax = options.routeOwnerCacheMax ?? 256;
358
371
  this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
359
372
  const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
360
373
  const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
@@ -547,25 +560,26 @@ var WorkerClusterRuntime = class {
547
560
  return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
548
561
  }
549
562
  const cachedPattern = this.wildcardPublishCache.get(topic);
550
- if (cachedPattern !== void 0) {
551
- if (cachedPattern === null || this.assignedTopics.has(cachedPattern)) {
552
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
553
- }
554
- this.wildcardPublishCache.delete(topic);
563
+ if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
564
+ return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
555
565
  }
556
- for (const pattern of this.assignedTopics.values()) {
557
- if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
558
- this.wildcardPublishCache.set(topic, pattern);
559
- return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
566
+ if (cachedPattern === void 0) {
567
+ for (const pattern of this.assignedTopics.values()) {
568
+ if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
569
+ this.wildcardPublishCache.set(topic, pattern);
570
+ return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
571
+ }
560
572
  }
573
+ this.wildcardPublishCache.set(topic, null);
561
574
  }
562
- this.wildcardPublishCache.set(topic, null);
563
575
  const workers = this.readWorkers();
564
576
  const route = this.readRoute(topicKey);
565
577
  const cached = this.routeOwnerCache.get(topicKey);
566
578
  const cachedLive = cached && route && route.generation === cached.generation && route.workerId === cached.workerId && workers.some((worker) => worker.workerId === cached.workerId);
579
+ if (cachedLive) this.routeOwnerCacheHits += 1;
580
+ else this.routeOwnerCacheMisses += 1;
567
581
  const target = cachedLive ? cached.workerId : this.routeOwnerIsLive(route, workers) ? route?.workerId ?? this.workerId : this.workerId;
568
- if (route && target === route.workerId) this.routeOwnerCache.set(topicKey, { workerId: route.workerId, generation: route.generation });
582
+ if (route && target === route.workerId) this.touchRouteOwnerCache(topicKey, { workerId: route.workerId, generation: route.generation });
569
583
  else this.routeOwnerCache.delete(topicKey);
570
584
  return this.sendControl(target, "PUBLISH", topic, topicKey, data, metadata);
571
585
  }
@@ -622,7 +636,8 @@ var WorkerClusterRuntime = class {
622
636
  routes,
623
637
  subscribedTopics: Array.from(this.subscribedTopics),
624
638
  assignedTopics: Array.from(this.assignedTopics.values()),
625
- knownTopics: Array.from(this.knownTopics.entries(), ([topicKey, topic]) => ({ topicKey, topic }))
639
+ knownTopics: Array.from(this.knownTopics.entries(), ([topicKey, topic]) => ({ topicKey, topic })),
640
+ routeOwnerCache: { size: this.routeOwnerCache.size, max: this.routeOwnerCacheMax, hits: this.routeOwnerCacheHits, misses: this.routeOwnerCacheMisses }
626
641
  };
627
642
  }
628
643
  handlePageHide = () => this.pause();
@@ -1301,6 +1316,13 @@ var CrossTabDataBus = class {
1301
1316
  // a transport reopen succeeds so traces can correlate repeated failures.
1302
1317
  recoveryAttempt = 0;
1303
1318
  recoveryExhausted = false;
1319
+ /** Monotonic generation incremented on every successful transport open.
1320
+ * Stays in lockstep with `lastSuccessAt` so callers can detect that the
1321
+ * transport has been reopened even if the timestamp window is short. */
1322
+ recoveryGeneration = 0;
1323
+ /** Timestamp of the most recent successful transport open. Null until the
1324
+ * transport has reached the `ready` state at least once. */
1325
+ lastSuccessAt = null;
1304
1326
  // True while the tab is hidden so an in-flight transport start does not mark
1305
1327
  // the transport ready after suspendTransport() has stopped it.
1306
1328
  suspended = false;
@@ -1494,7 +1516,11 @@ var CrossTabDataBus = class {
1494
1516
  if (this.status === "error") {
1495
1517
  throw new Error("Transport failed during startup.");
1496
1518
  }
1497
- if (!this.suspended && !this.stopping) this.transportReady = true;
1519
+ if (!this.suspended && !this.stopping) {
1520
+ this.recoveryGeneration += 1;
1521
+ this.lastSuccessAt = this.now();
1522
+ this.transportReady = true;
1523
+ }
1498
1524
  });
1499
1525
  }).catch((error) => {
1500
1526
  if (stopClusterOnFailure) this.started = false;
@@ -1664,9 +1690,22 @@ var CrossTabDataBus = class {
1664
1690
  return this.status;
1665
1691
  }
1666
1692
  /** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
1693
+ /** Return the current automatic transport recovery state plus diagnostics.
1694
+ * `generation` increments on every successful transport open (initial start
1695
+ * and every recovery); `lastSuccessAt` is the timestamp of the most recent
1696
+ * successful open, or `null` until the transport reaches `ready`. */
1667
1697
  getRecoveryStats() {
1668
1698
  const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
1669
- return { attempt: this.recoveryAttempt, exhausted: this.recoveryExhausted, maxAttempts: this.recoveryMaxAttempts, hasError: this.lastError !== null, errorMessage, errorAt: this.lastErrorAt };
1699
+ return {
1700
+ attempt: this.recoveryAttempt,
1701
+ exhausted: this.recoveryExhausted,
1702
+ maxAttempts: this.recoveryMaxAttempts,
1703
+ hasError: this.lastError !== null,
1704
+ errorMessage,
1705
+ errorAt: this.lastErrorAt,
1706
+ generation: this.recoveryGeneration,
1707
+ lastSuccessAt: this.lastSuccessAt
1708
+ };
1670
1709
  }
1671
1710
  /** Snapshot of the cluster state (workers, routes, assignments).
1672
1711
  * For diagnostics only — the returned object is a shallow copy but
@@ -2143,4 +2182,4 @@ export {
2143
2182
  parseDataBusPublication,
2144
2183
  selectWorkerBackend
2145
2184
  };
2146
- //# sourceMappingURL=chunk-WAQJT3EE.js.map
2185
+ //# sourceMappingURL=chunk-RAUX7RZC.js.map