cross-tab-worker-databus 0.20.71 → 0.20.85
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 +204 -2
- package/README.md +8 -0
- package/README.zh.md +4 -0
- package/dist/centrifuge-protocol.d.ts +59 -26
- package/dist/centrifuge-protocol.d.ts.map +1 -1
- package/dist/centrifuge-session.d.ts +9 -0
- package/dist/centrifuge-session.d.ts.map +1 -1
- package/dist/centrifuge.d.ts +12 -2
- package/dist/centrifuge.d.ts.map +1 -1
- package/dist/centrifuge.js +168 -88
- package/dist/centrifuge.js.map +3 -3
- package/dist/centrifuge.shared.worker.js +146 -38
- package/dist/centrifuge.shared.worker.js.map +3 -3
- package/dist/centrifuge.worker.js +140 -33
- package/dist/centrifuge.worker.js.map +3 -3
- package/dist/{chunk-D2SIT473.js → chunk-PW63EWIK.js} +1090 -494
- package/dist/chunk-PW63EWIK.js.map +7 -0
- package/dist/chunk-TZ7ZP7YD.js +175 -0
- package/dist/chunk-TZ7ZP7YD.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +1403 -637
- package/dist/cjs/centrifuge.cjs.map +4 -4
- package/dist/cjs/hooks.cjs +37 -2
- package/dist/cjs/hooks.cjs.map +3 -3
- package/dist/cjs/index.cjs +1453 -730
- package/dist/cjs/index.cjs.map +4 -4
- package/dist/cjs/vue.cjs +45 -2
- package/dist/cjs/vue.cjs.map +3 -3
- package/dist/core/cluster.d.ts +40 -4
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +93 -69
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/dedup-manager.d.ts +98 -0
- package/dist/core/dedup-manager.d.ts.map +1 -0
- package/dist/core/environment.d.ts +43 -3
- package/dist/core/environment.d.ts.map +1 -1
- package/dist/core/replay-manager.d.ts +128 -0
- package/dist/core/replay-manager.d.ts.map +1 -0
- package/dist/core/replay-persistence.d.ts +2 -1
- package/dist/core/replay-persistence.d.ts.map +1 -1
- package/dist/core/routing.d.ts +22 -3
- package/dist/core/routing.d.ts.map +1 -1
- package/dist/core/storage-batch.d.ts +0 -4
- package/dist/core/storage-batch.d.ts.map +1 -1
- package/dist/core/trace.d.ts +55 -15
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/types.d.ts +58 -9
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/hooks.d.ts +12 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +28 -2
- package/dist/hooks.js.map +2 -2
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +228 -162
- package/dist/index.js.map +3 -3
- package/dist/utils/constants.d.ts +170 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/error-utils.d.ts +28 -0
- package/dist/utils/error-utils.d.ts.map +1 -0
- package/dist/utils/metadata.d.ts +16 -0
- package/dist/utils/metadata.d.ts.map +1 -0
- package/dist/utils/storage-utils.d.ts +24 -0
- package/dist/utils/storage-utils.d.ts.map +1 -0
- package/dist/utils/validation.d.ts +76 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/vue.d.ts +13 -1
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +36 -2
- package/dist/vue.js.map +2 -2
- package/dist/websocket.d.ts +6 -1
- package/dist/websocket.d.ts.map +1 -1
- package/dist/worker-mode.d.ts +6 -2
- package/dist/worker-mode.d.ts.map +1 -1
- package/docs/README.md +1 -0
- package/docs/api.md +122 -2
- package/docs/architecture.md +80 -0
- package/docs/benchmarks.md +24 -0
- package/docs/capabilities.md +24 -3
- package/docs/configuration.md +58 -0
- package/docs/getting-started.md +35 -0
- package/docs/release-checklist.md +28 -5
- package/docs/roadmap.md +74 -5
- package/docs/zh/README.md +2 -1
- package/docs/zh/api.md +121 -2
- package/docs/zh/architecture.md +49 -0
- package/docs/zh/benchmarks.md +24 -0
- package/docs/zh/capabilities.md +21 -3
- package/docs/zh/configuration.md +53 -0
- package/docs/zh/getting-started.md +35 -0
- package/docs/zh/release-checklist.md +29 -6
- package/docs/zh/roadmap.md +86 -5
- package/package.json +39 -16
- package/dist/chunk-D2SIT473.js.map +0 -7
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHANNEL_FALLBACK,
|
|
3
|
+
CLUSTER_MESSAGE_TYPE,
|
|
4
|
+
CONTROL_ACTION,
|
|
5
|
+
DEFAULT_STORAGE_PREFIX,
|
|
6
|
+
FAILURE_SOURCE,
|
|
7
|
+
HEALTH_STATE,
|
|
8
|
+
INVOKE_LABEL,
|
|
9
|
+
PERSISTENCE_OPERATION,
|
|
10
|
+
PRUNE_STRATEGY,
|
|
11
|
+
PUBLICATION_EVENT,
|
|
12
|
+
RECOVERY_OUTCOME,
|
|
13
|
+
RELIABILITY_OPERATION,
|
|
14
|
+
STORAGE_CHANNEL_PREFIX,
|
|
15
|
+
SUBSCRIPTION_ACTION,
|
|
16
|
+
TAB_ID_STORAGE_KEY,
|
|
17
|
+
TAB_VISIBILITY,
|
|
18
|
+
TRACE_ERROR_SOURCE,
|
|
19
|
+
TRACE_EVENT_TYPE,
|
|
20
|
+
TRACE_LIFECYCLE_ACTION,
|
|
21
|
+
TRACE_MODE,
|
|
22
|
+
WORKER_BACKEND,
|
|
23
|
+
WORKER_MODE,
|
|
24
|
+
WORKER_ROLE,
|
|
25
|
+
WORKER_STATUS
|
|
26
|
+
} from "./chunk-TZ7ZP7YD.js";
|
|
27
|
+
|
|
1
28
|
// src/core/environment.ts
|
|
2
29
|
function getStorage(name) {
|
|
3
30
|
try {
|
|
@@ -13,8 +40,53 @@ function randomId() {
|
|
|
13
40
|
return Math.random().toString(36).slice(2);
|
|
14
41
|
}
|
|
15
42
|
}
|
|
43
|
+
function createStorageEventChannel(options) {
|
|
44
|
+
const { name, storage, win } = options;
|
|
45
|
+
if (!storage || !win) return null;
|
|
46
|
+
const key = `${STORAGE_CHANNEL_PREFIX}${name}`;
|
|
47
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
48
|
+
let sequence = 0;
|
|
49
|
+
const onStorage = (event) => {
|
|
50
|
+
if (event.key !== key || event.newValue === null) return;
|
|
51
|
+
let message;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(event.newValue);
|
|
54
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.seq !== "number" || !parsed.message) return;
|
|
55
|
+
message = parsed.message;
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const event_ = { data: message };
|
|
60
|
+
for (const listener of [...listeners]) listener(event_);
|
|
61
|
+
};
|
|
62
|
+
win.addEventListener("storage", onStorage);
|
|
63
|
+
let closed = false;
|
|
64
|
+
return {
|
|
65
|
+
addEventListener(_type, listener) {
|
|
66
|
+
listeners.add(listener);
|
|
67
|
+
},
|
|
68
|
+
removeEventListener(_type, listener) {
|
|
69
|
+
listeners.delete(listener);
|
|
70
|
+
},
|
|
71
|
+
postMessage(message) {
|
|
72
|
+
if (closed) return;
|
|
73
|
+
sequence += 1;
|
|
74
|
+
storage.setItem(key, JSON.stringify({ seq: sequence, message }));
|
|
75
|
+
},
|
|
76
|
+
close() {
|
|
77
|
+
closed = true;
|
|
78
|
+
win.removeEventListener("storage", onStorage);
|
|
79
|
+
listeners.clear();
|
|
80
|
+
try {
|
|
81
|
+
storage.removeItem(key);
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
16
87
|
var tabIdentityInitialized = false;
|
|
17
|
-
function createBrowserEnvironment() {
|
|
88
|
+
function createBrowserEnvironment(options) {
|
|
89
|
+
const channelFallback = options?.channelFallback ?? CHANNEL_FALLBACK.NONE;
|
|
18
90
|
return {
|
|
19
91
|
storage: getStorage("localStorage"),
|
|
20
92
|
sessionStorage: getStorage("sessionStorage"),
|
|
@@ -22,14 +94,18 @@ function createBrowserEnvironment() {
|
|
|
22
94
|
randomId,
|
|
23
95
|
createChannel: (name) => {
|
|
24
96
|
try {
|
|
25
|
-
|
|
97
|
+
if (typeof BroadcastChannel !== "undefined") return new BroadcastChannel(name);
|
|
26
98
|
} catch {
|
|
27
|
-
return null;
|
|
28
99
|
}
|
|
100
|
+
return channelFallback === CHANNEL_FALLBACK.STORAGE_EVENT ? createStorageEventChannel({
|
|
101
|
+
name,
|
|
102
|
+
storage: getStorage("localStorage"),
|
|
103
|
+
win: typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null
|
|
104
|
+
}) : null;
|
|
29
105
|
},
|
|
30
106
|
setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
|
|
31
107
|
clearInterval: (handle) => globalThis.clearInterval(handle),
|
|
32
|
-
getVisibilityState: () => typeof document !== "undefined" && document.visibilityState ===
|
|
108
|
+
getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === TAB_VISIBILITY.HIDDEN ? TAB_VISIBILITY.HIDDEN : TAB_VISIBILITY.VISIBLE,
|
|
33
109
|
addVisibilityChangeListener: (listener) => {
|
|
34
110
|
if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
|
|
35
111
|
},
|
|
@@ -60,7 +136,7 @@ function canUseStorage(storage, probeKey) {
|
|
|
60
136
|
return false;
|
|
61
137
|
}
|
|
62
138
|
}
|
|
63
|
-
function getOrCreateTabId(environment, key =
|
|
139
|
+
function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
|
|
64
140
|
const storage = environment.sessionStorage;
|
|
65
141
|
try {
|
|
66
142
|
const existing = storage?.getItem(key);
|
|
@@ -113,21 +189,73 @@ function avalancheMix(self, neighbor) {
|
|
|
113
189
|
|
|
114
190
|
// src/core/routing.ts
|
|
115
191
|
var DEFAULT_MAX_ACTIVE_WORKERS = 3;
|
|
116
|
-
function
|
|
192
|
+
function effectiveWorkerLoad(worker, options) {
|
|
193
|
+
const baseLoad = Number.isFinite(worker.load) ? worker.load : 0;
|
|
194
|
+
const sample = worker.throughput;
|
|
195
|
+
const messageRateWeight = options?.messageRateWeight ?? 0;
|
|
196
|
+
const byteRateWeight = options?.byteRateWeight ?? 0;
|
|
197
|
+
const scheduleLagWeight = options?.scheduleLagWeight ?? 0;
|
|
198
|
+
if (!sample || !Number.isFinite(sample.windowMs) || sample.windowMs <= 0 || messageRateWeight === 0 && byteRateWeight === 0 && scheduleLagWeight === 0) {
|
|
199
|
+
return baseLoad;
|
|
200
|
+
}
|
|
201
|
+
const windowSeconds = sample.windowMs / 1e3;
|
|
202
|
+
const messageRate = sample.messageCount / windowSeconds;
|
|
203
|
+
const byteRate = sample.byteCount / windowSeconds;
|
|
204
|
+
const scheduleLagRatio = sample.overrunMs / sample.windowMs;
|
|
205
|
+
const weighted = baseLoad + messageRateWeight * messageRate + byteRateWeight * byteRate + scheduleLagWeight * scheduleLagRatio;
|
|
206
|
+
return Number.isFinite(weighted) ? weighted : baseLoad;
|
|
207
|
+
}
|
|
208
|
+
function approximatePayloadBytes(payload) {
|
|
209
|
+
return estimatePayloadBytes(payload, 0);
|
|
210
|
+
}
|
|
211
|
+
var MAX_PAYLOAD_DEPTH = 6;
|
|
212
|
+
function estimatePayloadBytes(payload, depth) {
|
|
213
|
+
if (payload === null || payload === void 0) return 0;
|
|
214
|
+
switch (typeof payload) {
|
|
215
|
+
case "boolean":
|
|
216
|
+
return 4;
|
|
217
|
+
case "number":
|
|
218
|
+
case "bigint":
|
|
219
|
+
return 8;
|
|
220
|
+
case "string":
|
|
221
|
+
return payload.length;
|
|
222
|
+
case "symbol":
|
|
223
|
+
case "function":
|
|
224
|
+
return 0;
|
|
225
|
+
case "object":
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
if (depth >= MAX_PAYLOAD_DEPTH) return 0;
|
|
229
|
+
if (payload instanceof ArrayBuffer) return payload.byteLength;
|
|
230
|
+
if (ArrayBuffer.isView(payload)) return payload.byteLength;
|
|
231
|
+
if (Array.isArray(payload)) {
|
|
232
|
+
let sum2 = 8;
|
|
233
|
+
for (const item of payload) sum2 += estimatePayloadBytes(item, depth + 1);
|
|
234
|
+
return sum2;
|
|
235
|
+
}
|
|
236
|
+
let sum = 0;
|
|
237
|
+
for (const value of Object.values(payload)) {
|
|
238
|
+
sum += estimatePayloadBytes(value, depth + 1);
|
|
239
|
+
}
|
|
240
|
+
return sum;
|
|
241
|
+
}
|
|
242
|
+
function selectLeastLoadedWorker(workers, preferredWorkerId, options) {
|
|
117
243
|
const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
|
|
118
244
|
if (preferred) return preferred;
|
|
119
245
|
return workers.reduce((least, worker) => {
|
|
120
246
|
if (!least) return worker;
|
|
121
|
-
const byLoad = worker
|
|
247
|
+
const byLoad = effectiveWorkerLoad(worker, options) - effectiveWorkerLoad(least, options);
|
|
122
248
|
if (byLoad !== 0) return byLoad < 0 ? worker : least;
|
|
123
249
|
if (worker.workerId < least.workerId) return worker;
|
|
124
250
|
return least;
|
|
125
251
|
}, void 0);
|
|
126
252
|
}
|
|
127
253
|
function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
|
|
128
|
-
const healthyWorkers = workers.filter(
|
|
254
|
+
const healthyWorkers = workers.filter(
|
|
255
|
+
(worker) => worker.status === WORKER_STATUS.CONNECTING || worker.status === WORKER_STATUS.CONNECTED
|
|
256
|
+
);
|
|
129
257
|
const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
|
|
130
|
-
const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState ===
|
|
258
|
+
const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === TAB_VISIBILITY.VISIBLE);
|
|
131
259
|
const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
|
|
132
260
|
return candidates.sort(
|
|
133
261
|
(left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
|
|
@@ -155,6 +283,123 @@ function topicMatchesPattern(pattern, topic) {
|
|
|
155
283
|
return topic.startsWith(pattern.slice(0, -1));
|
|
156
284
|
}
|
|
157
285
|
|
|
286
|
+
// src/utils/metadata.ts
|
|
287
|
+
function publicationMetadata(messageId, timestamp) {
|
|
288
|
+
if (messageId === void 0 && timestamp === void 0) return void 0;
|
|
289
|
+
return {
|
|
290
|
+
...messageId === void 0 ? {} : { messageId },
|
|
291
|
+
...timestamp === void 0 ? {} : { timestamp }
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/utils/validation.ts
|
|
296
|
+
function assertPositiveSafeInteger(value, name) {
|
|
297
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
298
|
+
throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}.`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function assertPositiveFiniteNumber(value, name) {
|
|
302
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
303
|
+
throw new TypeError(`${name} must be a positive finite number.`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function assertNonNegativeFiniteNumber(value, name) {
|
|
307
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
308
|
+
throw new TypeError(`${name} must be a non-negative finite number.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function assertPruneStrategy(value) {
|
|
312
|
+
const allowed = [PRUNE_STRATEGY.COUNT, PRUNE_STRATEGY.AGE, PRUNE_STRATEGY.BOTH];
|
|
313
|
+
if (!allowed.includes(String(value))) {
|
|
314
|
+
throw new TypeError("pruneStrategy must be count, age, or both.");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function assertReplayOptions(replay) {
|
|
318
|
+
if (!replay) return;
|
|
319
|
+
if (replay.maxPerTopic !== void 0) assertPositiveSafeInteger(replay.maxPerTopic, "replay.maxPerTopic");
|
|
320
|
+
if (replay.pruneStrategy !== void 0) assertPruneStrategy(replay.pruneStrategy);
|
|
321
|
+
if (replay.retentionMs !== void 0) assertPositiveFiniteNumber(replay.retentionMs, "replay.retentionMs");
|
|
322
|
+
if (replay.retentionSweepMs !== void 0) {
|
|
323
|
+
assertPositiveFiniteNumber(replay.retentionSweepMs, "replay.retentionSweepMs");
|
|
324
|
+
}
|
|
325
|
+
if (replay.persistenceRetry) assertPersistenceRetryOptions(replay.persistenceRetry);
|
|
326
|
+
}
|
|
327
|
+
function assertPersistenceRetryOptions(retry) {
|
|
328
|
+
if (retry.maxAttempts !== void 0) {
|
|
329
|
+
assertPositiveSafeInteger(retry.maxAttempts, "replay.persistenceRetry.maxAttempts");
|
|
330
|
+
}
|
|
331
|
+
if (retry.backoffMs !== void 0) {
|
|
332
|
+
assertNonNegativeFiniteNumber(retry.backoffMs, "replay.persistenceRetry.backoffMs");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function assertDedupOptions(dedup) {
|
|
336
|
+
if (!dedup) return;
|
|
337
|
+
if (dedup.maxEntries !== void 0) assertPositiveSafeInteger(dedup.maxEntries, "dedup.maxEntries");
|
|
338
|
+
if (dedup.ttlMs !== void 0) assertPositiveFiniteNumber(dedup.ttlMs, "dedup.ttlMs");
|
|
339
|
+
if (dedup.sweepMs !== void 0) assertPositiveFiniteNumber(dedup.sweepMs, "dedup.sweepMs");
|
|
340
|
+
const bounds = dedup.adaptiveTtl;
|
|
341
|
+
if (bounds !== void 0) {
|
|
342
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
343
|
+
if (!finite(bounds.minMs) || !finite(bounds.maxMs) || bounds.minMs <= 0 || bounds.maxMs < bounds.minMs) {
|
|
344
|
+
throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function assertRecoveryOptions(recovery) {
|
|
349
|
+
if (!recovery) return;
|
|
350
|
+
if (recovery.cooldownMs !== void 0) {
|
|
351
|
+
assertPositiveFiniteNumber(recovery.cooldownMs, "recovery.cooldownMs");
|
|
352
|
+
}
|
|
353
|
+
const maxAttempts = recovery.maxAttempts;
|
|
354
|
+
if (maxAttempts !== void 0 && !(maxAttempts === Number.POSITIVE_INFINITY || typeof maxAttempts === "number" && Number.isSafeInteger(maxAttempts) && maxAttempts > 0)) {
|
|
355
|
+
throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function assertLoadWeightingOptions(loadWeighting) {
|
|
359
|
+
if (!loadWeighting) return;
|
|
360
|
+
const weights = {
|
|
361
|
+
messageRateWeight: loadWeighting.messageRateWeight,
|
|
362
|
+
byteRateWeight: loadWeighting.byteRateWeight,
|
|
363
|
+
scheduleLagWeight: loadWeighting.scheduleLagWeight
|
|
364
|
+
};
|
|
365
|
+
for (const [name, value] of Object.entries(weights)) {
|
|
366
|
+
if (value !== void 0) assertNonNegativeFiniteNumber(value, `loadWeighting.${name}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function assertClusterOptions(options) {
|
|
370
|
+
if (options.maxActiveWorkers !== void 0) {
|
|
371
|
+
assertPositiveSafeInteger(options.maxActiveWorkers, "maxActiveWorkers");
|
|
372
|
+
}
|
|
373
|
+
if (options.heartbeatIntervalMs !== void 0) {
|
|
374
|
+
assertPositiveFiniteNumber(options.heartbeatIntervalMs, "heartbeatIntervalMs");
|
|
375
|
+
}
|
|
376
|
+
if (options.workerTtlMs !== void 0) {
|
|
377
|
+
assertPositiveFiniteNumber(options.workerTtlMs, "workerTtlMs");
|
|
378
|
+
}
|
|
379
|
+
if (options.routeOwnerCacheMax !== void 0) {
|
|
380
|
+
assertPositiveSafeInteger(options.routeOwnerCacheMax, "routeOwnerCacheMax");
|
|
381
|
+
}
|
|
382
|
+
assertLoadWeightingOptions(options.loadWeighting);
|
|
383
|
+
}
|
|
384
|
+
function assertHeartbeatInterval(value) {
|
|
385
|
+
if (value === Infinity) return;
|
|
386
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return;
|
|
387
|
+
throw new TypeError(
|
|
388
|
+
`Centrifuge heartbeatIntervalMs must be a positive number or Infinity, got ${String(value)}.`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
function assertStructuredCloneable(value) {
|
|
392
|
+
if (typeof structuredClone !== "function") return;
|
|
393
|
+
try {
|
|
394
|
+
structuredClone(value);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
throw new TypeError(
|
|
397
|
+
"Centrifuge Worker configuration and published data must be structured-cloneable.",
|
|
398
|
+
{ cause: error }
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
158
403
|
// src/core/storage-batch.ts
|
|
159
404
|
var INITIAL_RETRY_DELAY_MS = 50;
|
|
160
405
|
var MAX_RETRY_DELAY_MS = 1600;
|
|
@@ -163,6 +408,7 @@ var BatchingStorageWriter = class {
|
|
|
163
408
|
constructor(storage) {
|
|
164
409
|
this.storage = storage;
|
|
165
410
|
}
|
|
411
|
+
storage;
|
|
166
412
|
/** Coalesced write set. A `null` value represents a pending delete. */
|
|
167
413
|
pending = /* @__PURE__ */ new Map();
|
|
168
414
|
/** Per-key retry counter, reset on a successful write. */
|
|
@@ -219,7 +465,7 @@ var BatchingStorageWriter = class {
|
|
|
219
465
|
this.pending.delete(key);
|
|
220
466
|
this.retryCount.delete(key);
|
|
221
467
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
222
|
-
console.warn(
|
|
468
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] storage write gave up after retries, dropping key:`, key);
|
|
223
469
|
}
|
|
224
470
|
continue;
|
|
225
471
|
}
|
|
@@ -279,12 +525,7 @@ var BatchingStorageWriter = class {
|
|
|
279
525
|
}
|
|
280
526
|
};
|
|
281
527
|
|
|
282
|
-
// src/
|
|
283
|
-
var CLUSTER_PROTOCOL_VERSION = 1;
|
|
284
|
-
var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
|
|
285
|
-
var DEFAULT_WORKER_TTL_MS = 1e4;
|
|
286
|
-
var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
|
|
287
|
-
var MAX_KNOWN_TOPICS = 500;
|
|
528
|
+
// src/utils/storage-utils.ts
|
|
288
529
|
function readJson(storage, key) {
|
|
289
530
|
try {
|
|
290
531
|
const value = storage.getItem(key);
|
|
@@ -299,13 +540,6 @@ function writeJson(storage, key, value) {
|
|
|
299
540
|
} catch {
|
|
300
541
|
}
|
|
301
542
|
}
|
|
302
|
-
function publicationMetadata(messageId, timestamp) {
|
|
303
|
-
if (messageId === void 0 && timestamp === void 0) return void 0;
|
|
304
|
-
return {
|
|
305
|
-
...messageId === void 0 ? {} : { messageId },
|
|
306
|
-
...timestamp === void 0 ? {} : { timestamp }
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
543
|
function listKeys(storage, prefix) {
|
|
310
544
|
try {
|
|
311
545
|
return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
|
|
@@ -318,6 +552,12 @@ function listKeys(storage, prefix) {
|
|
|
318
552
|
function readAllByPrefix(storage, prefix) {
|
|
319
553
|
return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
|
|
320
554
|
}
|
|
555
|
+
|
|
556
|
+
// src/core/cluster.ts
|
|
557
|
+
var CLUSTER_PROTOCOL_VERSION = 1;
|
|
558
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
|
|
559
|
+
var DEFAULT_WORKER_TTL_MS = 1e4;
|
|
560
|
+
var MAX_KNOWN_TOPICS = 500;
|
|
321
561
|
var WorkerClusterRuntime = class {
|
|
322
562
|
tabId;
|
|
323
563
|
workerId;
|
|
@@ -331,6 +571,14 @@ var WorkerClusterRuntime = class {
|
|
|
331
571
|
routePrefix;
|
|
332
572
|
subscriberPrefix;
|
|
333
573
|
channelName;
|
|
574
|
+
/** Adaptive load weighting options; undefined keeps legacy topic-count routing. */
|
|
575
|
+
loadWeighting;
|
|
576
|
+
/** Rolling traffic accumulator folded into the worker record on writeRecord. */
|
|
577
|
+
throughputWindow = {
|
|
578
|
+
startedAt: 0,
|
|
579
|
+
messageCount: 0,
|
|
580
|
+
byteCount: 0
|
|
581
|
+
};
|
|
334
582
|
// Topics this tab has subscribed to (local interest, plaintext).
|
|
335
583
|
subscribedTopics = /* @__PURE__ */ new Set();
|
|
336
584
|
// Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
|
|
@@ -366,12 +614,14 @@ var WorkerClusterRuntime = class {
|
|
|
366
614
|
lifecycleListening = false;
|
|
367
615
|
currentRecord;
|
|
368
616
|
constructor(options) {
|
|
617
|
+
assertClusterOptions(options);
|
|
369
618
|
this.environment = options.environment ?? createBrowserEnvironment();
|
|
370
619
|
this.handlers = options.handlers;
|
|
371
620
|
this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
|
|
372
621
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
373
622
|
this.routeOwnerCacheMax = options.routeOwnerCacheMax ?? 256;
|
|
374
623
|
this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
|
|
624
|
+
this.loadWeighting = options.loadWeighting;
|
|
375
625
|
const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
|
|
376
626
|
const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
|
|
377
627
|
const baseKey = `${prefix}:${clusterHash}`;
|
|
@@ -388,8 +638,8 @@ var WorkerClusterRuntime = class {
|
|
|
388
638
|
workerId: this.workerId,
|
|
389
639
|
tabId: this.tabId,
|
|
390
640
|
load: 0,
|
|
391
|
-
role:
|
|
392
|
-
status:
|
|
641
|
+
role: WORKER_ROLE.STANDBY,
|
|
642
|
+
status: WORKER_STATUS.CONNECTING,
|
|
393
643
|
visibilityState: this.environment.getVisibilityState(),
|
|
394
644
|
heartbeatAt: now,
|
|
395
645
|
registeredAt: now
|
|
@@ -442,7 +692,7 @@ var WorkerClusterRuntime = class {
|
|
|
442
692
|
this.writeRecord(true);
|
|
443
693
|
for (const topic of this.subscribedTopics) {
|
|
444
694
|
const topicKey = this.rememberTopic(topic);
|
|
445
|
-
if (!this.storage) this.sendControl(this.workerId,
|
|
695
|
+
if (!this.storage) this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
446
696
|
else this.writeSubscriber(topicKey);
|
|
447
697
|
}
|
|
448
698
|
this.reconcile();
|
|
@@ -470,9 +720,14 @@ var WorkerClusterRuntime = class {
|
|
|
470
720
|
this.removeStorage(this.workerStorageKey(this.workerId));
|
|
471
721
|
this.flushStorage();
|
|
472
722
|
this.notifyRegistry();
|
|
473
|
-
this.channel
|
|
723
|
+
const channel = this.channel;
|
|
474
724
|
this.channel = null;
|
|
475
725
|
this.handlers.onSuspend?.();
|
|
726
|
+
if (typeof globalThis.setTimeout === "function") {
|
|
727
|
+
globalThis.setTimeout(() => channel?.close(), 0);
|
|
728
|
+
} else {
|
|
729
|
+
channel?.close();
|
|
730
|
+
}
|
|
476
731
|
}
|
|
477
732
|
/** Update the worker's connection status and persist the change. */
|
|
478
733
|
setStatus(status) {
|
|
@@ -490,7 +745,7 @@ var WorkerClusterRuntime = class {
|
|
|
490
745
|
this.subscribedTopics.add(topic);
|
|
491
746
|
if (!this.started) return false;
|
|
492
747
|
if (!this.storage) {
|
|
493
|
-
this.sendControl(this.workerId,
|
|
748
|
+
this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
494
749
|
return true;
|
|
495
750
|
}
|
|
496
751
|
this.writeSubscriber(topicKey);
|
|
@@ -500,9 +755,9 @@ var WorkerClusterRuntime = class {
|
|
|
500
755
|
return existingRoute?.workerId === this.workerId;
|
|
501
756
|
}
|
|
502
757
|
const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
|
|
503
|
-
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
758
|
+
const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
|
|
504
759
|
this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
|
|
505
|
-
this.sendControl(owner.workerId,
|
|
760
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
506
761
|
this.notifyRegistry();
|
|
507
762
|
return owner.workerId === this.workerId;
|
|
508
763
|
}
|
|
@@ -526,7 +781,7 @@ var WorkerClusterRuntime = class {
|
|
|
526
781
|
const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
|
|
527
782
|
if (subscribers.length === 0) {
|
|
528
783
|
this.removeStorage(this.routeStorageKey(topicKey));
|
|
529
|
-
if (notifyOwner) this.sendControl(route.workerId,
|
|
784
|
+
if (notifyOwner) this.sendControl(route.workerId, CONTROL_ACTION.UNSUBSCRIBE, topic, topicKey);
|
|
530
785
|
}
|
|
531
786
|
return topicKey;
|
|
532
787
|
}
|
|
@@ -545,15 +800,17 @@ var WorkerClusterRuntime = class {
|
|
|
545
800
|
continue;
|
|
546
801
|
}
|
|
547
802
|
const owner = selectLeastLoadedWorker(
|
|
548
|
-
activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
|
|
803
|
+
activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load })),
|
|
804
|
+
void 0,
|
|
805
|
+
this.loadWeighting
|
|
549
806
|
);
|
|
550
807
|
if (!owner) continue;
|
|
551
808
|
projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
|
|
552
809
|
const generation = (previous?.generation ?? 0) + 1;
|
|
553
810
|
this.writeRoute(topicKey, owner, previous?.workerId, generation);
|
|
554
|
-
this.handlers.onDiagnostic?.({ operation:
|
|
811
|
+
this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION, topic });
|
|
555
812
|
this.flushStorage();
|
|
556
|
-
this.handlers.onControl(
|
|
813
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic);
|
|
557
814
|
this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
|
|
558
815
|
}
|
|
559
816
|
}
|
|
@@ -561,22 +818,19 @@ var WorkerClusterRuntime = class {
|
|
|
561
818
|
const metadata = typeof metadataOrMessageId === "string" ? { messageId: metadataOrMessageId } : metadataOrMessageId;
|
|
562
819
|
const topicKey = this.rememberTopic(topic);
|
|
563
820
|
if (this.assignedTopics.has(topicKey)) {
|
|
564
|
-
return this.sendControl(this.workerId,
|
|
821
|
+
return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
565
822
|
}
|
|
566
823
|
const cachedPattern = this.wildcardPublishCache.get(topic);
|
|
567
|
-
if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
|
|
568
|
-
return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
|
|
569
|
-
}
|
|
570
824
|
if (cachedPattern === void 0) {
|
|
571
825
|
for (const pattern of this.assignedTopics.values()) {
|
|
572
826
|
if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
|
|
573
827
|
this.wildcardPublishCache.set(topic, pattern);
|
|
574
|
-
return this.sendControl(this.workerId,
|
|
828
|
+
return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
575
829
|
}
|
|
576
830
|
}
|
|
577
831
|
this.wildcardPublishCache.set(topic, null);
|
|
578
832
|
}
|
|
579
|
-
return this.sendControl(this.resolvePublishTarget(topic, topicKey),
|
|
833
|
+
return this.sendControl(this.resolvePublishTarget(topic, topicKey), CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
580
834
|
}
|
|
581
835
|
/**
|
|
582
836
|
* Burst-friendly variant of `publish()`: packs up to N items into a single
|
|
@@ -597,19 +851,15 @@ var WorkerClusterRuntime = class {
|
|
|
597
851
|
}
|
|
598
852
|
const topicKey = this.rememberTopic(topic);
|
|
599
853
|
if (this.assignedTopics.has(topicKey)) {
|
|
600
|
-
|
|
854
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
601
855
|
return true;
|
|
602
856
|
}
|
|
603
857
|
const cachedPattern = this.wildcardPublishCache.get(topic);
|
|
604
|
-
if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
|
|
605
|
-
for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
|
|
606
|
-
return true;
|
|
607
|
-
}
|
|
608
858
|
if (cachedPattern === void 0) {
|
|
609
859
|
for (const pattern of this.assignedTopics.values()) {
|
|
610
860
|
if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
|
|
611
861
|
this.wildcardPublishCache.set(topic, pattern);
|
|
612
|
-
|
|
862
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
613
863
|
return true;
|
|
614
864
|
}
|
|
615
865
|
}
|
|
@@ -617,14 +867,14 @@ var WorkerClusterRuntime = class {
|
|
|
617
867
|
}
|
|
618
868
|
const target = this.resolvePublishTarget(topic, topicKey);
|
|
619
869
|
if (target === this.workerId) {
|
|
620
|
-
|
|
870
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
621
871
|
return true;
|
|
622
872
|
}
|
|
623
873
|
return this.send({
|
|
624
|
-
type:
|
|
874
|
+
type: CLUSTER_MESSAGE_TYPE.CONTROL,
|
|
625
875
|
sourceWorkerId: this.workerId,
|
|
626
876
|
targetWorkerId: target,
|
|
627
|
-
action:
|
|
877
|
+
action: CONTROL_ACTION.PUBLISH,
|
|
628
878
|
topic,
|
|
629
879
|
topicKey,
|
|
630
880
|
items: items.map((item) => ({
|
|
@@ -655,8 +905,17 @@ var WorkerClusterRuntime = class {
|
|
|
655
905
|
dispatchLocalPublish(topic, topicKey, data, messageId, timestamp) {
|
|
656
906
|
void topicKey;
|
|
657
907
|
const meta = publicationMetadata(messageId, timestamp);
|
|
658
|
-
if (meta) this.handlers.onControl(
|
|
659
|
-
else this.handlers.onControl(
|
|
908
|
+
if (meta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data, meta.messageId, meta.timestamp);
|
|
909
|
+
else this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data);
|
|
910
|
+
}
|
|
911
|
+
/** Fan out a publication batch to the local onControl path: one
|
|
912
|
+
* onPublishBatch call when the owner supports it, per-item otherwise. */
|
|
913
|
+
dispatchLocalPublishBatch(topic, topicKey, items) {
|
|
914
|
+
if (this.handlers.onPublishBatch) {
|
|
915
|
+
this.handlers.onPublishBatch(topic, items);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
|
|
660
919
|
}
|
|
661
920
|
/** True when `route` exists and its owner worker is among `workers`.
|
|
662
921
|
* Shared by subscribe (skip re-assignment) and publish (route to owner).
|
|
@@ -670,7 +929,39 @@ var WorkerClusterRuntime = class {
|
|
|
670
929
|
* listeners can attribute the event to its source tab even after fan-out. */
|
|
671
930
|
broadcastEvent(eventType, payload, originTabId) {
|
|
672
931
|
const effectiveOriginTabId = originTabId ?? this.tabId;
|
|
673
|
-
this.
|
|
932
|
+
this.recordTraffic(payload);
|
|
933
|
+
this.send({ type: CLUSTER_MESSAGE_TYPE.EVENT, sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
|
|
934
|
+
}
|
|
935
|
+
/** Count one fan-out unit toward the adaptive load sample. No-op unless
|
|
936
|
+
* adaptive weighting is configured. */
|
|
937
|
+
recordTraffic(payload) {
|
|
938
|
+
if (this.loadWeighting === void 0) return;
|
|
939
|
+
this.throughputWindow.messageCount += 1;
|
|
940
|
+
this.throughputWindow.byteCount += approximatePayloadBytes(payload);
|
|
941
|
+
}
|
|
942
|
+
/** Convert the accumulated window into a publishable throughput sample and
|
|
943
|
+
* reset the accumulator. Returns undefined until a full window has elapsed
|
|
944
|
+
* so the first write does not emit a zero-width sample. */
|
|
945
|
+
sampleThroughput(now) {
|
|
946
|
+
if (this.throughputWindow.startedAt === 0) {
|
|
947
|
+
this.throughputWindow.startedAt = now;
|
|
948
|
+
return void 0;
|
|
949
|
+
}
|
|
950
|
+
const windowMs = now - this.throughputWindow.startedAt;
|
|
951
|
+
if (windowMs <= 0) return void 0;
|
|
952
|
+
const sample = {
|
|
953
|
+
windowMs,
|
|
954
|
+
messageCount: this.throughputWindow.messageCount,
|
|
955
|
+
byteCount: this.throughputWindow.byteCount,
|
|
956
|
+
// How much later the heartbeat landed than its nominal interval. This
|
|
957
|
+
// window is anchored at the previous writeRecord (one heartbeat tick),
|
|
958
|
+
// so a starved event loop stretches windowMs past the interval and the
|
|
959
|
+
// positive excess is the scheduling-overrun signal.
|
|
960
|
+
overrunMs: Math.max(0, windowMs - this.heartbeatIntervalMs),
|
|
961
|
+
sampledAt: now
|
|
962
|
+
};
|
|
963
|
+
this.throughputWindow = { startedAt: now, messageCount: 0, byteCount: 0 };
|
|
964
|
+
return sample;
|
|
674
965
|
}
|
|
675
966
|
isAssigned(topic) {
|
|
676
967
|
const topicKey = createOpaqueKey(topic);
|
|
@@ -760,14 +1051,14 @@ var WorkerClusterRuntime = class {
|
|
|
760
1051
|
const message = event.data;
|
|
761
1052
|
if (!message || message.sourceWorkerId === this.workerId) return;
|
|
762
1053
|
switch (message.type) {
|
|
763
|
-
case
|
|
1054
|
+
case CLUSTER_MESSAGE_TYPE.CONTROL:
|
|
764
1055
|
return this.handleControlMessage(message);
|
|
765
|
-
case
|
|
1056
|
+
case CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED:
|
|
766
1057
|
return this.handleRouteReleasedMessage(message);
|
|
767
|
-
case
|
|
1058
|
+
case CLUSTER_MESSAGE_TYPE.EVENT:
|
|
768
1059
|
this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId, message.originTabId);
|
|
769
1060
|
return;
|
|
770
|
-
case
|
|
1061
|
+
case CLUSTER_MESSAGE_TYPE.REGISTRY:
|
|
771
1062
|
this.reconcile();
|
|
772
1063
|
return;
|
|
773
1064
|
default: {
|
|
@@ -784,19 +1075,23 @@ var WorkerClusterRuntime = class {
|
|
|
784
1075
|
if (message.targetWorkerId !== this.workerId) return;
|
|
785
1076
|
this.rememberTopic(message.topic);
|
|
786
1077
|
switch (message.action) {
|
|
787
|
-
case
|
|
1078
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
788
1079
|
this.assignedTopics.set(message.topicKey, message.topic);
|
|
789
1080
|
this.confirmRoute(message.topicKey);
|
|
790
1081
|
break;
|
|
791
|
-
case
|
|
1082
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
792
1083
|
if (this.releaseHandoffOnUnsubscribe(message)) return;
|
|
793
1084
|
break;
|
|
794
|
-
case
|
|
1085
|
+
case CONTROL_ACTION.PUBLISH:
|
|
795
1086
|
if (message.items && message.items.length > 0) {
|
|
1087
|
+
if (this.handlers.onPublishBatch) {
|
|
1088
|
+
this.handlers.onPublishBatch(message.topic, message.items);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
796
1091
|
for (const item of message.items) {
|
|
797
1092
|
const itemMeta = publicationMetadata(item.messageId, item.timestamp);
|
|
798
|
-
if (itemMeta) this.handlers.onControl(
|
|
799
|
-
else this.handlers.onControl(
|
|
1093
|
+
if (itemMeta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
|
|
1094
|
+
else this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data);
|
|
800
1095
|
}
|
|
801
1096
|
return;
|
|
802
1097
|
}
|
|
@@ -813,7 +1108,7 @@ var WorkerClusterRuntime = class {
|
|
|
813
1108
|
metadata.timestamp
|
|
814
1109
|
);
|
|
815
1110
|
else this.handlers.onControl(message.action, message.topic, message.data);
|
|
816
|
-
if (message.action !==
|
|
1111
|
+
if (message.action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
|
|
817
1112
|
}
|
|
818
1113
|
/**
|
|
819
1114
|
* When this worker is the previous owner in a graceful handoff and the new
|
|
@@ -825,7 +1120,7 @@ var WorkerClusterRuntime = class {
|
|
|
825
1120
|
this.assignedTopics.delete(message.topicKey);
|
|
826
1121
|
const route = this.readRoute(message.topicKey);
|
|
827
1122
|
if (route?.handoffFromWorkerId !== this.workerId) return false;
|
|
828
|
-
this.handlers.onControl(
|
|
1123
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, message.topic, void 0);
|
|
829
1124
|
this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
|
|
830
1125
|
this.updateLoad();
|
|
831
1126
|
return true;
|
|
@@ -834,7 +1129,7 @@ var WorkerClusterRuntime = class {
|
|
|
834
1129
|
* generation so only the matching new owner may act on it. */
|
|
835
1130
|
sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
|
|
836
1131
|
this.send({
|
|
837
|
-
type:
|
|
1132
|
+
type: CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED,
|
|
838
1133
|
sourceWorkerId: this.workerId,
|
|
839
1134
|
targetWorkerId,
|
|
840
1135
|
topic,
|
|
@@ -853,14 +1148,24 @@ var WorkerClusterRuntime = class {
|
|
|
853
1148
|
if (!route || this.isStaleRouteRelease(route, message)) return;
|
|
854
1149
|
this.assignedTopics.set(message.topicKey, message.topic);
|
|
855
1150
|
this.confirmRoute(message.topicKey);
|
|
856
|
-
this.handlers.onControl(
|
|
1151
|
+
this.handlers.onControl(CONTROL_ACTION.SUBSCRIBE, message.topic, void 0);
|
|
857
1152
|
this.updateLoad();
|
|
858
1153
|
}
|
|
859
1154
|
/** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
|
|
860
1155
|
* points to us, the release comes from the recorded previous owner, and
|
|
861
|
-
* the release generation is at least as new as ours.
|
|
1156
|
+
* the release generation is at least as new as ours. A replayed ACK from an
|
|
1157
|
+
* earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
|
|
1158
|
+
* and must not confirm the current round. */
|
|
862
1159
|
isStaleRouteRelease(route, message) {
|
|
863
|
-
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId ||
|
|
1160
|
+
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
|
|
1161
|
+
}
|
|
1162
|
+
/** True when an unconfirmed handoff route has been stuck longer than a
|
|
1163
|
+
* worker TTL. The ACK for a live handoff is posted synchronously with the
|
|
1164
|
+
* route write, so anything older than the TTL with a dead previous owner
|
|
1165
|
+
* will never complete — while a fresh unconfirmed route may simply be
|
|
1166
|
+
* waiting out its confirmation flush and must be left alone. */
|
|
1167
|
+
isStaleHandoff(route) {
|
|
1168
|
+
return this.environment.now() - route.updatedAt > this.workerTtlMs;
|
|
864
1169
|
}
|
|
865
1170
|
/** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
|
|
866
1171
|
reconcile() {
|
|
@@ -893,20 +1198,39 @@ var WorkerClusterRuntime = class {
|
|
|
893
1198
|
*/
|
|
894
1199
|
reconcileSubscriptions(workers, activeWorkers) {
|
|
895
1200
|
const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
|
|
1201
|
+
const recoveryProjectedLoads = /* @__PURE__ */ new Map();
|
|
896
1202
|
for (const topic of this.subscribedTopics) {
|
|
897
1203
|
const topicKey = this.rememberTopic(topic);
|
|
898
1204
|
this.writeSubscriber(topicKey);
|
|
899
1205
|
const route = this.readRoute(topicKey);
|
|
900
1206
|
if (!route || !liveWorkerIds.has(route.workerId)) {
|
|
901
|
-
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
1207
|
+
const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
|
|
902
1208
|
this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
|
|
903
|
-
this.sendControl(owner.workerId,
|
|
1209
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
904
1210
|
this.notifyRegistry();
|
|
905
1211
|
continue;
|
|
906
1212
|
}
|
|
907
1213
|
if (route.confirmedAt === void 0) {
|
|
908
1214
|
if (!route.handoffFromWorkerId) {
|
|
909
|
-
this.sendControl(route.workerId,
|
|
1215
|
+
this.sendControl(route.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
1216
|
+
} else if (!liveWorkerIds.has(route.handoffFromWorkerId) && this.isStaleHandoff(route)) {
|
|
1217
|
+
const owner = selectLeastLoadedWorker(
|
|
1218
|
+
activeWorkers.map((worker) => ({ ...worker, load: recoveryProjectedLoads.get(worker.workerId) ?? worker.load })),
|
|
1219
|
+
void 0,
|
|
1220
|
+
this.loadWeighting
|
|
1221
|
+
) ?? this.currentRecord;
|
|
1222
|
+
recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
|
|
1223
|
+
if (owner.workerId !== this.workerId) {
|
|
1224
|
+
const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
|
|
1225
|
+
const ownerSubscribed = workers.some(
|
|
1226
|
+
(worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
|
|
1227
|
+
);
|
|
1228
|
+
if (ownerSubscribed) continue;
|
|
1229
|
+
}
|
|
1230
|
+
this.writeRoute(topicKey, owner, void 0, route.generation + 1);
|
|
1231
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
1232
|
+
this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION_RECOVERY, topic });
|
|
1233
|
+
this.notifyRegistry();
|
|
910
1234
|
}
|
|
911
1235
|
}
|
|
912
1236
|
}
|
|
@@ -917,7 +1241,7 @@ var WorkerClusterRuntime = class {
|
|
|
917
1241
|
const route = this.readRoute(topicKey);
|
|
918
1242
|
if (route?.workerId === this.workerId) continue;
|
|
919
1243
|
this.assignedTopics.delete(topicKey);
|
|
920
|
-
this.handlers.onControl(
|
|
1244
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic, void 0);
|
|
921
1245
|
if (route?.handoffFromWorkerId === this.workerId) {
|
|
922
1246
|
this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
|
|
923
1247
|
}
|
|
@@ -932,14 +1256,14 @@ var WorkerClusterRuntime = class {
|
|
|
932
1256
|
sendControl(targetWorkerId, action, topic, topicKey, data, metadata) {
|
|
933
1257
|
if (targetWorkerId === this.workerId) {
|
|
934
1258
|
switch (action) {
|
|
935
|
-
case
|
|
1259
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
936
1260
|
this.assignedTopics.set(topicKey, topic);
|
|
937
1261
|
this.confirmRoute(topicKey);
|
|
938
1262
|
break;
|
|
939
|
-
case
|
|
1263
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
940
1264
|
this.assignedTopics.delete(topicKey);
|
|
941
1265
|
break;
|
|
942
|
-
case
|
|
1266
|
+
case CONTROL_ACTION.PUBLISH:
|
|
943
1267
|
default:
|
|
944
1268
|
break;
|
|
945
1269
|
}
|
|
@@ -951,11 +1275,11 @@ var WorkerClusterRuntime = class {
|
|
|
951
1275
|
metadata.timestamp
|
|
952
1276
|
);
|
|
953
1277
|
else this.handlers.onControl(action, topic, data);
|
|
954
|
-
if (action !==
|
|
1278
|
+
if (action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
|
|
955
1279
|
return true;
|
|
956
1280
|
}
|
|
957
1281
|
return this.send({
|
|
958
|
-
type:
|
|
1282
|
+
type: CLUSTER_MESSAGE_TYPE.CONTROL,
|
|
959
1283
|
sourceWorkerId: this.workerId,
|
|
960
1284
|
targetWorkerId,
|
|
961
1285
|
action,
|
|
@@ -1060,7 +1384,7 @@ var WorkerClusterRuntime = class {
|
|
|
1060
1384
|
confirmedAt: this.environment.now()
|
|
1061
1385
|
});
|
|
1062
1386
|
const topic = this.knownTopics.get(topicKey);
|
|
1063
|
-
if (topic) this.handlers.onDiagnostic?.({ operation:
|
|
1387
|
+
if (topic) this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_ACK, topic });
|
|
1064
1388
|
}
|
|
1065
1389
|
/** Remove routes whose topic has no subscribers and whose TTL has expired. */
|
|
1066
1390
|
cleanupOrphanedRoutes(workers) {
|
|
@@ -1095,17 +1419,23 @@ var WorkerClusterRuntime = class {
|
|
|
1095
1419
|
* avoid a REGISTRY storm every 3 s; true on status/role changes that
|
|
1096
1420
|
* peers should observe promptly. */
|
|
1097
1421
|
writeRecord(notify) {
|
|
1098
|
-
|
|
1422
|
+
const now = this.environment.now();
|
|
1423
|
+
const sample = this.loadWeighting !== void 0 ? this.sampleThroughput(now) : void 0;
|
|
1424
|
+
this.currentRecord = {
|
|
1425
|
+
...this.currentRecord,
|
|
1426
|
+
heartbeatAt: now,
|
|
1427
|
+
...sample ? { throughput: sample } : {}
|
|
1428
|
+
};
|
|
1099
1429
|
if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
|
|
1100
1430
|
if (notify) this.notifyRegistry();
|
|
1101
1431
|
}
|
|
1102
1432
|
/** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
|
|
1103
1433
|
notifyRegistry() {
|
|
1104
|
-
this.send({ type:
|
|
1434
|
+
this.send({ type: CLUSTER_MESSAGE_TYPE.REGISTRY, sourceWorkerId: this.workerId });
|
|
1105
1435
|
}
|
|
1106
1436
|
/** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
|
|
1107
1437
|
refreshRole(workers) {
|
|
1108
|
-
const role = this.isActiveAmong(workers) ?
|
|
1438
|
+
const role = this.isActiveAmong(workers) ? WORKER_ROLE.ACTIVE : WORKER_ROLE.STANDBY;
|
|
1109
1439
|
if (role === this.currentRecord.role) return false;
|
|
1110
1440
|
this.currentRecord = { ...this.currentRecord, role };
|
|
1111
1441
|
return true;
|
|
@@ -1203,7 +1533,7 @@ var DataBusTraceReporter = class {
|
|
|
1203
1533
|
/** Start the periodic metrics flush interval. No-op when mode is 'events'
|
|
1204
1534
|
* (no metrics to emit), when disabled, or when already running. */
|
|
1205
1535
|
start() {
|
|
1206
|
-
if (!this.enabled || this.intervalHandle || this.mode ===
|
|
1536
|
+
if (!this.enabled || this.intervalHandle || this.mode === TRACE_MODE.EVENTS) return;
|
|
1207
1537
|
this.stopped = false;
|
|
1208
1538
|
this.intervalStartedAt = this.now();
|
|
1209
1539
|
this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
|
|
@@ -1219,11 +1549,40 @@ var DataBusTraceReporter = class {
|
|
|
1219
1549
|
this.stopped = true;
|
|
1220
1550
|
this.pause();
|
|
1221
1551
|
}
|
|
1552
|
+
/** Synchronous sink state for diagnostics: whether delivery is async and how
|
|
1553
|
+
* many events are queued behind the microtask flush. A growing queue under
|
|
1554
|
+
* `asyncSink: true` is the first sign of sink back-pressure. */
|
|
1555
|
+
getSinkState() {
|
|
1556
|
+
return { asyncSink: this.asyncSink, pendingEvents: this.pendingEvents.length };
|
|
1557
|
+
}
|
|
1222
1558
|
/** Record an instantaneous trace event (lifecycle, status, error, etc.). */
|
|
1223
1559
|
event(event) {
|
|
1224
|
-
if (!this.enabled || this.mode ===
|
|
1560
|
+
if (!this.enabled || this.mode === TRACE_MODE.METRICS) return;
|
|
1225
1561
|
this.emit({ ...event, timestamp: this.now() });
|
|
1226
1562
|
}
|
|
1563
|
+
/** Synchronous snapshot of the current metrics window without resetting it.
|
|
1564
|
+
* Returns the same derived counters as a periodic `message_metrics` event,
|
|
1565
|
+
* or null when metrics recording is inactive (disabled or events-only mode).
|
|
1566
|
+
* The window keeps accumulating until the next interval flush. */
|
|
1567
|
+
getMetrics() {
|
|
1568
|
+
if (!this.metricsActive || this.stopped) return null;
|
|
1569
|
+
const timestamp = this.now();
|
|
1570
|
+
const samples = this.latencySamples;
|
|
1571
|
+
return {
|
|
1572
|
+
durationMs: Math.max(0, timestamp - this.intervalStartedAt),
|
|
1573
|
+
received: this.received,
|
|
1574
|
+
dispatched: this.dispatched,
|
|
1575
|
+
topics: this.topics.size,
|
|
1576
|
+
dispatchSamples: samples,
|
|
1577
|
+
dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
|
|
1578
|
+
dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
|
|
1579
|
+
dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
|
|
1580
|
+
dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
|
|
1581
|
+
dedupAccepted: this.dedupAccepted,
|
|
1582
|
+
dedupSuppressed: this.dedupSuppressed,
|
|
1583
|
+
timestamp
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1227
1586
|
/** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
|
|
1228
1587
|
recordReceived(topic) {
|
|
1229
1588
|
if (!this.metricsActive) return;
|
|
@@ -1281,7 +1640,7 @@ var DataBusTraceReporter = class {
|
|
|
1281
1640
|
* Extracted so the four record / flush methods share one guard expression
|
|
1282
1641
|
* instead of repeating `!this.enabled || this.mode === 'events'` at each. */
|
|
1283
1642
|
get metricsActive() {
|
|
1284
|
-
return this.enabled && this.mode !==
|
|
1643
|
+
return this.enabled && this.mode !== TRACE_MODE.EVENTS;
|
|
1285
1644
|
}
|
|
1286
1645
|
/** Emit the accumulated metrics snapshot if the interval is active. */
|
|
1287
1646
|
flush() {
|
|
@@ -1293,7 +1652,7 @@ var DataBusTraceReporter = class {
|
|
|
1293
1652
|
if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
|
|
1294
1653
|
const samples = this.latencySamples;
|
|
1295
1654
|
this.emit({
|
|
1296
|
-
type:
|
|
1655
|
+
type: TRACE_EVENT_TYPE.MESSAGE_METRICS,
|
|
1297
1656
|
durationMs: Math.max(0, timestamp - this.intervalStartedAt),
|
|
1298
1657
|
received: this.received,
|
|
1299
1658
|
dispatched: this.dispatched,
|
|
@@ -1344,7 +1703,7 @@ var DataBusTraceReporter = class {
|
|
|
1344
1703
|
this.sink(event);
|
|
1345
1704
|
} catch (error) {
|
|
1346
1705
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
1347
|
-
console.warn(
|
|
1706
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] trace sink threw:`, error);
|
|
1348
1707
|
}
|
|
1349
1708
|
}
|
|
1350
1709
|
}
|
|
@@ -1370,16 +1729,446 @@ function roundMs(value) {
|
|
|
1370
1729
|
return Math.round(value * 10) / 10;
|
|
1371
1730
|
}
|
|
1372
1731
|
|
|
1373
|
-
// src/core/
|
|
1374
|
-
var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
|
|
1375
|
-
var SDK_VERSION = "0.20.69";
|
|
1376
|
-
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
1732
|
+
// src/core/replay-manager.ts
|
|
1377
1733
|
var PersistenceRetryCancelledError = class extends Error {
|
|
1378
1734
|
constructor() {
|
|
1379
1735
|
super("Persistence retry cancelled by lifecycle transition.");
|
|
1380
1736
|
this.name = "PersistenceRetryCancelledError";
|
|
1381
1737
|
}
|
|
1382
1738
|
};
|
|
1739
|
+
var MAX_RETRY_DELAY_MS2 = 1600;
|
|
1740
|
+
var ReplayManager = class {
|
|
1741
|
+
constructor(deps) {
|
|
1742
|
+
this.deps = deps;
|
|
1743
|
+
this.buffers = deps.enabled ? /* @__PURE__ */ new Map() : null;
|
|
1744
|
+
this.maxPerTopic = deps.maxPerTopic;
|
|
1745
|
+
this.persistence = deps.persistence ?? null;
|
|
1746
|
+
this.retentionMs = deps.retentionMs;
|
|
1747
|
+
this.pruneStrategy = deps.pruneStrategy;
|
|
1748
|
+
this.retentionSweepMs = deps.retentionSweepMs;
|
|
1749
|
+
this.persistenceRetryMaxAttempts = deps.persistenceRetryMaxAttempts;
|
|
1750
|
+
this.persistenceRetryBackoffMs = deps.persistenceRetryBackoffMs;
|
|
1751
|
+
this.now = deps.now;
|
|
1752
|
+
this.trace = deps.trace;
|
|
1753
|
+
this.onPersistenceError = deps.onPersistenceError;
|
|
1754
|
+
this.onDispatchError = deps.onDispatchError;
|
|
1755
|
+
this.hydration = this.hydrate();
|
|
1756
|
+
}
|
|
1757
|
+
deps;
|
|
1758
|
+
buffers;
|
|
1759
|
+
maxPerTopic;
|
|
1760
|
+
persistence;
|
|
1761
|
+
retentionMs;
|
|
1762
|
+
pruneStrategy;
|
|
1763
|
+
retentionSweepMs;
|
|
1764
|
+
persistenceRetryMaxAttempts;
|
|
1765
|
+
persistenceRetryBackoffMs;
|
|
1766
|
+
now;
|
|
1767
|
+
trace;
|
|
1768
|
+
onPersistenceError;
|
|
1769
|
+
onDispatchError;
|
|
1770
|
+
/** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
|
|
1771
|
+
retryGeneration = 0;
|
|
1772
|
+
pendingReplayPersistence = [];
|
|
1773
|
+
persistenceFlushScheduled = false;
|
|
1774
|
+
hydration;
|
|
1775
|
+
/** Coalesced retention cleanup: the newest cutoff wins while one is running. */
|
|
1776
|
+
retentionCleanup = null;
|
|
1777
|
+
retentionCutoff = null;
|
|
1778
|
+
retentionTimer = null;
|
|
1779
|
+
/** True when replay buffering is enabled. */
|
|
1780
|
+
get enabled() {
|
|
1781
|
+
return this.buffers !== null;
|
|
1782
|
+
}
|
|
1783
|
+
/** Append a dispatched publication to the topic's replay ring buffer.
|
|
1784
|
+
* No-op when replay is disabled. */
|
|
1785
|
+
record(message) {
|
|
1786
|
+
if (!this.buffers) return;
|
|
1787
|
+
let buffer = this.buffers.get(message.topic);
|
|
1788
|
+
if (!buffer) {
|
|
1789
|
+
buffer = [];
|
|
1790
|
+
this.buffers.set(message.topic, buffer);
|
|
1791
|
+
}
|
|
1792
|
+
buffer.push(message);
|
|
1793
|
+
const ageBounded = this.pruneStrategy !== PRUNE_STRATEGY.COUNT && this.retentionMs !== void 0;
|
|
1794
|
+
if (this.pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) {
|
|
1795
|
+
while (buffer.length > this.maxPerTopic) buffer.shift();
|
|
1796
|
+
}
|
|
1797
|
+
if (ageBounded) {
|
|
1798
|
+
const cutoff = this.now() - this.retentionMs;
|
|
1799
|
+
while (buffer.length > 0) {
|
|
1800
|
+
const first = buffer[0];
|
|
1801
|
+
if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
|
|
1802
|
+
buffer.shift();
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
if (!this.persistence) return;
|
|
1806
|
+
if (this.persistence.appendBatch) {
|
|
1807
|
+
this.pendingReplayPersistence.push(message);
|
|
1808
|
+
this.schedulePersistenceFlush();
|
|
1809
|
+
} else {
|
|
1810
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.append(message)).catch((error) => this.onPersistenceError(error));
|
|
1811
|
+
}
|
|
1812
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
1813
|
+
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
/** Deliver buffered history to a newly-registered handler. For an exact
|
|
1817
|
+
* topic this is that topic's ring; for a wildcard subscription every
|
|
1818
|
+
* buffered topic matching the pattern contributes (in buffer insertion
|
|
1819
|
+
* order). Replay deliveries are marked `replayed: true` and are not counted
|
|
1820
|
+
* into trace metrics.
|
|
1821
|
+
*
|
|
1822
|
+
* When a durable persistence backend is present, delivery waits for the
|
|
1823
|
+
* hydration load to settle first; `isHandlerActive` is then consulted so a
|
|
1824
|
+
* handler that unsubscribed during the async load does not receive history.
|
|
1825
|
+
*/
|
|
1826
|
+
deliverReplay(topic, replayOption, handler, isHandlerActive) {
|
|
1827
|
+
if (!this.buffers) return;
|
|
1828
|
+
const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
|
|
1829
|
+
if (this.persistence) {
|
|
1830
|
+
void this.hydration.then(() => {
|
|
1831
|
+
if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
|
|
1832
|
+
});
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
this.deliver(topic, limit, handler);
|
|
1836
|
+
}
|
|
1837
|
+
/** Clean up a topic that lost its last local handler: drop the ring buffer,
|
|
1838
|
+
* filter queued batch flushes (so an in-flight append cannot undo the
|
|
1839
|
+
* clearTopic), and prune durable history. */
|
|
1840
|
+
onTopicUnsubscribed(topic) {
|
|
1841
|
+
if (!this.buffers) return;
|
|
1842
|
+
this.buffers.delete(topic);
|
|
1843
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
1844
|
+
if (this.persistence?.clearTopic) {
|
|
1845
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
/** Clear all in-memory replay buffers and, when supported, durable history.
|
|
1849
|
+
* Reports persistence failures and rethrows, mirroring the public API
|
|
1850
|
+
* contract that callers can observe a failed clear. */
|
|
1851
|
+
async clearAll() {
|
|
1852
|
+
if (!this.buffers) return;
|
|
1853
|
+
this.buffers.clear();
|
|
1854
|
+
this.pendingReplayPersistence = [];
|
|
1855
|
+
if (this.persistence?.clear) {
|
|
1856
|
+
try {
|
|
1857
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
this.onPersistenceError(error);
|
|
1860
|
+
throw error;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
/** Clear replay history for one exact topic, including durable storage. */
|
|
1865
|
+
async clearTopic(topic) {
|
|
1866
|
+
if (!this.buffers) return;
|
|
1867
|
+
this.buffers.delete(topic);
|
|
1868
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
1869
|
+
if (this.persistence?.clearTopic) {
|
|
1870
|
+
try {
|
|
1871
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
this.onPersistenceError(error);
|
|
1874
|
+
throw error;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
1879
|
+
async clearBefore(timestamp) {
|
|
1880
|
+
if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
|
|
1881
|
+
if (this.buffers) {
|
|
1882
|
+
for (const [topic, messages] of this.buffers) {
|
|
1883
|
+
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
1884
|
+
if (kept.length) this.buffers.set(topic, kept);
|
|
1885
|
+
else this.buffers.delete(topic);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
|
|
1889
|
+
(message) => message.timestamp === void 0 || message.timestamp >= timestamp
|
|
1890
|
+
);
|
|
1891
|
+
if (this.persistence?.clearBefore) {
|
|
1892
|
+
try {
|
|
1893
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
this.onPersistenceError(error);
|
|
1896
|
+
throw error;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
/** Start the periodic retention sweep. No-op when no durable retention
|
|
1901
|
+
* config makes it necessary. */
|
|
1902
|
+
start() {
|
|
1903
|
+
if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
|
|
1904
|
+
this.retentionTimer = setInterval(() => {
|
|
1905
|
+
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
1906
|
+
}, this.retentionSweepMs);
|
|
1907
|
+
}
|
|
1908
|
+
/** Stop the periodic retention sweep. */
|
|
1909
|
+
stop() {
|
|
1910
|
+
if (this.retentionTimer) clearInterval(this.retentionTimer);
|
|
1911
|
+
this.retentionTimer = null;
|
|
1912
|
+
}
|
|
1913
|
+
/** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
|
|
1914
|
+
* or stopped bus does not keep hammering the store) and stop the sweep. */
|
|
1915
|
+
suspend() {
|
|
1916
|
+
this.retryGeneration += 1;
|
|
1917
|
+
this.stop();
|
|
1918
|
+
}
|
|
1919
|
+
/** Drop all in-memory buffers (used on full teardown). */
|
|
1920
|
+
resetBuffers() {
|
|
1921
|
+
this.buffers?.clear();
|
|
1922
|
+
}
|
|
1923
|
+
/** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
|
|
1924
|
+
* payload footprint (same heuristic as adaptive load weighting), computed on
|
|
1925
|
+
* demand so the hot append path never pays for it. */
|
|
1926
|
+
getStats() {
|
|
1927
|
+
let messages = 0;
|
|
1928
|
+
let bytes = 0;
|
|
1929
|
+
if (this.buffers) {
|
|
1930
|
+
for (const buffer of this.buffers.values()) {
|
|
1931
|
+
messages += buffer.length;
|
|
1932
|
+
for (const message of buffer) bytes += approximatePayloadBytes(message.data);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return { enabled: this.enabled, topics: this.buffers?.size ?? 0, messages, bytes };
|
|
1936
|
+
}
|
|
1937
|
+
/** Deliver history from one topic's ring to a handler, isolating a throwing
|
|
1938
|
+
* handler so the remaining buffers are still delivered. */
|
|
1939
|
+
deliver(topic, limit, handler) {
|
|
1940
|
+
if (!this.buffers || limit <= 0) return;
|
|
1941
|
+
const deliverBuffer = (buffer2) => {
|
|
1942
|
+
for (const message of buffer2.slice(-limit)) {
|
|
1943
|
+
try {
|
|
1944
|
+
handler({ ...message, replayed: true });
|
|
1945
|
+
} catch (error) {
|
|
1946
|
+
this.onDispatchError(error);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
if (isWildcardTopic(topic)) {
|
|
1951
|
+
for (const [bufferedTopic, buffer2] of this.buffers) {
|
|
1952
|
+
if (topicMatchesPattern(topic, bufferedTopic)) deliverBuffer(buffer2);
|
|
1953
|
+
}
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
const buffer = this.buffers.get(topic);
|
|
1957
|
+
if (buffer) deliverBuffer(buffer);
|
|
1958
|
+
}
|
|
1959
|
+
/** Coalesce queued persistence appends into a single microtask batch so a
|
|
1960
|
+
* burst of publications does not issue one IndexedDB transaction each.
|
|
1961
|
+
* Only reachable when the backend advertises `appendBatch` (the sole queuer,
|
|
1962
|
+
* `record()`, guards on it), so the batched path is unconditional here. */
|
|
1963
|
+
schedulePersistenceFlush() {
|
|
1964
|
+
if (this.persistenceFlushScheduled) return;
|
|
1965
|
+
this.persistenceFlushScheduled = true;
|
|
1966
|
+
queueMicrotask(() => {
|
|
1967
|
+
this.persistenceFlushScheduled = false;
|
|
1968
|
+
const batch = this.pendingReplayPersistence.splice(0);
|
|
1969
|
+
if (batch.length === 0 || !this.persistence) return;
|
|
1970
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
/** Load durable history into the in-memory rings once at startup, pruning
|
|
1974
|
+
* entries past the retention window first. Failures are reported but do not
|
|
1975
|
+
* block startup — the bus runs with whatever survived. */
|
|
1976
|
+
async hydrate() {
|
|
1977
|
+
if (!this.buffers || !this.persistence) {
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
try {
|
|
1981
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
1982
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
1983
|
+
}
|
|
1984
|
+
for (const message of await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load())) {
|
|
1985
|
+
let buffer = this.buffers.get(message.topic);
|
|
1986
|
+
if (!buffer) {
|
|
1987
|
+
buffer = [];
|
|
1988
|
+
this.buffers.set(message.topic, buffer);
|
|
1989
|
+
}
|
|
1990
|
+
buffer.push(message);
|
|
1991
|
+
if (buffer.length > this.maxPerTopic) buffer.shift();
|
|
1992
|
+
}
|
|
1993
|
+
} catch (error) {
|
|
1994
|
+
this.onPersistenceError(error);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
/** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
|
|
1998
|
+
* so a burst of publications issues at most one clearBefore transaction. */
|
|
1999
|
+
scheduleRetentionCleanup(cutoff) {
|
|
2000
|
+
if (!this.persistence?.clearBefore) return;
|
|
2001
|
+
if (this.retentionCutoff === null || cutoff > this.retentionCutoff) {
|
|
2002
|
+
this.retentionCutoff = cutoff;
|
|
2003
|
+
}
|
|
2004
|
+
if (this.retentionCleanup) return;
|
|
2005
|
+
this.retentionCleanup = (async () => {
|
|
2006
|
+
while (this.retentionCutoff !== null) {
|
|
2007
|
+
const nextCutoff = this.retentionCutoff;
|
|
2008
|
+
this.retentionCutoff = null;
|
|
2009
|
+
try {
|
|
2010
|
+
await this.persistence.clearBefore(nextCutoff);
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
this.onPersistenceError(error);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
})().finally(() => {
|
|
2016
|
+
this.retentionCleanup = null;
|
|
2017
|
+
if (this.retentionCutoff !== null) {
|
|
2018
|
+
this.scheduleRetentionCleanup(this.retentionCutoff);
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
/** Run a persistence operation with exponential backoff on transient failure.
|
|
2023
|
+
* Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
|
|
2024
|
+
* structurally failing operation throws after `persistenceRetryMaxAttempts`,
|
|
2025
|
+
* leaving the ring buffer intact so the bus keeps working. */
|
|
2026
|
+
async withPersistenceRetry(persistenceOperation, operation) {
|
|
2027
|
+
const generation = this.retryGeneration;
|
|
2028
|
+
let attempt = 0;
|
|
2029
|
+
let delay = this.persistenceRetryBackoffMs;
|
|
2030
|
+
while (true) {
|
|
2031
|
+
attempt += 1;
|
|
2032
|
+
try {
|
|
2033
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2034
|
+
return await operation();
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
if (error instanceof PersistenceRetryCancelledError || generation !== this.retryGeneration) {
|
|
2037
|
+
throw new PersistenceRetryCancelledError();
|
|
2038
|
+
}
|
|
2039
|
+
if (attempt >= this.persistenceRetryMaxAttempts) throw error;
|
|
2040
|
+
this.trace.event({
|
|
2041
|
+
type: TRACE_EVENT_TYPE.RELIABILITY,
|
|
2042
|
+
operation: RELIABILITY_OPERATION.PERSISTENCE_RETRY,
|
|
2043
|
+
persistenceOperation,
|
|
2044
|
+
attempt
|
|
2045
|
+
});
|
|
2046
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2047
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2048
|
+
delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS2);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
|
|
2054
|
+
// src/core/dedup-manager.ts
|
|
2055
|
+
var ADAPTIVE_WINDOW_MS = 5e3;
|
|
2056
|
+
var QUIET_RATE_PER_MS = 0.01;
|
|
2057
|
+
var DedupManager = class {
|
|
2058
|
+
enabled;
|
|
2059
|
+
maxEntries;
|
|
2060
|
+
ttlMs;
|
|
2061
|
+
adaptiveBounds;
|
|
2062
|
+
sweepMs;
|
|
2063
|
+
now;
|
|
2064
|
+
trace;
|
|
2065
|
+
seenMessageIds = /* @__PURE__ */ new Map();
|
|
2066
|
+
sweepTimer = null;
|
|
2067
|
+
windowStartedAt = 0;
|
|
2068
|
+
windowAccepted = 0;
|
|
2069
|
+
suppressed = 0;
|
|
2070
|
+
accepted = 0;
|
|
2071
|
+
constructor(options) {
|
|
2072
|
+
this.enabled = options.enabled;
|
|
2073
|
+
this.maxEntries = options.maxEntries;
|
|
2074
|
+
this.ttlMs = options.ttlMs;
|
|
2075
|
+
this.adaptiveBounds = options.adaptiveBounds;
|
|
2076
|
+
this.sweepMs = options.sweepMs;
|
|
2077
|
+
this.now = options.now;
|
|
2078
|
+
this.trace = options.trace;
|
|
2079
|
+
this.windowStartedAt = this.now();
|
|
2080
|
+
}
|
|
2081
|
+
/** True when a publication carrying `messageId` was already seen. Records the
|
|
2082
|
+
* ID and updates counters/trace on acceptance. Disabled or ID-less messages
|
|
2083
|
+
* always pass through. */
|
|
2084
|
+
isDuplicate(messageId, topic) {
|
|
2085
|
+
if (!this.enabled || !messageId) return false;
|
|
2086
|
+
const now = this.now();
|
|
2087
|
+
const ttlMs = this.currentTtl();
|
|
2088
|
+
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2089
|
+
if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
|
|
2090
|
+
}
|
|
2091
|
+
if (this.seenMessageIds.has(messageId)) {
|
|
2092
|
+
this.suppressed += 1;
|
|
2093
|
+
this.trace.event({
|
|
2094
|
+
type: TRACE_EVENT_TYPE.RELIABILITY,
|
|
2095
|
+
operation: RELIABILITY_OPERATION.DEDUP_SUPPRESSED,
|
|
2096
|
+
topic
|
|
2097
|
+
});
|
|
2098
|
+
this.trace.recordDedupSuppressed();
|
|
2099
|
+
return true;
|
|
2100
|
+
}
|
|
2101
|
+
this.seenMessageIds.set(messageId, now);
|
|
2102
|
+
this.accepted += 1;
|
|
2103
|
+
this.windowAccepted += 1;
|
|
2104
|
+
this.trace.recordDedupAccepted();
|
|
2105
|
+
while (this.seenMessageIds.size > this.maxEntries) {
|
|
2106
|
+
const oldest = this.seenMessageIds.keys().next().value;
|
|
2107
|
+
if (oldest === void 0) break;
|
|
2108
|
+
this.seenMessageIds.delete(oldest);
|
|
2109
|
+
}
|
|
2110
|
+
return false;
|
|
2111
|
+
}
|
|
2112
|
+
/** Start the periodic expiry sweep. No-op when disabled or no sweepMs was
|
|
2113
|
+
* configured (the hot-path opportunistic expiry still bounds the map). */
|
|
2114
|
+
start() {
|
|
2115
|
+
if (this.sweepTimer || !this.enabled || !this.sweepMs) return;
|
|
2116
|
+
this.sweepTimer = setInterval(() => this.pruneExpired(), this.sweepMs);
|
|
2117
|
+
}
|
|
2118
|
+
/** Stop the periodic expiry sweep. */
|
|
2119
|
+
stop() {
|
|
2120
|
+
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
2121
|
+
this.sweepTimer = null;
|
|
2122
|
+
}
|
|
2123
|
+
/** Return bounded deduplication counters for diagnostics and health checks. */
|
|
2124
|
+
getStats() {
|
|
2125
|
+
return {
|
|
2126
|
+
enabled: this.enabled,
|
|
2127
|
+
tracked: this.seenMessageIds.size,
|
|
2128
|
+
suppressed: this.suppressed,
|
|
2129
|
+
accepted: this.accepted,
|
|
2130
|
+
...this.adaptiveBounds ? { ttlMs: this.currentTtl() } : {}
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
/** Drop all remembered IDs and reset dedup counters. */
|
|
2134
|
+
reset() {
|
|
2135
|
+
this.seenMessageIds.clear();
|
|
2136
|
+
this.suppressed = 0;
|
|
2137
|
+
this.accepted = 0;
|
|
2138
|
+
this.windowStartedAt = this.now();
|
|
2139
|
+
this.windowAccepted = 0;
|
|
2140
|
+
}
|
|
2141
|
+
/** Remove IDs whose timestamp predates the effective TTL cutoff. */
|
|
2142
|
+
pruneExpired() {
|
|
2143
|
+
const cutoff = this.now() - this.currentTtl();
|
|
2144
|
+
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2145
|
+
if (timestamp < cutoff) this.seenMessageIds.delete(id);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
/** Effective TTL: the fixed `ttlMs` unless adaptive bounds are configured, in
|
|
2149
|
+
* which case a higher recent message rate shortens the window (dedup only
|
|
2150
|
+
* needs to live long enough to bridge duplicate bursts). The window resets
|
|
2151
|
+
* every ADAPTIVE_WINDOW_MS. */
|
|
2152
|
+
currentTtl() {
|
|
2153
|
+
if (!this.adaptiveBounds) return this.ttlMs;
|
|
2154
|
+
const now = this.now();
|
|
2155
|
+
const elapsed = now - this.windowStartedAt;
|
|
2156
|
+
if (elapsed >= ADAPTIVE_WINDOW_MS) {
|
|
2157
|
+
this.windowStartedAt = now;
|
|
2158
|
+
this.windowAccepted = 0;
|
|
2159
|
+
return this.adaptiveBounds.maxMs;
|
|
2160
|
+
}
|
|
2161
|
+
const rate = this.windowAccepted / Math.max(1, elapsed);
|
|
2162
|
+
const factor = Math.min(1, rate / QUIET_RATE_PER_MS);
|
|
2163
|
+
return this.adaptiveBounds.maxMs - (this.adaptiveBounds.maxMs - this.adaptiveBounds.minMs) * factor;
|
|
2164
|
+
}
|
|
2165
|
+
};
|
|
2166
|
+
|
|
2167
|
+
// src/core/version.ts
|
|
2168
|
+
var SDK_VERSION = true ? "0.20.85" : "";
|
|
2169
|
+
|
|
2170
|
+
// src/core/data-bus.ts
|
|
2171
|
+
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
1383
2172
|
var CrossTabDataBus = class {
|
|
1384
2173
|
transport;
|
|
1385
2174
|
cluster;
|
|
@@ -1390,43 +2179,14 @@ var CrossTabDataBus = class {
|
|
|
1390
2179
|
transportSubscribedTopics = /* @__PURE__ */ new Set();
|
|
1391
2180
|
statusHandlers = /* @__PURE__ */ new Set();
|
|
1392
2181
|
errorHandlers = /* @__PURE__ */ new Set();
|
|
1393
|
-
|
|
1394
|
-
// replay is enabled — buffering is opt-in and must cost nothing otherwise.
|
|
1395
|
-
replayBuffers;
|
|
1396
|
-
replayMaxPerTopic;
|
|
1397
|
-
replayPersistence;
|
|
1398
|
-
replayRetentionMs;
|
|
1399
|
-
replayPruneStrategy;
|
|
1400
|
-
replayRetentionSweepMs;
|
|
1401
|
-
persistenceRetryMaxAttempts;
|
|
1402
|
-
persistenceRetryBackoffMs;
|
|
1403
|
-
persistenceRetryGeneration = 0;
|
|
1404
|
-
pendingReplayPersistence = [];
|
|
1405
|
-
replayPersistenceFlushScheduled = false;
|
|
1406
|
-
replayHydration;
|
|
1407
|
-
// Retention cleanup is coalesced so a burst of publications does not issue
|
|
1408
|
-
// one IndexedDB read/write transaction per message. The newest cutoff wins.
|
|
1409
|
-
replayRetentionCleanup = null;
|
|
1410
|
-
replayRetentionCutoff = null;
|
|
1411
|
-
replayRetentionTimer = null;
|
|
2182
|
+
replayManager;
|
|
1412
2183
|
initialConfig;
|
|
1413
2184
|
hasInitialConfig;
|
|
1414
2185
|
trace;
|
|
1415
|
-
|
|
1416
|
-
dedupTtlMs;
|
|
1417
|
-
dedupAdaptiveBounds;
|
|
1418
|
-
dedupWindowStartedAt = 0;
|
|
1419
|
-
dedupWindowAccepted = 0;
|
|
1420
|
-
dedupAdaptiveWindowMs = 5e3;
|
|
1421
|
-
dedupSweepMs;
|
|
1422
|
-
dedupSweepTimer = null;
|
|
1423
|
-
dedupEnabled;
|
|
2186
|
+
dedupManager;
|
|
1424
2187
|
now;
|
|
1425
|
-
seenMessageIds = /* @__PURE__ */ new Map();
|
|
1426
|
-
dedupSuppressed = 0;
|
|
1427
|
-
dedupAccepted = 0;
|
|
1428
2188
|
activeConfig;
|
|
1429
|
-
status =
|
|
2189
|
+
status = WORKER_STATUS.DISCONNECTED;
|
|
1430
2190
|
started = false;
|
|
1431
2191
|
stopping = false;
|
|
1432
2192
|
transportReady = false;
|
|
@@ -1434,6 +2194,12 @@ var CrossTabDataBus = class {
|
|
|
1434
2194
|
// never awaited start() directly. Cleared on the next successful start.
|
|
1435
2195
|
lastError = null;
|
|
1436
2196
|
lastErrorAt = null;
|
|
2197
|
+
// Unified failure ledger for the health summary: the most recent failure of
|
|
2198
|
+
// any source (transport, persistence, dispatch) since the last explicit start.
|
|
2199
|
+
lastFailure = null;
|
|
2200
|
+
persistenceFailureCount = 0;
|
|
2201
|
+
persistenceLastFailureAt = null;
|
|
2202
|
+
persistenceLastErrorMessage = null;
|
|
1437
2203
|
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
1438
2204
|
// transition at a time. Resets to null once the operation settles.
|
|
1439
2205
|
startPromise = null;
|
|
@@ -1464,66 +2230,40 @@ var CrossTabDataBus = class {
|
|
|
1464
2230
|
recoveryMaxAttempts;
|
|
1465
2231
|
constructor(options) {
|
|
1466
2232
|
const replay = options.replay;
|
|
1467
|
-
|
|
1468
|
-
const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
|
|
1469
|
-
if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
|
|
1470
|
-
throw new TypeError(
|
|
1471
|
-
`replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
|
|
1472
|
-
);
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
this.replayMaxPerTopic = replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
|
|
1476
|
-
this.replayBuffers = replay ? /* @__PURE__ */ new Map() : null;
|
|
1477
|
-
this.replayPersistence = replay?.persistence ?? null;
|
|
1478
|
-
this.replayRetentionMs = replay?.retentionMs;
|
|
1479
|
-
this.replayPruneStrategy = replay?.pruneStrategy ?? "count";
|
|
1480
|
-
if (!["count", "age", "both"].includes(this.replayPruneStrategy)) throw new TypeError("replay.pruneStrategy must be count, age, or both.");
|
|
1481
|
-
if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
|
|
1482
|
-
throw new TypeError("replay.retentionMs must be a positive finite number.");
|
|
1483
|
-
}
|
|
1484
|
-
this.replayRetentionSweepMs = replay?.retentionSweepMs;
|
|
1485
|
-
if (this.replayRetentionSweepMs !== void 0 && (!Number.isFinite(this.replayRetentionSweepMs) || this.replayRetentionSweepMs <= 0)) {
|
|
1486
|
-
throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
|
|
1487
|
-
}
|
|
1488
|
-
this.persistenceRetryMaxAttempts = replay?.persistenceRetry?.maxAttempts ?? 1;
|
|
1489
|
-
this.persistenceRetryBackoffMs = replay?.persistenceRetry?.backoffMs ?? 50;
|
|
1490
|
-
if (!Number.isSafeInteger(this.persistenceRetryMaxAttempts) || this.persistenceRetryMaxAttempts <= 0) {
|
|
1491
|
-
throw new TypeError("replay.persistenceRetry.maxAttempts must be a positive safe integer.");
|
|
1492
|
-
}
|
|
1493
|
-
if (!Number.isFinite(this.persistenceRetryBackoffMs) || this.persistenceRetryBackoffMs < 0) {
|
|
1494
|
-
throw new TypeError("replay.persistenceRetry.backoffMs must be a non-negative finite number.");
|
|
1495
|
-
}
|
|
2233
|
+
assertReplayOptions(replay);
|
|
1496
2234
|
const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
|
|
2235
|
+
assertRecoveryOptions(recovery);
|
|
1497
2236
|
this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
|
|
1498
2237
|
this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
|
|
1499
|
-
if (!Number.isFinite(this.recoveryCooldownMs) || this.recoveryCooldownMs <= 0) {
|
|
1500
|
-
throw new TypeError("recovery.cooldownMs must be a positive finite number.");
|
|
1501
|
-
}
|
|
1502
|
-
if (!(this.recoveryMaxAttempts === Number.POSITIVE_INFINITY || Number.isSafeInteger(this.recoveryMaxAttempts) && this.recoveryMaxAttempts > 0)) {
|
|
1503
|
-
throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
|
|
1504
|
-
}
|
|
1505
2238
|
this.now = dedup?.now ?? Date.now;
|
|
1506
|
-
this.replayHydration = this.hydrateReplay();
|
|
1507
2239
|
this.transport = transport;
|
|
1508
2240
|
this.initialConfig = initialConfig;
|
|
1509
2241
|
this.hasInitialConfig = "initialConfig" in options;
|
|
1510
2242
|
this.trace = new DataBusTraceReporter(trace);
|
|
1511
|
-
this.
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
2243
|
+
this.replayManager = new ReplayManager({
|
|
2244
|
+
enabled: replay !== void 0,
|
|
2245
|
+
maxPerTopic: replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC,
|
|
2246
|
+
persistence: replay?.persistence ?? null,
|
|
2247
|
+
retentionMs: replay?.retentionMs,
|
|
2248
|
+
pruneStrategy: replay?.pruneStrategy ?? PRUNE_STRATEGY.COUNT,
|
|
2249
|
+
retentionSweepMs: replay?.retentionSweepMs,
|
|
2250
|
+
persistenceRetryMaxAttempts: replay?.persistenceRetry?.maxAttempts ?? 1,
|
|
2251
|
+
persistenceRetryBackoffMs: replay?.persistenceRetry?.backoffMs ?? 50,
|
|
2252
|
+
now: this.now,
|
|
2253
|
+
trace: this.trace,
|
|
2254
|
+
onPersistenceError: (error) => this.reportPersistenceError(error),
|
|
2255
|
+
onDispatchError: (error) => this.reportError(error, FAILURE_SOURCE.DISPATCH)
|
|
2256
|
+
});
|
|
2257
|
+
assertDedupOptions(dedup);
|
|
2258
|
+
this.dedupManager = new DedupManager({
|
|
2259
|
+
enabled: dedup !== void 0,
|
|
2260
|
+
maxEntries: dedup?.maxEntries ?? 1e3,
|
|
2261
|
+
ttlMs: dedup?.ttlMs ?? 6e4,
|
|
2262
|
+
adaptiveBounds: dedup?.adaptiveTtl,
|
|
2263
|
+
sweepMs: dedup?.sweepMs,
|
|
2264
|
+
now: this.now,
|
|
2265
|
+
trace: this.trace
|
|
2266
|
+
});
|
|
1527
2267
|
this.cluster = new WorkerClusterRuntime({
|
|
1528
2268
|
...clusterOptions,
|
|
1529
2269
|
handlers: {
|
|
@@ -1531,26 +2271,36 @@ var CrossTabDataBus = class {
|
|
|
1531
2271
|
// control message — meaning the owning Worker has delegated the action to us.
|
|
1532
2272
|
onControl: (action, topic, data, messageId, timestamp) => {
|
|
1533
2273
|
switch (action) {
|
|
1534
|
-
case
|
|
1535
|
-
if (this.subscribeTransport(topic)) this.traceSubscription(
|
|
2274
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
2275
|
+
if (this.subscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.SUBSCRIBE, topic);
|
|
1536
2276
|
break;
|
|
1537
|
-
case
|
|
1538
|
-
if (this.unsubscribeTransport(topic)) this.traceSubscription(
|
|
2277
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
2278
|
+
if (this.unsubscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.UNSUBSCRIBE, topic);
|
|
1539
2279
|
break;
|
|
1540
|
-
case
|
|
1541
|
-
this.runTransport(() => this.transport.publish(
|
|
1542
|
-
topic,
|
|
1543
|
-
data,
|
|
1544
|
-
messageId === void 0 && timestamp === void 0 ? void 0 : {
|
|
1545
|
-
...messageId === void 0 ? {} : { messageId },
|
|
1546
|
-
...timestamp === void 0 ? {} : { timestamp }
|
|
1547
|
-
}
|
|
1548
|
-
));
|
|
2280
|
+
case CONTROL_ACTION.PUBLISH:
|
|
2281
|
+
this.runTransport(() => this.transport.publish(topic, data, publicationMetadata(messageId, timestamp)));
|
|
1549
2282
|
break;
|
|
1550
2283
|
default:
|
|
1551
2284
|
break;
|
|
1552
2285
|
}
|
|
1553
2286
|
},
|
|
2287
|
+
// Batched variant of the PUBLISH action (CONTROL frames carrying
|
|
2288
|
+
// multiple items, and the local publishBatch fast path). Uses the
|
|
2289
|
+
// transport's one-frame publishBatch when available, preserving
|
|
2290
|
+
// per-item metadata; otherwise falls back to per-item publishes.
|
|
2291
|
+
onPublishBatch: (topic, items) => {
|
|
2292
|
+
if (typeof this.transport.publishBatch === "function") {
|
|
2293
|
+
this.runTransport(() => this.transport.publishBatch(topic, items));
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
for (const item of items) {
|
|
2297
|
+
this.runTransport(() => this.transport.publish(
|
|
2298
|
+
topic,
|
|
2299
|
+
item.data,
|
|
2300
|
+
publicationMetadata(item.messageId, item.timestamp)
|
|
2301
|
+
));
|
|
2302
|
+
}
|
|
2303
|
+
},
|
|
1554
2304
|
// The cluster calls `onEvent` when a publication broadcast arrives from
|
|
1555
2305
|
// another tab. Dispatch locally if we have subscribers. The payload is
|
|
1556
2306
|
// typed `unknown` at the cluster boundary (the cluster is transport-
|
|
@@ -1563,22 +2313,21 @@ var CrossTabDataBus = class {
|
|
|
1563
2313
|
if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
|
|
1564
2314
|
},
|
|
1565
2315
|
onSuspend: () => {
|
|
1566
|
-
if (!this.stopping) this.trace.event({ type:
|
|
2316
|
+
if (!this.stopping) this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.SUSPEND });
|
|
1567
2317
|
this.trace.pause();
|
|
1568
|
-
this.
|
|
2318
|
+
this.replayManager.suspend();
|
|
1569
2319
|
this.stopDedupSweep();
|
|
1570
|
-
this.stopReplayRetentionSweep();
|
|
1571
2320
|
this.suspendTransport();
|
|
1572
2321
|
},
|
|
1573
2322
|
onResume: () => {
|
|
1574
|
-
this.trace.event({ type:
|
|
2323
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
|
|
1575
2324
|
this.trace.start();
|
|
1576
2325
|
this.startDedupSweep();
|
|
1577
|
-
this.
|
|
2326
|
+
this.replayManager.start();
|
|
1578
2327
|
this.resumeTransport();
|
|
1579
2328
|
},
|
|
1580
2329
|
onDiagnostic: (event) => {
|
|
1581
|
-
this.trace.event({ type:
|
|
2330
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
|
|
1582
2331
|
}
|
|
1583
2332
|
}
|
|
1584
2333
|
});
|
|
@@ -1600,27 +2349,24 @@ var CrossTabDataBus = class {
|
|
|
1600
2349
|
this.suspended = false;
|
|
1601
2350
|
this.activeConfig = config;
|
|
1602
2351
|
this.lastError = null;
|
|
1603
|
-
this.
|
|
2352
|
+
this.lastFailure = null;
|
|
2353
|
+
this.persistenceFailureCount = 0;
|
|
2354
|
+
this.persistenceLastFailureAt = null;
|
|
2355
|
+
this.persistenceLastErrorMessage = null;
|
|
2356
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
1604
2357
|
this.trace.start();
|
|
1605
2358
|
this.startDedupSweep();
|
|
1606
|
-
this.
|
|
1607
|
-
this.updateStatus(
|
|
2359
|
+
this.replayManager.start();
|
|
2360
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
1608
2361
|
this.cluster.start();
|
|
1609
2362
|
const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
|
|
1610
2363
|
this.startPromise = opening;
|
|
1611
2364
|
for (const topic of this.topicHandlers.keys()) {
|
|
1612
2365
|
this.cluster.subscribe(topic);
|
|
1613
2366
|
}
|
|
1614
|
-
const snapshot = this.cluster.getSnapshot();
|
|
1615
|
-
this.trace.event({
|
|
1616
|
-
type: "coordination",
|
|
1617
|
-
coordinated: snapshot.coordinated,
|
|
1618
|
-
activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
|
|
1619
|
-
workers: snapshot.workers.map(formatWorkerTrace),
|
|
1620
|
-
routes: snapshot.routes.map(formatRouteTrace)
|
|
1621
|
-
});
|
|
1622
2367
|
void opening.then(
|
|
1623
2368
|
() => {
|
|
2369
|
+
this.emitCoordinationTrace();
|
|
1624
2370
|
if (this.startPromise === opening) this.startPromise = null;
|
|
1625
2371
|
},
|
|
1626
2372
|
() => {
|
|
@@ -1647,7 +2393,7 @@ var CrossTabDataBus = class {
|
|
|
1647
2393
|
onError: (error) => this.reportError(error)
|
|
1648
2394
|
})
|
|
1649
2395
|
).then(() => {
|
|
1650
|
-
if (this.status ===
|
|
2396
|
+
if (this.status === WORKER_STATUS.ERROR) {
|
|
1651
2397
|
throw new Error("Transport failed during startup.");
|
|
1652
2398
|
}
|
|
1653
2399
|
if (!this.suspended && !this.stopping) {
|
|
@@ -1661,7 +2407,7 @@ var CrossTabDataBus = class {
|
|
|
1661
2407
|
if (!this.pendingStop) {
|
|
1662
2408
|
this.pendingStop = this.createStopPromise();
|
|
1663
2409
|
}
|
|
1664
|
-
this.updateStatus(
|
|
2410
|
+
this.updateStatus(WORKER_STATUS.ERROR);
|
|
1665
2411
|
this.reportError(error);
|
|
1666
2412
|
this.lastError = error;
|
|
1667
2413
|
this.lastErrorAt = this.now();
|
|
@@ -1706,17 +2452,12 @@ var CrossTabDataBus = class {
|
|
|
1706
2452
|
this.topicHandlers.set(topic, handlers);
|
|
1707
2453
|
if (wasUnused) this.cluster.subscribe(topic);
|
|
1708
2454
|
if (options?.replay) {
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
2455
|
+
this.replayManager.deliverReplay(
|
|
2456
|
+
topic,
|
|
2457
|
+
options.replay,
|
|
2458
|
+
handler,
|
|
2459
|
+
() => Boolean(this.topicHandlers.get(topic)?.has(handler))
|
|
1712
2460
|
);
|
|
1713
|
-
if (this.replayPersistence) {
|
|
1714
|
-
void this.replayHydration.then(() => {
|
|
1715
|
-
if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
|
|
1716
|
-
});
|
|
1717
|
-
} else {
|
|
1718
|
-
this.deliverReplay(topic, limit, handler);
|
|
1719
|
-
}
|
|
1720
2461
|
}
|
|
1721
2462
|
return () => this.unsubscribe(topic, handler);
|
|
1722
2463
|
}
|
|
@@ -1731,70 +2472,28 @@ var CrossTabDataBus = class {
|
|
|
1731
2472
|
else handlers.clear();
|
|
1732
2473
|
if (handlers.size > 0) return;
|
|
1733
2474
|
this.topicHandlers.delete(topic);
|
|
1734
|
-
this.
|
|
1735
|
-
if (this.replayPersistence?.clearTopic) {
|
|
1736
|
-
void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
|
|
1737
|
-
}
|
|
2475
|
+
this.replayManager.onTopicUnsubscribed(topic);
|
|
1738
2476
|
this.cluster.unsubscribe(topic);
|
|
1739
2477
|
}
|
|
1740
2478
|
/** Clear all in-memory replay buffers and, when supported, durable history. */
|
|
1741
2479
|
async clearReplay() {
|
|
1742
|
-
this.
|
|
1743
|
-
if (this.replayPersistence?.clear) {
|
|
1744
|
-
try {
|
|
1745
|
-
await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
|
|
1746
|
-
} catch (error) {
|
|
1747
|
-
this.reportPersistenceError(error);
|
|
1748
|
-
throw error;
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
2480
|
+
await this.replayManager.clearAll();
|
|
1751
2481
|
}
|
|
1752
2482
|
/** Clear replay history for one exact topic, including durable storage. */
|
|
1753
2483
|
async clearReplayTopic(topic) {
|
|
1754
|
-
this.
|
|
1755
|
-
if (this.replayPersistence?.clearTopic) {
|
|
1756
|
-
try {
|
|
1757
|
-
await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
|
|
1758
|
-
} catch (error) {
|
|
1759
|
-
this.reportPersistenceError(error);
|
|
1760
|
-
throw error;
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
2484
|
+
await this.replayManager.clearTopic(topic);
|
|
1763
2485
|
}
|
|
1764
2486
|
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
1765
2487
|
async clearReplayBefore(timestamp) {
|
|
1766
|
-
|
|
1767
|
-
if (this.replayBuffers) {
|
|
1768
|
-
for (const [topic, messages] of this.replayBuffers) {
|
|
1769
|
-
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
1770
|
-
if (kept.length) this.replayBuffers.set(topic, kept);
|
|
1771
|
-
else this.replayBuffers.delete(topic);
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
if (this.replayPersistence?.clearBefore) {
|
|
1775
|
-
try {
|
|
1776
|
-
await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
|
|
1777
|
-
} catch (error) {
|
|
1778
|
-
this.reportPersistenceError(error);
|
|
1779
|
-
throw error;
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
2488
|
+
await this.replayManager.clearBefore(timestamp);
|
|
1782
2489
|
}
|
|
1783
2490
|
/** Return bounded deduplication counters for diagnostics and health checks. */
|
|
1784
2491
|
getDedupStats() {
|
|
1785
|
-
return
|
|
1786
|
-
enabled: this.dedupEnabled,
|
|
1787
|
-
tracked: this.seenMessageIds.size,
|
|
1788
|
-
suppressed: this.dedupSuppressed,
|
|
1789
|
-
accepted: this.dedupAccepted,
|
|
1790
|
-
...this.dedupAdaptiveBounds ? { ttlMs: this.currentDedupTtl() } : {}
|
|
1791
|
-
};
|
|
2492
|
+
return this.dedupManager.getStats();
|
|
1792
2493
|
}
|
|
1793
2494
|
/** Drop all remembered IDs and reset dedup counters. */
|
|
1794
2495
|
resetDedup() {
|
|
1795
|
-
this.
|
|
1796
|
-
this.dedupSuppressed = 0;
|
|
1797
|
-
this.dedupAccepted = 0;
|
|
2496
|
+
this.dedupManager.reset();
|
|
1798
2497
|
}
|
|
1799
2498
|
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
1800
2499
|
publish(topic, data, options) {
|
|
@@ -1822,8 +2521,7 @@ var CrossTabDataBus = class {
|
|
|
1822
2521
|
}
|
|
1823
2522
|
const mapped = items.map((item) => ({
|
|
1824
2523
|
data: item.data,
|
|
1825
|
-
...item.options?.messageId
|
|
1826
|
-
...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
|
|
2524
|
+
...publicationMetadata(item.options?.messageId, item.options?.timestamp)
|
|
1827
2525
|
}));
|
|
1828
2526
|
if (!this.cluster.publishBatch(topic, mapped)) {
|
|
1829
2527
|
this.reportError(
|
|
@@ -1868,6 +2566,41 @@ var CrossTabDataBus = class {
|
|
|
1868
2566
|
lastSuccessAt: this.lastSuccessAt
|
|
1869
2567
|
};
|
|
1870
2568
|
}
|
|
2569
|
+
/** Bounded failure counters for the replay persistence backend. */
|
|
2570
|
+
getPersistenceStats() {
|
|
2571
|
+
return {
|
|
2572
|
+
failures: this.persistenceFailureCount,
|
|
2573
|
+
lastFailureAt: this.persistenceLastFailureAt,
|
|
2574
|
+
lastErrorMessage: this.persistenceLastErrorMessage
|
|
2575
|
+
};
|
|
2576
|
+
}
|
|
2577
|
+
/** Compact health verdict for dashboards, readiness probes, and support
|
|
2578
|
+
* bundles. Answers "is the bus usable right now" first, then attaches the
|
|
2579
|
+
* unified failure ledger and recovery context that explains the verdict. */
|
|
2580
|
+
getHealthSummary() {
|
|
2581
|
+
const transport = this.transport;
|
|
2582
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2583
|
+
const state = !this.started ? HEALTH_STATE.STOPPED : this.suspended ? HEALTH_STATE.SUSPENDED : transportDown ? this.recoveryExhausted ? HEALTH_STATE.DEGRADED : this.status === WORKER_STATUS.CONNECTING && this.recoveryAttempt === 0 ? HEALTH_STATE.STARTING : HEALTH_STATE.RECOVERING : HEALTH_STATE.HEALTHY;
|
|
2584
|
+
return {
|
|
2585
|
+
healthy: state === HEALTH_STATE.HEALTHY,
|
|
2586
|
+
state,
|
|
2587
|
+
status: this.status,
|
|
2588
|
+
sdkVersion: SDK_VERSION,
|
|
2589
|
+
started: this.started,
|
|
2590
|
+
suspended: this.suspended,
|
|
2591
|
+
transport: {
|
|
2592
|
+
name: transport.diagnosticsName ?? transport.constructor.name,
|
|
2593
|
+
backend: transport.diagnosticsBackend ?? null,
|
|
2594
|
+
ready: this.transportReady,
|
|
2595
|
+
status: this.status
|
|
2596
|
+
},
|
|
2597
|
+
recovery: this.getRecoveryStats(),
|
|
2598
|
+
lastFailure: this.lastFailure,
|
|
2599
|
+
persistence: this.getPersistenceStats(),
|
|
2600
|
+
metrics: this.trace.getMetrics(),
|
|
2601
|
+
trace: this.trace.getSinkState()
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
1871
2604
|
/** Snapshot of the cluster state (workers, routes, assignments).
|
|
1872
2605
|
* For diagnostics only — the returned object is a shallow copy but
|
|
1873
2606
|
* nested arrays are snapshots at call time. */
|
|
@@ -1876,8 +2609,7 @@ var CrossTabDataBus = class {
|
|
|
1876
2609
|
}
|
|
1877
2610
|
/** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
|
|
1878
2611
|
getDiagnostics() {
|
|
1879
|
-
|
|
1880
|
-
if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
|
|
2612
|
+
const replay = this.replayManager.getStats();
|
|
1881
2613
|
const cluster = this.cluster.getSnapshot();
|
|
1882
2614
|
const unknownMessages = this.cluster.getUnknownMessageStats();
|
|
1883
2615
|
const transport = this.transport;
|
|
@@ -1888,12 +2620,26 @@ var CrossTabDataBus = class {
|
|
|
1888
2620
|
transportReady: this.transportReady,
|
|
1889
2621
|
recovery: this.getRecoveryStats(),
|
|
1890
2622
|
dedup: this.getDedupStats(),
|
|
1891
|
-
replay: { enabled:
|
|
2623
|
+
replay: { enabled: replay.enabled, topics: replay.topics, messages: replay.messages, bytes: replay.bytes },
|
|
2624
|
+
persistence: this.getPersistenceStats(),
|
|
1892
2625
|
protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
|
|
1893
|
-
transport: {
|
|
1894
|
-
|
|
2626
|
+
transport: {
|
|
2627
|
+
name: transport.diagnosticsName ?? transport.constructor.name,
|
|
2628
|
+
backend: transport.diagnosticsBackend ?? null,
|
|
2629
|
+
status: this.status,
|
|
2630
|
+
suspended: this.suspended
|
|
2631
|
+
},
|
|
2632
|
+
cluster,
|
|
2633
|
+
metrics: this.trace.getMetrics(),
|
|
2634
|
+
trace: this.trace.getSinkState()
|
|
1895
2635
|
};
|
|
1896
2636
|
}
|
|
2637
|
+
/** Synchronous snapshot of the current trace metrics window (throughput,
|
|
2638
|
+
* dispatch latency, dedup outcomes), without flushing or resetting it.
|
|
2639
|
+
* Returns null when trace metrics are inactive (disabled or events-only). */
|
|
2640
|
+
getMetrics() {
|
|
2641
|
+
return this.trace.getMetrics();
|
|
2642
|
+
}
|
|
1897
2643
|
/**
|
|
1898
2644
|
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
1899
2645
|
* and close the transport. Idempotent.
|
|
@@ -1901,13 +2647,12 @@ var CrossTabDataBus = class {
|
|
|
1901
2647
|
async stop() {
|
|
1902
2648
|
if (!this.started) return;
|
|
1903
2649
|
this.stopping = true;
|
|
1904
|
-
this.
|
|
1905
|
-
this.trace.event({ type:
|
|
2650
|
+
this.replayManager.suspend();
|
|
2651
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
1906
2652
|
this.trace.stop();
|
|
1907
2653
|
this.stopDedupSweep();
|
|
1908
|
-
this.stopReplayRetentionSweep();
|
|
1909
2654
|
this.topicHandlers.clear();
|
|
1910
|
-
this.
|
|
2655
|
+
this.replayManager.resetBuffers();
|
|
1911
2656
|
this.cluster.stop();
|
|
1912
2657
|
try {
|
|
1913
2658
|
await this.startPromise?.catch(() => void 0);
|
|
@@ -1928,7 +2673,7 @@ var CrossTabDataBus = class {
|
|
|
1928
2673
|
this.activeConfig = void 0;
|
|
1929
2674
|
this.recoveryAttempt = 0;
|
|
1930
2675
|
this.recoveryExhausted = false;
|
|
1931
|
-
this.updateStatus(
|
|
2676
|
+
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
1932
2677
|
}
|
|
1933
2678
|
}
|
|
1934
2679
|
/**
|
|
@@ -1937,7 +2682,7 @@ var CrossTabDataBus = class {
|
|
|
1937
2682
|
* and dispatches locally.
|
|
1938
2683
|
*/
|
|
1939
2684
|
handleTransportMessage(message) {
|
|
1940
|
-
if (this.isDuplicate(message)) return;
|
|
2685
|
+
if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
|
|
1941
2686
|
this.trace.recordReceived(message.topic);
|
|
1942
2687
|
if (!this.cluster.isAssigned(message.topic)) {
|
|
1943
2688
|
this.trace.recordDiscarded(message.topic);
|
|
@@ -1951,55 +2696,13 @@ var CrossTabDataBus = class {
|
|
|
1951
2696
|
}
|
|
1952
2697
|
this.trace.recordDiscarded(message.topic);
|
|
1953
2698
|
}
|
|
1954
|
-
|
|
1955
|
-
if (!this.dedupEnabled || !message.messageId) return false;
|
|
1956
|
-
const now = this.now();
|
|
1957
|
-
for (const [id, timestamp] of this.seenMessageIds) {
|
|
1958
|
-
if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
|
|
1959
|
-
}
|
|
1960
|
-
if (this.seenMessageIds.has(message.messageId)) {
|
|
1961
|
-
this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
|
|
1962
|
-
this.dedupSuppressed += 1;
|
|
1963
|
-
this.trace.recordDedupSuppressed();
|
|
1964
|
-
return true;
|
|
1965
|
-
}
|
|
1966
|
-
this.seenMessageIds.set(message.messageId, now);
|
|
1967
|
-
this.dedupAccepted += 1;
|
|
1968
|
-
this.dedupWindowAccepted += 1;
|
|
1969
|
-
this.trace.recordDedupAccepted();
|
|
1970
|
-
while (this.seenMessageIds.size > this.dedupMaxEntries) {
|
|
1971
|
-
const oldest = this.seenMessageIds.keys().next().value;
|
|
1972
|
-
if (oldest === void 0) break;
|
|
1973
|
-
this.seenMessageIds.delete(oldest);
|
|
1974
|
-
}
|
|
1975
|
-
return false;
|
|
1976
|
-
}
|
|
2699
|
+
/** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
|
|
1977
2700
|
startDedupSweep() {
|
|
1978
|
-
|
|
1979
|
-
this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
|
|
2701
|
+
this.dedupManager.start();
|
|
1980
2702
|
}
|
|
2703
|
+
/** Stop enqueuing the dedup expiry sweep. */
|
|
1981
2704
|
stopDedupSweep() {
|
|
1982
|
-
|
|
1983
|
-
this.dedupSweepTimer = null;
|
|
1984
|
-
}
|
|
1985
|
-
pruneExpiredDedup() {
|
|
1986
|
-
const cutoff = this.now() - this.currentDedupTtl();
|
|
1987
|
-
for (const [id, timestamp] of this.seenMessageIds) {
|
|
1988
|
-
if (timestamp < cutoff) this.seenMessageIds.delete(id);
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
currentDedupTtl() {
|
|
1992
|
-
if (!this.dedupAdaptiveBounds) return this.dedupTtlMs;
|
|
1993
|
-
const now = this.now();
|
|
1994
|
-
const elapsed = now - this.dedupWindowStartedAt;
|
|
1995
|
-
if (elapsed >= this.dedupAdaptiveWindowMs) {
|
|
1996
|
-
this.dedupWindowStartedAt = now;
|
|
1997
|
-
this.dedupWindowAccepted = 0;
|
|
1998
|
-
return this.dedupAdaptiveBounds.maxMs;
|
|
1999
|
-
}
|
|
2000
|
-
const rate = this.dedupWindowAccepted / Math.max(1, elapsed);
|
|
2001
|
-
const factor = Math.min(1, rate / 0.01);
|
|
2002
|
-
return this.dedupAdaptiveBounds.maxMs - (this.dedupAdaptiveBounds.maxMs - this.dedupAdaptiveBounds.minMs) * factor;
|
|
2705
|
+
this.dedupManager.stop();
|
|
2003
2706
|
}
|
|
2004
2707
|
/** Deliver a message to every local handler registered for its topic,
|
|
2005
2708
|
* plus every handler registered with a wildcard subscription that matches
|
|
@@ -2012,153 +2715,7 @@ var CrossTabDataBus = class {
|
|
|
2012
2715
|
this.invokeHandlers(handlers, (handler) => handler(message));
|
|
2013
2716
|
}
|
|
2014
2717
|
}
|
|
2015
|
-
this.
|
|
2016
|
-
}
|
|
2017
|
-
/** Append a dispatched publication to the topic's replay ring buffer.
|
|
2018
|
-
* No-op when replay is disabled. */
|
|
2019
|
-
recordReplay(message) {
|
|
2020
|
-
if (!this.replayBuffers) return;
|
|
2021
|
-
let buffer = this.replayBuffers.get(message.topic);
|
|
2022
|
-
if (!buffer) {
|
|
2023
|
-
buffer = [];
|
|
2024
|
-
this.replayBuffers.set(message.topic, buffer);
|
|
2025
|
-
}
|
|
2026
|
-
const storedMessage = message;
|
|
2027
|
-
buffer.push(storedMessage);
|
|
2028
|
-
if (this.replayPruneStrategy !== "age") {
|
|
2029
|
-
while (buffer.length > this.replayMaxPerTopic) buffer.shift();
|
|
2030
|
-
}
|
|
2031
|
-
if (this.replayPruneStrategy !== "count" && this.replayRetentionMs !== void 0) {
|
|
2032
|
-
const cutoff = this.now() - this.replayRetentionMs;
|
|
2033
|
-
while (buffer.length > 0) {
|
|
2034
|
-
const first = buffer[0];
|
|
2035
|
-
if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
|
|
2036
|
-
buffer.shift();
|
|
2037
|
-
}
|
|
2038
|
-
}
|
|
2039
|
-
if (this.replayPersistence) {
|
|
2040
|
-
if (this.replayPersistence.appendBatch) {
|
|
2041
|
-
this.pendingReplayPersistence.push(storedMessage);
|
|
2042
|
-
this.scheduleReplayPersistenceFlush();
|
|
2043
|
-
} else {
|
|
2044
|
-
void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
|
|
2045
|
-
}
|
|
2046
|
-
if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
|
|
2047
|
-
this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
scheduleReplayPersistenceFlush() {
|
|
2052
|
-
if (this.replayPersistenceFlushScheduled) return;
|
|
2053
|
-
this.replayPersistenceFlushScheduled = true;
|
|
2054
|
-
queueMicrotask(() => {
|
|
2055
|
-
this.replayPersistenceFlushScheduled = false;
|
|
2056
|
-
const batch = this.pendingReplayPersistence.splice(0);
|
|
2057
|
-
if (batch.length === 0 || !this.replayPersistence) return;
|
|
2058
|
-
const operation = this.replayPersistence.appendBatch ? () => this.replayPersistence.appendBatch(batch) : () => Promise.all(batch.map((message) => this.replayPersistence.append(message))).then(() => void 0);
|
|
2059
|
-
void this.withPersistenceRetry("append", operation).catch((error) => this.reportPersistenceError(error));
|
|
2060
|
-
});
|
|
2061
|
-
}
|
|
2062
|
-
async hydrateReplay() {
|
|
2063
|
-
if (!this.replayBuffers || !this.replayPersistence) {
|
|
2064
|
-
return;
|
|
2065
|
-
}
|
|
2066
|
-
try {
|
|
2067
|
-
if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
|
|
2068
|
-
await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
|
|
2069
|
-
}
|
|
2070
|
-
for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
|
|
2071
|
-
let buffer = this.replayBuffers.get(message.topic);
|
|
2072
|
-
if (!buffer) {
|
|
2073
|
-
buffer = [];
|
|
2074
|
-
this.replayBuffers.set(message.topic, buffer);
|
|
2075
|
-
}
|
|
2076
|
-
buffer.push(message);
|
|
2077
|
-
if (buffer.length > this.replayMaxPerTopic) buffer.shift();
|
|
2078
|
-
}
|
|
2079
|
-
} catch (error) {
|
|
2080
|
-
this.reportPersistenceError(error);
|
|
2081
|
-
}
|
|
2082
|
-
}
|
|
2083
|
-
scheduleReplayRetentionCleanup(cutoff) {
|
|
2084
|
-
if (!this.replayPersistence?.clearBefore) return;
|
|
2085
|
-
if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
|
|
2086
|
-
this.replayRetentionCutoff = cutoff;
|
|
2087
|
-
}
|
|
2088
|
-
if (this.replayRetentionCleanup) return;
|
|
2089
|
-
this.replayRetentionCleanup = (async () => {
|
|
2090
|
-
while (this.replayRetentionCutoff !== null) {
|
|
2091
|
-
const nextCutoff = this.replayRetentionCutoff;
|
|
2092
|
-
this.replayRetentionCutoff = null;
|
|
2093
|
-
try {
|
|
2094
|
-
await this.replayPersistence.clearBefore(nextCutoff);
|
|
2095
|
-
} catch (error) {
|
|
2096
|
-
this.reportPersistenceError(error);
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
})().finally(() => {
|
|
2100
|
-
this.replayRetentionCleanup = null;
|
|
2101
|
-
if (this.replayRetentionCutoff !== null) {
|
|
2102
|
-
this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
|
|
2103
|
-
}
|
|
2104
|
-
});
|
|
2105
|
-
}
|
|
2106
|
-
startReplayRetentionSweep() {
|
|
2107
|
-
if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
|
|
2108
|
-
this.replayRetentionTimer = setInterval(() => {
|
|
2109
|
-
this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
|
|
2110
|
-
}, this.replayRetentionSweepMs);
|
|
2111
|
-
}
|
|
2112
|
-
stopReplayRetentionSweep() {
|
|
2113
|
-
if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
|
|
2114
|
-
this.replayRetentionTimer = null;
|
|
2115
|
-
}
|
|
2116
|
-
async withPersistenceRetry(persistenceOperation, operation) {
|
|
2117
|
-
const generation = this.persistenceRetryGeneration;
|
|
2118
|
-
let attempt = 0;
|
|
2119
|
-
let delay = this.persistenceRetryBackoffMs;
|
|
2120
|
-
while (true) {
|
|
2121
|
-
attempt += 1;
|
|
2122
|
-
try {
|
|
2123
|
-
if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
|
|
2124
|
-
return await operation();
|
|
2125
|
-
} catch (error) {
|
|
2126
|
-
if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
|
|
2127
|
-
throw new PersistenceRetryCancelledError();
|
|
2128
|
-
}
|
|
2129
|
-
if (attempt >= this.persistenceRetryMaxAttempts) throw error;
|
|
2130
|
-
this.trace.event({
|
|
2131
|
-
type: "reliability",
|
|
2132
|
-
operation: "persistence_retry",
|
|
2133
|
-
persistenceOperation,
|
|
2134
|
-
attempt
|
|
2135
|
-
});
|
|
2136
|
-
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2137
|
-
if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
|
|
2138
|
-
delay = Math.min(delay * 2, 1600);
|
|
2139
|
-
}
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
/** Deliver buffered history to a newly-registered handler. For an exact
|
|
2143
|
-
* topic this is that topic's ring; for a wildcard subscription every
|
|
2144
|
-
* buffered topic matching the pattern contributes (in buffer insertion
|
|
2145
|
-
* order). Replay deliveries are marked `replayed: true` and are not
|
|
2146
|
-
* counted into trace metrics. */
|
|
2147
|
-
deliverReplay(topic, limit, handler) {
|
|
2148
|
-
if (!this.replayBuffers || limit <= 0) return;
|
|
2149
|
-
const deliver = (buffer2) => {
|
|
2150
|
-
for (const message of buffer2.slice(-limit)) {
|
|
2151
|
-
this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
|
|
2152
|
-
}
|
|
2153
|
-
};
|
|
2154
|
-
if (isWildcardTopic(topic)) {
|
|
2155
|
-
for (const [bufferedTopic, buffer2] of this.replayBuffers) {
|
|
2156
|
-
if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
|
|
2157
|
-
}
|
|
2158
|
-
return;
|
|
2159
|
-
}
|
|
2160
|
-
const buffer = this.replayBuffers.get(topic);
|
|
2161
|
-
if (buffer) deliver(buffer);
|
|
2718
|
+
this.replayManager.record(message);
|
|
2162
2719
|
}
|
|
2163
2720
|
/**
|
|
2164
2721
|
* Propagate a status change to the cluster, trace, and all registered
|
|
@@ -2167,13 +2724,13 @@ var CrossTabDataBus = class {
|
|
|
2167
2724
|
updateStatus(status) {
|
|
2168
2725
|
const previousStatus = this.status;
|
|
2169
2726
|
this.status = status;
|
|
2170
|
-
if (previousStatus !== status) this.trace.event({ type:
|
|
2727
|
+
if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
|
|
2171
2728
|
this.cluster.setStatus(status);
|
|
2172
|
-
if (status ===
|
|
2173
|
-
if (status ===
|
|
2729
|
+
if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
|
|
2730
|
+
if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
|
|
2174
2731
|
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
2175
2732
|
}
|
|
2176
|
-
if (status ===
|
|
2733
|
+
if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
|
|
2177
2734
|
const now = this.now();
|
|
2178
2735
|
if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
|
|
2179
2736
|
this.lastRecoveryAt = now;
|
|
@@ -2181,37 +2738,67 @@ var CrossTabDataBus = class {
|
|
|
2181
2738
|
if (attempt > this.recoveryMaxAttempts) {
|
|
2182
2739
|
if (!this.recoveryExhausted) {
|
|
2183
2740
|
this.recoveryExhausted = true;
|
|
2184
|
-
this.trace.event({ type:
|
|
2741
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
|
|
2185
2742
|
}
|
|
2186
2743
|
return;
|
|
2187
2744
|
}
|
|
2188
|
-
this.trace.event({ type:
|
|
2745
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
|
|
2189
2746
|
setTimeout(() => {
|
|
2190
2747
|
if (this.stopping || !this.started || this.suspended) return;
|
|
2191
|
-
if (this.status !==
|
|
2748
|
+
if (this.status !== WORKER_STATUS.ERROR) return;
|
|
2192
2749
|
void this.reopenTransport(attempt);
|
|
2193
2750
|
}, this.recoveryCooldownMs);
|
|
2194
2751
|
}
|
|
2195
2752
|
}
|
|
2196
2753
|
this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
|
|
2197
2754
|
}
|
|
2198
|
-
reportError(error) {
|
|
2199
|
-
this.
|
|
2200
|
-
|
|
2755
|
+
reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
|
|
2756
|
+
this.lastFailure = {
|
|
2757
|
+
source,
|
|
2758
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2759
|
+
at: this.now()
|
|
2760
|
+
};
|
|
2761
|
+
if (source === FAILURE_SOURCE.PERSISTENCE) {
|
|
2762
|
+
this.persistenceFailureCount += 1;
|
|
2763
|
+
this.persistenceLastFailureAt = this.lastFailure.at;
|
|
2764
|
+
this.persistenceLastErrorMessage = this.lastFailure.message;
|
|
2765
|
+
}
|
|
2766
|
+
this.trace.event({
|
|
2767
|
+
type: TRACE_EVENT_TYPE.ERROR,
|
|
2768
|
+
source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
|
|
2769
|
+
});
|
|
2770
|
+
this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
|
|
2201
2771
|
}
|
|
2772
|
+
/** Report a persistence failure to the trace and the unified failure ledger,
|
|
2773
|
+
* unless it is a {@link PersistenceRetryCancelledError} cancellation from a
|
|
2774
|
+
* lifecycle transition (teardown should stay quiet). */
|
|
2202
2775
|
reportPersistenceError(error) {
|
|
2203
2776
|
if (error instanceof PersistenceRetryCancelledError) return;
|
|
2204
|
-
this.trace.event({ type:
|
|
2205
|
-
this.reportError(error);
|
|
2777
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
|
|
2778
|
+
this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
|
|
2206
2779
|
}
|
|
2207
2780
|
traceSubscription(action, topic) {
|
|
2208
2781
|
this.trace.event({
|
|
2209
|
-
type:
|
|
2782
|
+
type: TRACE_EVENT_TYPE.SUBSCRIPTION,
|
|
2210
2783
|
action,
|
|
2211
2784
|
topic,
|
|
2212
2785
|
activeTopics: this.transportSubscribedTopics.size
|
|
2213
2786
|
});
|
|
2214
2787
|
}
|
|
2788
|
+
/** Emit the coordination trace snapshot from the current cluster state.
|
|
2789
|
+
* Called after a transport opens (start and recovery), when the role and
|
|
2790
|
+
* route picture has settled — the synchronous pre-open snapshot would see no
|
|
2791
|
+
* routes because their writes are still coalesced in the batch writer. */
|
|
2792
|
+
emitCoordinationTrace() {
|
|
2793
|
+
const snapshot = this.cluster.getSnapshot();
|
|
2794
|
+
this.trace.event({
|
|
2795
|
+
type: TRACE_EVENT_TYPE.COORDINATION,
|
|
2796
|
+
coordinated: snapshot.coordinated,
|
|
2797
|
+
activeWorkers: snapshot.workers.filter((worker) => worker.role === WORKER_ROLE.ACTIVE).length,
|
|
2798
|
+
workers: snapshot.workers.map(formatWorkerTrace),
|
|
2799
|
+
routes: snapshot.routes.map(formatRouteTrace)
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2215
2802
|
/** Ask the transport to subscribe to a topic (idempotent). */
|
|
2216
2803
|
subscribeTransport(topic) {
|
|
2217
2804
|
if (this.transportSubscribedTopics.has(topic)) return false;
|
|
@@ -2229,17 +2816,17 @@ var CrossTabDataBus = class {
|
|
|
2229
2816
|
* are routed to `reportError` (which surfaces them to error subscribers);
|
|
2230
2817
|
* error-handler failures are logged to the console to avoid infinite
|
|
2231
2818
|
* recursion through reportError itself. */
|
|
2232
|
-
invokeHandlers(handlers, callback, label =
|
|
2819
|
+
invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
|
|
2233
2820
|
for (const handler of handlers) {
|
|
2234
2821
|
try {
|
|
2235
2822
|
callback(handler);
|
|
2236
2823
|
} catch (error) {
|
|
2237
|
-
if (label ===
|
|
2824
|
+
if (label === INVOKE_LABEL.ERROR_HANDLER) {
|
|
2238
2825
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
2239
|
-
console.warn(
|
|
2826
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
|
|
2240
2827
|
}
|
|
2241
2828
|
} else {
|
|
2242
|
-
this.reportError(error);
|
|
2829
|
+
this.reportError(error, FAILURE_SOURCE.DISPATCH);
|
|
2243
2830
|
}
|
|
2244
2831
|
}
|
|
2245
2832
|
}
|
|
@@ -2253,7 +2840,7 @@ var CrossTabDataBus = class {
|
|
|
2253
2840
|
this.suspended = true;
|
|
2254
2841
|
this.transportReady = false;
|
|
2255
2842
|
this.transportSubscribedTopics.clear();
|
|
2256
|
-
this.updateStatus(
|
|
2843
|
+
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
2257
2844
|
if (this.pendingStop) return;
|
|
2258
2845
|
const pending = this.startPromise ?? Promise.resolve();
|
|
2259
2846
|
const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
|
|
@@ -2287,14 +2874,14 @@ var CrossTabDataBus = class {
|
|
|
2287
2874
|
const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
|
|
2288
2875
|
this.started = true;
|
|
2289
2876
|
this.suspended = false;
|
|
2290
|
-
this.updateStatus(
|
|
2877
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2291
2878
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
2292
2879
|
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
2293
2880
|
this.startPromise = opening;
|
|
2294
2881
|
void opening.then(
|
|
2295
2882
|
() => {
|
|
2296
2883
|
if (traceAttempt !== void 0) {
|
|
2297
|
-
this.trace.event({ type:
|
|
2884
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
|
|
2298
2885
|
this.recoveryAttempt = 0;
|
|
2299
2886
|
this.recoveryExhausted = false;
|
|
2300
2887
|
}
|
|
@@ -2302,7 +2889,7 @@ var CrossTabDataBus = class {
|
|
|
2302
2889
|
},
|
|
2303
2890
|
() => {
|
|
2304
2891
|
if (traceAttempt !== void 0) {
|
|
2305
|
-
this.trace.event({ type:
|
|
2892
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
|
|
2306
2893
|
}
|
|
2307
2894
|
}
|
|
2308
2895
|
);
|
|
@@ -2360,10 +2947,10 @@ function formatRouteTrace(route) {
|
|
|
2360
2947
|
function selectWorkerBackend(mode, availability = {}) {
|
|
2361
2948
|
const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
|
|
2362
2949
|
const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
|
|
2363
|
-
if (mode ===
|
|
2364
|
-
return hasShared ?
|
|
2950
|
+
if (mode === WORKER_MODE.SHARED || mode === WORKER_MODE.AUTO) {
|
|
2951
|
+
return hasShared ? WORKER_BACKEND.SHARED : hasDedicated ? WORKER_BACKEND.DEDICATED : WORKER_BACKEND.LOCAL;
|
|
2365
2952
|
}
|
|
2366
|
-
return hasDedicated ?
|
|
2953
|
+
return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
|
|
2367
2954
|
}
|
|
2368
2955
|
|
|
2369
2956
|
// src/core/publication.ts
|
|
@@ -2389,19 +2976,28 @@ function parseDataBusPublication(value, fallbackTopic) {
|
|
|
2389
2976
|
}
|
|
2390
2977
|
|
|
2391
2978
|
export {
|
|
2979
|
+
createStorageEventChannel,
|
|
2392
2980
|
createBrowserEnvironment,
|
|
2393
2981
|
getOrCreateTabId,
|
|
2394
2982
|
createOpaqueKey,
|
|
2395
2983
|
DEFAULT_MAX_ACTIVE_WORKERS,
|
|
2984
|
+
effectiveWorkerLoad,
|
|
2985
|
+
approximatePayloadBytes,
|
|
2396
2986
|
selectLeastLoadedWorker,
|
|
2397
2987
|
selectActiveWorkers,
|
|
2398
2988
|
selectRebalanceTarget,
|
|
2399
2989
|
hasActiveOwner,
|
|
2400
2990
|
isWildcardTopic,
|
|
2401
2991
|
topicMatchesPattern,
|
|
2992
|
+
publicationMetadata,
|
|
2993
|
+
assertPositiveSafeInteger,
|
|
2994
|
+
assertPositiveFiniteNumber,
|
|
2995
|
+
assertPruneStrategy,
|
|
2996
|
+
assertHeartbeatInterval,
|
|
2997
|
+
assertStructuredCloneable,
|
|
2402
2998
|
WorkerClusterRuntime,
|
|
2403
2999
|
CrossTabDataBus,
|
|
2404
3000
|
parseDataBusPublication,
|
|
2405
3001
|
selectWorkerBackend
|
|
2406
3002
|
};
|
|
2407
|
-
//# sourceMappingURL=chunk-
|
|
3003
|
+
//# sourceMappingURL=chunk-PW63EWIK.js.map
|