cross-tab-worker-databus 0.20.71 → 0.20.86
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 +216 -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-SDOV3UHG.js} +1304 -515
- package/dist/chunk-SDOV3UHG.js.map +7 -0
- package/dist/chunk-TZ7ZP7YD.js +175 -0
- package/dist/chunk-TZ7ZP7YD.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +1608 -650
- 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 +1692 -747
- 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 +134 -77
- 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 +129 -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/replay-pruning.d.ts +21 -0
- package/dist/core/replay-pruning.d.ts.map +1 -0
- 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 +263 -166
- 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 +135 -4
- package/docs/architecture.md +92 -1
- 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 +81 -5
- package/docs/zh/README.md +2 -1
- package/docs/zh/api.md +134 -4
- package/docs/zh/architecture.md +61 -1
- 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 +93 -5
- package/package.json +39 -16
- package/dist/chunk-D2SIT473.js.map +0 -7
package/dist/cjs/index.cjs
CHANGED
|
@@ -24,10 +24,13 @@ __export(src_exports, {
|
|
|
24
24
|
DEFAULT_MAX_ACTIVE_WORKERS: () => DEFAULT_MAX_ACTIVE_WORKERS,
|
|
25
25
|
WebSocketTransport: () => WebSocketTransport,
|
|
26
26
|
WorkerClusterRuntime: () => WorkerClusterRuntime,
|
|
27
|
+
approximatePayloadBytes: () => approximatePayloadBytes,
|
|
27
28
|
createBrowserEnvironment: () => createBrowserEnvironment,
|
|
28
29
|
createIndexedDbReplayPersistence: () => createIndexedDbReplayPersistence,
|
|
29
30
|
createOpaqueKey: () => createOpaqueKey,
|
|
31
|
+
createStorageEventChannel: () => createStorageEventChannel,
|
|
30
32
|
createWebSocketDataBus: () => createWebSocketDataBus,
|
|
33
|
+
effectiveWorkerLoad: () => effectiveWorkerLoad,
|
|
31
34
|
getOrCreateTabId: () => getOrCreateTabId,
|
|
32
35
|
hasActiveOwner: () => hasActiveOwner,
|
|
33
36
|
isWildcardTopic: () => isWildcardTopic,
|
|
@@ -39,6 +42,133 @@ __export(src_exports, {
|
|
|
39
42
|
});
|
|
40
43
|
module.exports = __toCommonJS(src_exports);
|
|
41
44
|
|
|
45
|
+
// src/utils/constants.ts
|
|
46
|
+
var WORKER_STATUS = {
|
|
47
|
+
CONNECTING: "connecting",
|
|
48
|
+
CONNECTED: "connected",
|
|
49
|
+
DISCONNECTED: "disconnected",
|
|
50
|
+
ERROR: "error"
|
|
51
|
+
};
|
|
52
|
+
var WORKER_ROLE = {
|
|
53
|
+
ACTIVE: "active",
|
|
54
|
+
STANDBY: "standby"
|
|
55
|
+
};
|
|
56
|
+
var TAB_VISIBILITY = {
|
|
57
|
+
VISIBLE: "visible",
|
|
58
|
+
HIDDEN: "hidden"
|
|
59
|
+
};
|
|
60
|
+
var WORKER_MODE = {
|
|
61
|
+
DEDICATED: "dedicated",
|
|
62
|
+
SHARED: "shared",
|
|
63
|
+
AUTO: "auto"
|
|
64
|
+
};
|
|
65
|
+
var WORKER_BACKEND = {
|
|
66
|
+
DEDICATED: "dedicated",
|
|
67
|
+
SHARED: "shared",
|
|
68
|
+
LOCAL: "local"
|
|
69
|
+
};
|
|
70
|
+
var CHANNEL_FALLBACK = {
|
|
71
|
+
NONE: "none",
|
|
72
|
+
STORAGE_EVENT: "storage-event"
|
|
73
|
+
};
|
|
74
|
+
var CONTROL_ACTION = {
|
|
75
|
+
SUBSCRIBE: "SUBSCRIBE",
|
|
76
|
+
UNSUBSCRIBE: "UNSUBSCRIBE",
|
|
77
|
+
PUBLISH: "PUBLISH"
|
|
78
|
+
};
|
|
79
|
+
var CLUSTER_MESSAGE_TYPE = {
|
|
80
|
+
CONTROL: "CONTROL",
|
|
81
|
+
EVENT: "EVENT",
|
|
82
|
+
REGISTRY: "REGISTRY",
|
|
83
|
+
ROUTE_RELEASED: "ROUTE_RELEASED"
|
|
84
|
+
};
|
|
85
|
+
var DEFAULT_STORAGE_PREFIX = "cross-tab-worker-databus";
|
|
86
|
+
var STORAGE_CHANNEL_PREFIX = `${DEFAULT_STORAGE_PREFIX}:channel:`;
|
|
87
|
+
var TAB_ID_STORAGE_KEY = `${DEFAULT_STORAGE_PREFIX}:tab-id`;
|
|
88
|
+
var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
|
|
89
|
+
var TRACE_EVENT_TYPE = {
|
|
90
|
+
LIFECYCLE: "lifecycle",
|
|
91
|
+
STATUS: "status",
|
|
92
|
+
SUBSCRIPTION: "subscription",
|
|
93
|
+
COORDINATION: "coordination",
|
|
94
|
+
ERROR: "error",
|
|
95
|
+
RELIABILITY: "reliability",
|
|
96
|
+
MESSAGE_METRICS: "message_metrics"
|
|
97
|
+
};
|
|
98
|
+
var TRACE_LIFECYCLE_ACTION = {
|
|
99
|
+
START: "start",
|
|
100
|
+
STOP: "stop",
|
|
101
|
+
SUSPEND: "suspend",
|
|
102
|
+
RESUME: "resume"
|
|
103
|
+
};
|
|
104
|
+
var INVOKE_LABEL = {
|
|
105
|
+
DISPATCH: "dispatch",
|
|
106
|
+
STATUS: "status",
|
|
107
|
+
ERROR_HANDLER: "error handler"
|
|
108
|
+
};
|
|
109
|
+
var TRACE_MODE = {
|
|
110
|
+
EVENTS: "events",
|
|
111
|
+
METRICS: "metrics",
|
|
112
|
+
ALL: "all"
|
|
113
|
+
};
|
|
114
|
+
var TRACE_ERROR_SOURCE = {
|
|
115
|
+
TRANSPORT: "transport",
|
|
116
|
+
OPERATION: "operation"
|
|
117
|
+
};
|
|
118
|
+
var RELIABILITY_OPERATION = {
|
|
119
|
+
TRANSPORT_RECOVERY: "transport_recovery",
|
|
120
|
+
ROUTE_ACK: "route_ack",
|
|
121
|
+
ROUTE_MIGRATION: "route_migration",
|
|
122
|
+
// A re-election that recovered a stranded unconfirmed handoff (previous
|
|
123
|
+
// owner gone, ACK never arrived). Distinct from ROUTE_MIGRATION so trace
|
|
124
|
+
// consumers can tell recoveries apart from routine graceful handoffs.
|
|
125
|
+
ROUTE_MIGRATION_RECOVERY: "route_migration_recovery",
|
|
126
|
+
PERSISTENCE_CLEANUP: "persistence_cleanup",
|
|
127
|
+
PERSISTENCE_RETRY: "persistence_retry",
|
|
128
|
+
DEDUP_SUPPRESSED: "dedup_suppressed"
|
|
129
|
+
};
|
|
130
|
+
var RECOVERY_OUTCOME = {
|
|
131
|
+
SCHEDULED: "scheduled",
|
|
132
|
+
SUCCEEDED: "succeeded",
|
|
133
|
+
FAILED: "failed",
|
|
134
|
+
EXHAUSTED: "exhausted"
|
|
135
|
+
};
|
|
136
|
+
var SUBSCRIPTION_ACTION = {
|
|
137
|
+
SUBSCRIBE: "subscribe",
|
|
138
|
+
UNSUBSCRIBE: "unsubscribe"
|
|
139
|
+
};
|
|
140
|
+
var PRUNE_STRATEGY = {
|
|
141
|
+
COUNT: "count",
|
|
142
|
+
AGE: "age",
|
|
143
|
+
BOTH: "both"
|
|
144
|
+
};
|
|
145
|
+
var PERSISTENCE_OPERATION = {
|
|
146
|
+
LOAD: "load",
|
|
147
|
+
APPEND: "append",
|
|
148
|
+
CLEAR: "clear",
|
|
149
|
+
CLEAR_TOPIC: "clearTopic",
|
|
150
|
+
CLEAR_BEFORE: "clearBefore"
|
|
151
|
+
};
|
|
152
|
+
var FAILURE_SOURCE = {
|
|
153
|
+
TRANSPORT: "transport",
|
|
154
|
+
PERSISTENCE: "persistence",
|
|
155
|
+
DISPATCH: "dispatch"
|
|
156
|
+
};
|
|
157
|
+
var HEALTH_STATE = {
|
|
158
|
+
STOPPED: "stopped",
|
|
159
|
+
STARTING: "starting",
|
|
160
|
+
HEALTHY: "healthy",
|
|
161
|
+
RECOVERING: "recovering",
|
|
162
|
+
SUSPENDED: "suspended",
|
|
163
|
+
DEGRADED: "degraded"
|
|
164
|
+
};
|
|
165
|
+
var WS_OP = {
|
|
166
|
+
SUBSCRIBE: "subscribe",
|
|
167
|
+
UNSUBSCRIBE: "unsubscribe",
|
|
168
|
+
PUBLISH: "publish",
|
|
169
|
+
PUBLISH_BATCH: "publishBatch"
|
|
170
|
+
};
|
|
171
|
+
|
|
42
172
|
// src/core/environment.ts
|
|
43
173
|
function getStorage(name) {
|
|
44
174
|
try {
|
|
@@ -54,8 +184,53 @@ function randomId() {
|
|
|
54
184
|
return Math.random().toString(36).slice(2);
|
|
55
185
|
}
|
|
56
186
|
}
|
|
187
|
+
function createStorageEventChannel(options) {
|
|
188
|
+
const { name, storage, win } = options;
|
|
189
|
+
if (!storage || !win) return null;
|
|
190
|
+
const key = `${STORAGE_CHANNEL_PREFIX}${name}`;
|
|
191
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
192
|
+
let sequence = 0;
|
|
193
|
+
const onStorage = (event) => {
|
|
194
|
+
if (event.key !== key || event.newValue === null) return;
|
|
195
|
+
let message;
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(event.newValue);
|
|
198
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.seq !== "number" || !parsed.message) return;
|
|
199
|
+
message = parsed.message;
|
|
200
|
+
} catch {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const event_ = { data: message };
|
|
204
|
+
for (const listener of [...listeners]) listener(event_);
|
|
205
|
+
};
|
|
206
|
+
win.addEventListener("storage", onStorage);
|
|
207
|
+
let closed = false;
|
|
208
|
+
return {
|
|
209
|
+
addEventListener(_type, listener) {
|
|
210
|
+
listeners.add(listener);
|
|
211
|
+
},
|
|
212
|
+
removeEventListener(_type, listener) {
|
|
213
|
+
listeners.delete(listener);
|
|
214
|
+
},
|
|
215
|
+
postMessage(message) {
|
|
216
|
+
if (closed) return;
|
|
217
|
+
sequence += 1;
|
|
218
|
+
storage.setItem(key, JSON.stringify({ seq: sequence, message }));
|
|
219
|
+
},
|
|
220
|
+
close() {
|
|
221
|
+
closed = true;
|
|
222
|
+
win.removeEventListener("storage", onStorage);
|
|
223
|
+
listeners.clear();
|
|
224
|
+
try {
|
|
225
|
+
storage.removeItem(key);
|
|
226
|
+
} catch {
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
57
231
|
var tabIdentityInitialized = false;
|
|
58
|
-
function createBrowserEnvironment() {
|
|
232
|
+
function createBrowserEnvironment(options) {
|
|
233
|
+
const channelFallback = options?.channelFallback ?? CHANNEL_FALLBACK.NONE;
|
|
59
234
|
return {
|
|
60
235
|
storage: getStorage("localStorage"),
|
|
61
236
|
sessionStorage: getStorage("sessionStorage"),
|
|
@@ -63,14 +238,18 @@ function createBrowserEnvironment() {
|
|
|
63
238
|
randomId,
|
|
64
239
|
createChannel: (name) => {
|
|
65
240
|
try {
|
|
66
|
-
|
|
241
|
+
if (typeof BroadcastChannel !== "undefined") return new BroadcastChannel(name);
|
|
67
242
|
} catch {
|
|
68
|
-
return null;
|
|
69
243
|
}
|
|
244
|
+
return channelFallback === CHANNEL_FALLBACK.STORAGE_EVENT ? createStorageEventChannel({
|
|
245
|
+
name,
|
|
246
|
+
storage: getStorage("localStorage"),
|
|
247
|
+
win: typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : null
|
|
248
|
+
}) : null;
|
|
70
249
|
},
|
|
71
250
|
setInterval: (callback, intervalMs) => globalThis.setInterval(callback, intervalMs),
|
|
72
251
|
clearInterval: (handle) => globalThis.clearInterval(handle),
|
|
73
|
-
getVisibilityState: () => typeof document !== "undefined" && document.visibilityState ===
|
|
252
|
+
getVisibilityState: () => typeof document !== "undefined" && document.visibilityState === TAB_VISIBILITY.HIDDEN ? TAB_VISIBILITY.HIDDEN : TAB_VISIBILITY.VISIBLE,
|
|
74
253
|
addVisibilityChangeListener: (listener) => {
|
|
75
254
|
if (typeof document !== "undefined") document.addEventListener("visibilitychange", listener);
|
|
76
255
|
},
|
|
@@ -101,7 +280,7 @@ function canUseStorage(storage, probeKey) {
|
|
|
101
280
|
return false;
|
|
102
281
|
}
|
|
103
282
|
}
|
|
104
|
-
function getOrCreateTabId(environment, key =
|
|
283
|
+
function getOrCreateTabId(environment, key = TAB_ID_STORAGE_KEY) {
|
|
105
284
|
const storage = environment.sessionStorage;
|
|
106
285
|
try {
|
|
107
286
|
const existing = storage?.getItem(key);
|
|
@@ -154,21 +333,73 @@ function avalancheMix(self, neighbor) {
|
|
|
154
333
|
|
|
155
334
|
// src/core/routing.ts
|
|
156
335
|
var DEFAULT_MAX_ACTIVE_WORKERS = 3;
|
|
157
|
-
function
|
|
336
|
+
function effectiveWorkerLoad(worker, options) {
|
|
337
|
+
const baseLoad = Number.isFinite(worker.load) ? worker.load : 0;
|
|
338
|
+
const sample = worker.throughput;
|
|
339
|
+
const messageRateWeight = options?.messageRateWeight ?? 0;
|
|
340
|
+
const byteRateWeight = options?.byteRateWeight ?? 0;
|
|
341
|
+
const scheduleLagWeight = options?.scheduleLagWeight ?? 0;
|
|
342
|
+
if (!sample || !Number.isFinite(sample.windowMs) || sample.windowMs <= 0 || messageRateWeight === 0 && byteRateWeight === 0 && scheduleLagWeight === 0) {
|
|
343
|
+
return baseLoad;
|
|
344
|
+
}
|
|
345
|
+
const windowSeconds = sample.windowMs / 1e3;
|
|
346
|
+
const messageRate = sample.messageCount / windowSeconds;
|
|
347
|
+
const byteRate = sample.byteCount / windowSeconds;
|
|
348
|
+
const scheduleLagRatio = sample.overrunMs / sample.windowMs;
|
|
349
|
+
const weighted = baseLoad + messageRateWeight * messageRate + byteRateWeight * byteRate + scheduleLagWeight * scheduleLagRatio;
|
|
350
|
+
return Number.isFinite(weighted) ? weighted : baseLoad;
|
|
351
|
+
}
|
|
352
|
+
function approximatePayloadBytes(payload) {
|
|
353
|
+
return estimatePayloadBytes(payload, 0);
|
|
354
|
+
}
|
|
355
|
+
var MAX_PAYLOAD_DEPTH = 6;
|
|
356
|
+
function estimatePayloadBytes(payload, depth) {
|
|
357
|
+
if (payload === null || payload === void 0) return 0;
|
|
358
|
+
switch (typeof payload) {
|
|
359
|
+
case "boolean":
|
|
360
|
+
return 4;
|
|
361
|
+
case "number":
|
|
362
|
+
case "bigint":
|
|
363
|
+
return 8;
|
|
364
|
+
case "string":
|
|
365
|
+
return payload.length;
|
|
366
|
+
case "symbol":
|
|
367
|
+
case "function":
|
|
368
|
+
return 0;
|
|
369
|
+
case "object":
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
if (depth >= MAX_PAYLOAD_DEPTH) return 0;
|
|
373
|
+
if (payload instanceof ArrayBuffer) return payload.byteLength;
|
|
374
|
+
if (ArrayBuffer.isView(payload)) return payload.byteLength;
|
|
375
|
+
if (Array.isArray(payload)) {
|
|
376
|
+
let sum2 = 8;
|
|
377
|
+
for (const item of payload) sum2 += estimatePayloadBytes(item, depth + 1);
|
|
378
|
+
return sum2;
|
|
379
|
+
}
|
|
380
|
+
let sum = 0;
|
|
381
|
+
for (const value of Object.values(payload)) {
|
|
382
|
+
sum += estimatePayloadBytes(value, depth + 1);
|
|
383
|
+
}
|
|
384
|
+
return sum;
|
|
385
|
+
}
|
|
386
|
+
function selectLeastLoadedWorker(workers, preferredWorkerId, options) {
|
|
158
387
|
const preferred = workers.find((worker) => worker.workerId === preferredWorkerId);
|
|
159
388
|
if (preferred) return preferred;
|
|
160
389
|
return workers.reduce((least, worker) => {
|
|
161
390
|
if (!least) return worker;
|
|
162
|
-
const byLoad = worker
|
|
391
|
+
const byLoad = effectiveWorkerLoad(worker, options) - effectiveWorkerLoad(least, options);
|
|
163
392
|
if (byLoad !== 0) return byLoad < 0 ? worker : least;
|
|
164
393
|
if (worker.workerId < least.workerId) return worker;
|
|
165
394
|
return least;
|
|
166
395
|
}, void 0);
|
|
167
396
|
}
|
|
168
397
|
function selectActiveWorkers(workers, maxActiveWorkers = DEFAULT_MAX_ACTIVE_WORKERS) {
|
|
169
|
-
const healthyWorkers = workers.filter(
|
|
398
|
+
const healthyWorkers = workers.filter(
|
|
399
|
+
(worker) => worker.status === WORKER_STATUS.CONNECTING || worker.status === WORKER_STATUS.CONNECTED
|
|
400
|
+
);
|
|
170
401
|
const availableWorkers = healthyWorkers.length > 0 ? healthyWorkers : [...workers];
|
|
171
|
-
const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState ===
|
|
402
|
+
const visibleWorkers = availableWorkers.filter((worker) => worker.visibilityState === TAB_VISIBILITY.VISIBLE);
|
|
172
403
|
const candidates = visibleWorkers.length > 0 ? visibleWorkers : availableWorkers;
|
|
173
404
|
return candidates.sort(
|
|
174
405
|
(left, right) => left.registeredAt - right.registeredAt || (left.workerId < right.workerId ? -1 : left.workerId > right.workerId ? 1 : 0)
|
|
@@ -204,6 +435,7 @@ var BatchingStorageWriter = class {
|
|
|
204
435
|
constructor(storage) {
|
|
205
436
|
this.storage = storage;
|
|
206
437
|
}
|
|
438
|
+
storage;
|
|
207
439
|
/** Coalesced write set. A `null` value represents a pending delete. */
|
|
208
440
|
pending = /* @__PURE__ */ new Map();
|
|
209
441
|
/** Per-key retry counter, reset on a successful write. */
|
|
@@ -260,7 +492,7 @@ var BatchingStorageWriter = class {
|
|
|
260
492
|
this.pending.delete(key);
|
|
261
493
|
this.retryCount.delete(key);
|
|
262
494
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
263
|
-
console.warn(
|
|
495
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] storage write gave up after retries, dropping key:`, key);
|
|
264
496
|
}
|
|
265
497
|
continue;
|
|
266
498
|
}
|
|
@@ -320,12 +552,16 @@ var BatchingStorageWriter = class {
|
|
|
320
552
|
}
|
|
321
553
|
};
|
|
322
554
|
|
|
323
|
-
// src/
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
555
|
+
// src/utils/metadata.ts
|
|
556
|
+
function publicationMetadata(messageId, timestamp) {
|
|
557
|
+
if (messageId === void 0 && timestamp === void 0) return void 0;
|
|
558
|
+
return {
|
|
559
|
+
...messageId === void 0 ? {} : { messageId },
|
|
560
|
+
...timestamp === void 0 ? {} : { timestamp }
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/utils/storage-utils.ts
|
|
329
565
|
function readJson(storage, key) {
|
|
330
566
|
try {
|
|
331
567
|
const value = storage.getItem(key);
|
|
@@ -340,13 +576,6 @@ function writeJson(storage, key, value) {
|
|
|
340
576
|
} catch {
|
|
341
577
|
}
|
|
342
578
|
}
|
|
343
|
-
function publicationMetadata(messageId, timestamp) {
|
|
344
|
-
if (messageId === void 0 && timestamp === void 0) return void 0;
|
|
345
|
-
return {
|
|
346
|
-
...messageId === void 0 ? {} : { messageId },
|
|
347
|
-
...timestamp === void 0 ? {} : { timestamp }
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
579
|
function listKeys(storage, prefix) {
|
|
351
580
|
try {
|
|
352
581
|
return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
|
|
@@ -359,6 +588,102 @@ function listKeys(storage, prefix) {
|
|
|
359
588
|
function readAllByPrefix(storage, prefix) {
|
|
360
589
|
return listKeys(storage, prefix).map((key) => ({ key, value: readJson(storage, key) })).filter((entry) => entry.value !== null);
|
|
361
590
|
}
|
|
591
|
+
|
|
592
|
+
// src/utils/validation.ts
|
|
593
|
+
function assertPositiveSafeInteger(value, name) {
|
|
594
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
595
|
+
throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}.`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function assertPositiveFiniteNumber(value, name) {
|
|
599
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
600
|
+
throw new TypeError(`${name} must be a positive finite number.`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function assertNonNegativeFiniteNumber(value, name) {
|
|
604
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
605
|
+
throw new TypeError(`${name} must be a non-negative finite number.`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function assertPruneStrategy(value) {
|
|
609
|
+
const allowed = [PRUNE_STRATEGY.COUNT, PRUNE_STRATEGY.AGE, PRUNE_STRATEGY.BOTH];
|
|
610
|
+
if (!allowed.includes(String(value))) {
|
|
611
|
+
throw new TypeError("pruneStrategy must be count, age, or both.");
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function assertReplayOptions(replay) {
|
|
615
|
+
if (!replay) return;
|
|
616
|
+
if (replay.maxPerTopic !== void 0) assertPositiveSafeInteger(replay.maxPerTopic, "replay.maxPerTopic");
|
|
617
|
+
if (replay.pruneStrategy !== void 0) assertPruneStrategy(replay.pruneStrategy);
|
|
618
|
+
if (replay.retentionMs !== void 0) assertPositiveFiniteNumber(replay.retentionMs, "replay.retentionMs");
|
|
619
|
+
if (replay.retentionSweepMs !== void 0) {
|
|
620
|
+
assertPositiveFiniteNumber(replay.retentionSweepMs, "replay.retentionSweepMs");
|
|
621
|
+
}
|
|
622
|
+
if (replay.persistenceRetry) assertPersistenceRetryOptions(replay.persistenceRetry);
|
|
623
|
+
}
|
|
624
|
+
function assertPersistenceRetryOptions(retry) {
|
|
625
|
+
if (retry.maxAttempts !== void 0) {
|
|
626
|
+
assertPositiveSafeInteger(retry.maxAttempts, "replay.persistenceRetry.maxAttempts");
|
|
627
|
+
}
|
|
628
|
+
if (retry.backoffMs !== void 0) {
|
|
629
|
+
assertNonNegativeFiniteNumber(retry.backoffMs, "replay.persistenceRetry.backoffMs");
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function assertDedupOptions(dedup) {
|
|
633
|
+
if (!dedup) return;
|
|
634
|
+
if (dedup.maxEntries !== void 0) assertPositiveSafeInteger(dedup.maxEntries, "dedup.maxEntries");
|
|
635
|
+
if (dedup.ttlMs !== void 0) assertPositiveFiniteNumber(dedup.ttlMs, "dedup.ttlMs");
|
|
636
|
+
if (dedup.sweepMs !== void 0) assertPositiveFiniteNumber(dedup.sweepMs, "dedup.sweepMs");
|
|
637
|
+
const bounds = dedup.adaptiveTtl;
|
|
638
|
+
if (bounds !== void 0) {
|
|
639
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
640
|
+
if (!finite(bounds.minMs) || !finite(bounds.maxMs) || bounds.minMs <= 0 || bounds.maxMs < bounds.minMs) {
|
|
641
|
+
throw new TypeError("dedup.adaptiveTtl bounds are invalid.");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function assertRecoveryOptions(recovery) {
|
|
646
|
+
if (!recovery) return;
|
|
647
|
+
if (recovery.cooldownMs !== void 0) {
|
|
648
|
+
assertPositiveFiniteNumber(recovery.cooldownMs, "recovery.cooldownMs");
|
|
649
|
+
}
|
|
650
|
+
const maxAttempts = recovery.maxAttempts;
|
|
651
|
+
if (maxAttempts !== void 0 && !(maxAttempts === Number.POSITIVE_INFINITY || typeof maxAttempts === "number" && Number.isSafeInteger(maxAttempts) && maxAttempts > 0)) {
|
|
652
|
+
throw new TypeError("recovery.maxAttempts must be a positive safe integer.");
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function assertLoadWeightingOptions(loadWeighting) {
|
|
656
|
+
if (!loadWeighting) return;
|
|
657
|
+
const weights = {
|
|
658
|
+
messageRateWeight: loadWeighting.messageRateWeight,
|
|
659
|
+
byteRateWeight: loadWeighting.byteRateWeight,
|
|
660
|
+
scheduleLagWeight: loadWeighting.scheduleLagWeight
|
|
661
|
+
};
|
|
662
|
+
for (const [name, value] of Object.entries(weights)) {
|
|
663
|
+
if (value !== void 0) assertNonNegativeFiniteNumber(value, `loadWeighting.${name}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function assertClusterOptions(options) {
|
|
667
|
+
if (options.maxActiveWorkers !== void 0) {
|
|
668
|
+
assertPositiveSafeInteger(options.maxActiveWorkers, "maxActiveWorkers");
|
|
669
|
+
}
|
|
670
|
+
if (options.heartbeatIntervalMs !== void 0) {
|
|
671
|
+
assertPositiveFiniteNumber(options.heartbeatIntervalMs, "heartbeatIntervalMs");
|
|
672
|
+
}
|
|
673
|
+
if (options.workerTtlMs !== void 0) {
|
|
674
|
+
assertPositiveFiniteNumber(options.workerTtlMs, "workerTtlMs");
|
|
675
|
+
}
|
|
676
|
+
if (options.routeOwnerCacheMax !== void 0) {
|
|
677
|
+
assertPositiveSafeInteger(options.routeOwnerCacheMax, "routeOwnerCacheMax");
|
|
678
|
+
}
|
|
679
|
+
assertLoadWeightingOptions(options.loadWeighting);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/core/cluster.ts
|
|
683
|
+
var CLUSTER_PROTOCOL_VERSION = 1;
|
|
684
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e3;
|
|
685
|
+
var DEFAULT_WORKER_TTL_MS = 1e4;
|
|
686
|
+
var MAX_KNOWN_TOPICS = 500;
|
|
362
687
|
var WorkerClusterRuntime = class {
|
|
363
688
|
tabId;
|
|
364
689
|
workerId;
|
|
@@ -372,6 +697,14 @@ var WorkerClusterRuntime = class {
|
|
|
372
697
|
routePrefix;
|
|
373
698
|
subscriberPrefix;
|
|
374
699
|
channelName;
|
|
700
|
+
/** Adaptive load weighting options; undefined keeps legacy topic-count routing. */
|
|
701
|
+
loadWeighting;
|
|
702
|
+
/** Rolling traffic accumulator folded into the worker record on writeRecord. */
|
|
703
|
+
throughputWindow = {
|
|
704
|
+
startedAt: 0,
|
|
705
|
+
messageCount: 0,
|
|
706
|
+
byteCount: 0
|
|
707
|
+
};
|
|
375
708
|
// Topics this tab has subscribed to (local interest, plaintext).
|
|
376
709
|
subscribedTopics = /* @__PURE__ */ new Set();
|
|
377
710
|
// Topics assigned to this Worker as owner (topicKey → topic). Authoritative:
|
|
@@ -407,12 +740,14 @@ var WorkerClusterRuntime = class {
|
|
|
407
740
|
lifecycleListening = false;
|
|
408
741
|
currentRecord;
|
|
409
742
|
constructor(options) {
|
|
743
|
+
assertClusterOptions(options);
|
|
410
744
|
this.environment = options.environment ?? createBrowserEnvironment();
|
|
411
745
|
this.handlers = options.handlers;
|
|
412
746
|
this.maxActiveWorkers = options.maxActiveWorkers ?? DEFAULT_MAX_ACTIVE_WORKERS;
|
|
413
747
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
414
748
|
this.routeOwnerCacheMax = options.routeOwnerCacheMax ?? 256;
|
|
415
749
|
this.workerTtlMs = options.workerTtlMs ?? DEFAULT_WORKER_TTL_MS;
|
|
750
|
+
this.loadWeighting = options.loadWeighting;
|
|
416
751
|
const clusterHash = createOpaqueKey(options.clusterKey || "__default__");
|
|
417
752
|
const prefix = options.storagePrefix ?? DEFAULT_STORAGE_PREFIX;
|
|
418
753
|
const baseKey = `${prefix}:${clusterHash}`;
|
|
@@ -429,8 +764,8 @@ var WorkerClusterRuntime = class {
|
|
|
429
764
|
workerId: this.workerId,
|
|
430
765
|
tabId: this.tabId,
|
|
431
766
|
load: 0,
|
|
432
|
-
role:
|
|
433
|
-
status:
|
|
767
|
+
role: WORKER_ROLE.STANDBY,
|
|
768
|
+
status: WORKER_STATUS.CONNECTING,
|
|
434
769
|
visibilityState: this.environment.getVisibilityState(),
|
|
435
770
|
heartbeatAt: now,
|
|
436
771
|
registeredAt: now
|
|
@@ -483,7 +818,7 @@ var WorkerClusterRuntime = class {
|
|
|
483
818
|
this.writeRecord(true);
|
|
484
819
|
for (const topic of this.subscribedTopics) {
|
|
485
820
|
const topicKey = this.rememberTopic(topic);
|
|
486
|
-
if (!this.storage) this.sendControl(this.workerId,
|
|
821
|
+
if (!this.storage) this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
487
822
|
else this.writeSubscriber(topicKey);
|
|
488
823
|
}
|
|
489
824
|
this.reconcile();
|
|
@@ -511,9 +846,14 @@ var WorkerClusterRuntime = class {
|
|
|
511
846
|
this.removeStorage(this.workerStorageKey(this.workerId));
|
|
512
847
|
this.flushStorage();
|
|
513
848
|
this.notifyRegistry();
|
|
514
|
-
this.channel
|
|
849
|
+
const channel = this.channel;
|
|
515
850
|
this.channel = null;
|
|
516
851
|
this.handlers.onSuspend?.();
|
|
852
|
+
if (typeof globalThis.setTimeout === "function") {
|
|
853
|
+
globalThis.setTimeout(() => channel?.close(), 0);
|
|
854
|
+
} else {
|
|
855
|
+
channel?.close();
|
|
856
|
+
}
|
|
517
857
|
}
|
|
518
858
|
/** Update the worker's connection status and persist the change. */
|
|
519
859
|
setStatus(status) {
|
|
@@ -531,7 +871,7 @@ var WorkerClusterRuntime = class {
|
|
|
531
871
|
this.subscribedTopics.add(topic);
|
|
532
872
|
if (!this.started) return false;
|
|
533
873
|
if (!this.storage) {
|
|
534
|
-
this.sendControl(this.workerId,
|
|
874
|
+
this.sendControl(this.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
535
875
|
return true;
|
|
536
876
|
}
|
|
537
877
|
this.writeSubscriber(topicKey);
|
|
@@ -541,9 +881,9 @@ var WorkerClusterRuntime = class {
|
|
|
541
881
|
return existingRoute?.workerId === this.workerId;
|
|
542
882
|
}
|
|
543
883
|
const activeWorkers = selectActiveWorkers(workers, this.maxActiveWorkers);
|
|
544
|
-
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
884
|
+
const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
|
|
545
885
|
this.writeRoute(topicKey, owner, void 0, (existingRoute?.generation ?? 0) + 1);
|
|
546
|
-
this.sendControl(owner.workerId,
|
|
886
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
547
887
|
this.notifyRegistry();
|
|
548
888
|
return owner.workerId === this.workerId;
|
|
549
889
|
}
|
|
@@ -567,7 +907,7 @@ var WorkerClusterRuntime = class {
|
|
|
567
907
|
const subscribers = this.readSubscriberTabIds(topicKey, this.readWorkers());
|
|
568
908
|
if (subscribers.length === 0) {
|
|
569
909
|
this.removeStorage(this.routeStorageKey(topicKey));
|
|
570
|
-
if (notifyOwner) this.sendControl(route.workerId,
|
|
910
|
+
if (notifyOwner) this.sendControl(route.workerId, CONTROL_ACTION.UNSUBSCRIBE, topic, topicKey);
|
|
571
911
|
}
|
|
572
912
|
return topicKey;
|
|
573
913
|
}
|
|
@@ -586,15 +926,17 @@ var WorkerClusterRuntime = class {
|
|
|
586
926
|
continue;
|
|
587
927
|
}
|
|
588
928
|
const owner = selectLeastLoadedWorker(
|
|
589
|
-
activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load }))
|
|
929
|
+
activeWorkers.map((worker) => ({ ...worker, load: projectedLoads.get(worker.workerId) ?? worker.load })),
|
|
930
|
+
void 0,
|
|
931
|
+
this.loadWeighting
|
|
590
932
|
);
|
|
591
933
|
if (!owner) continue;
|
|
592
934
|
projectedLoads.set(owner.workerId, (projectedLoads.get(owner.workerId) ?? owner.load) + 1);
|
|
593
935
|
const generation = (previous?.generation ?? 0) + 1;
|
|
594
936
|
this.writeRoute(topicKey, owner, previous?.workerId, generation);
|
|
595
|
-
this.handlers.onDiagnostic?.({ operation:
|
|
937
|
+
this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION, topic });
|
|
596
938
|
this.flushStorage();
|
|
597
|
-
this.handlers.onControl(
|
|
939
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic);
|
|
598
940
|
this.sendRouteReleased(owner.workerId, topic, topicKey, generation);
|
|
599
941
|
}
|
|
600
942
|
}
|
|
@@ -602,22 +944,19 @@ var WorkerClusterRuntime = class {
|
|
|
602
944
|
const metadata = typeof metadataOrMessageId === "string" ? { messageId: metadataOrMessageId } : metadataOrMessageId;
|
|
603
945
|
const topicKey = this.rememberTopic(topic);
|
|
604
946
|
if (this.assignedTopics.has(topicKey)) {
|
|
605
|
-
return this.sendControl(this.workerId,
|
|
947
|
+
return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
606
948
|
}
|
|
607
949
|
const cachedPattern = this.wildcardPublishCache.get(topic);
|
|
608
|
-
if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
|
|
609
|
-
return this.sendControl(this.workerId, "PUBLISH", topic, topicKey, data, metadata);
|
|
610
|
-
}
|
|
611
950
|
if (cachedPattern === void 0) {
|
|
612
951
|
for (const pattern of this.assignedTopics.values()) {
|
|
613
952
|
if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
|
|
614
953
|
this.wildcardPublishCache.set(topic, pattern);
|
|
615
|
-
return this.sendControl(this.workerId,
|
|
954
|
+
return this.sendControl(this.workerId, CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
616
955
|
}
|
|
617
956
|
}
|
|
618
957
|
this.wildcardPublishCache.set(topic, null);
|
|
619
958
|
}
|
|
620
|
-
return this.sendControl(this.resolvePublishTarget(topic, topicKey),
|
|
959
|
+
return this.sendControl(this.resolvePublishTarget(topic, topicKey), CONTROL_ACTION.PUBLISH, topic, topicKey, data, metadata);
|
|
621
960
|
}
|
|
622
961
|
/**
|
|
623
962
|
* Burst-friendly variant of `publish()`: packs up to N items into a single
|
|
@@ -638,19 +977,15 @@ var WorkerClusterRuntime = class {
|
|
|
638
977
|
}
|
|
639
978
|
const topicKey = this.rememberTopic(topic);
|
|
640
979
|
if (this.assignedTopics.has(topicKey)) {
|
|
641
|
-
|
|
980
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
642
981
|
return true;
|
|
643
982
|
}
|
|
644
983
|
const cachedPattern = this.wildcardPublishCache.get(topic);
|
|
645
|
-
if (cachedPattern !== void 0 && cachedPattern !== null && this.assignedTopics.has(cachedPattern)) {
|
|
646
|
-
for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
|
|
647
|
-
return true;
|
|
648
|
-
}
|
|
649
984
|
if (cachedPattern === void 0) {
|
|
650
985
|
for (const pattern of this.assignedTopics.values()) {
|
|
651
986
|
if (pattern !== topic && topicMatchesPattern(pattern, topic)) {
|
|
652
987
|
this.wildcardPublishCache.set(topic, pattern);
|
|
653
|
-
|
|
988
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
654
989
|
return true;
|
|
655
990
|
}
|
|
656
991
|
}
|
|
@@ -658,14 +993,14 @@ var WorkerClusterRuntime = class {
|
|
|
658
993
|
}
|
|
659
994
|
const target = this.resolvePublishTarget(topic, topicKey);
|
|
660
995
|
if (target === this.workerId) {
|
|
661
|
-
|
|
996
|
+
this.dispatchLocalPublishBatch(topic, topicKey, items);
|
|
662
997
|
return true;
|
|
663
998
|
}
|
|
664
999
|
return this.send({
|
|
665
|
-
type:
|
|
1000
|
+
type: CLUSTER_MESSAGE_TYPE.CONTROL,
|
|
666
1001
|
sourceWorkerId: this.workerId,
|
|
667
1002
|
targetWorkerId: target,
|
|
668
|
-
action:
|
|
1003
|
+
action: CONTROL_ACTION.PUBLISH,
|
|
669
1004
|
topic,
|
|
670
1005
|
topicKey,
|
|
671
1006
|
items: items.map((item) => ({
|
|
@@ -696,8 +1031,17 @@ var WorkerClusterRuntime = class {
|
|
|
696
1031
|
dispatchLocalPublish(topic, topicKey, data, messageId, timestamp) {
|
|
697
1032
|
void topicKey;
|
|
698
1033
|
const meta = publicationMetadata(messageId, timestamp);
|
|
699
|
-
if (meta) this.handlers.onControl(
|
|
700
|
-
else this.handlers.onControl(
|
|
1034
|
+
if (meta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data, meta.messageId, meta.timestamp);
|
|
1035
|
+
else this.handlers.onControl(CONTROL_ACTION.PUBLISH, topic, data);
|
|
1036
|
+
}
|
|
1037
|
+
/** Fan out a publication batch to the local onControl path: one
|
|
1038
|
+
* onPublishBatch call when the owner supports it, per-item otherwise. */
|
|
1039
|
+
dispatchLocalPublishBatch(topic, topicKey, items) {
|
|
1040
|
+
if (this.handlers.onPublishBatch) {
|
|
1041
|
+
this.handlers.onPublishBatch(topic, items);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
for (const item of items) this.dispatchLocalPublish(topic, topicKey, item.data, item.messageId, item.timestamp);
|
|
701
1045
|
}
|
|
702
1046
|
/** True when `route` exists and its owner worker is among `workers`.
|
|
703
1047
|
* Shared by subscribe (skip re-assignment) and publish (route to owner).
|
|
@@ -711,7 +1055,39 @@ var WorkerClusterRuntime = class {
|
|
|
711
1055
|
* listeners can attribute the event to its source tab even after fan-out. */
|
|
712
1056
|
broadcastEvent(eventType, payload, originTabId) {
|
|
713
1057
|
const effectiveOriginTabId = originTabId ?? this.tabId;
|
|
714
|
-
this.
|
|
1058
|
+
this.recordTraffic(payload);
|
|
1059
|
+
this.send({ type: CLUSTER_MESSAGE_TYPE.EVENT, sourceWorkerId: this.workerId, eventType, payload, originTabId: effectiveOriginTabId });
|
|
1060
|
+
}
|
|
1061
|
+
/** Count one fan-out unit toward the adaptive load sample. No-op unless
|
|
1062
|
+
* adaptive weighting is configured. */
|
|
1063
|
+
recordTraffic(payload) {
|
|
1064
|
+
if (this.loadWeighting === void 0) return;
|
|
1065
|
+
this.throughputWindow.messageCount += 1;
|
|
1066
|
+
this.throughputWindow.byteCount += approximatePayloadBytes(payload);
|
|
1067
|
+
}
|
|
1068
|
+
/** Convert the accumulated window into a publishable throughput sample and
|
|
1069
|
+
* reset the accumulator. Returns undefined until a full window has elapsed
|
|
1070
|
+
* so the first write does not emit a zero-width sample. */
|
|
1071
|
+
sampleThroughput(now) {
|
|
1072
|
+
if (this.throughputWindow.startedAt === 0) {
|
|
1073
|
+
this.throughputWindow.startedAt = now;
|
|
1074
|
+
return void 0;
|
|
1075
|
+
}
|
|
1076
|
+
const windowMs = now - this.throughputWindow.startedAt;
|
|
1077
|
+
if (windowMs <= 0) return void 0;
|
|
1078
|
+
const sample = {
|
|
1079
|
+
windowMs,
|
|
1080
|
+
messageCount: this.throughputWindow.messageCount,
|
|
1081
|
+
byteCount: this.throughputWindow.byteCount,
|
|
1082
|
+
// How much later the heartbeat landed than its nominal interval. This
|
|
1083
|
+
// window is anchored at the previous writeRecord (one heartbeat tick),
|
|
1084
|
+
// so a starved event loop stretches windowMs past the interval and the
|
|
1085
|
+
// positive excess is the scheduling-overrun signal.
|
|
1086
|
+
overrunMs: Math.max(0, windowMs - this.heartbeatIntervalMs),
|
|
1087
|
+
sampledAt: now
|
|
1088
|
+
};
|
|
1089
|
+
this.throughputWindow = { startedAt: now, messageCount: 0, byteCount: 0 };
|
|
1090
|
+
return sample;
|
|
715
1091
|
}
|
|
716
1092
|
isAssigned(topic) {
|
|
717
1093
|
const topicKey = createOpaqueKey(topic);
|
|
@@ -801,14 +1177,14 @@ var WorkerClusterRuntime = class {
|
|
|
801
1177
|
const message = event.data;
|
|
802
1178
|
if (!message || message.sourceWorkerId === this.workerId) return;
|
|
803
1179
|
switch (message.type) {
|
|
804
|
-
case
|
|
1180
|
+
case CLUSTER_MESSAGE_TYPE.CONTROL:
|
|
805
1181
|
return this.handleControlMessage(message);
|
|
806
|
-
case
|
|
1182
|
+
case CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED:
|
|
807
1183
|
return this.handleRouteReleasedMessage(message);
|
|
808
|
-
case
|
|
1184
|
+
case CLUSTER_MESSAGE_TYPE.EVENT:
|
|
809
1185
|
this.handlers.onEvent(message.eventType, message.payload, message.sourceWorkerId, message.originTabId);
|
|
810
1186
|
return;
|
|
811
|
-
case
|
|
1187
|
+
case CLUSTER_MESSAGE_TYPE.REGISTRY:
|
|
812
1188
|
this.reconcile();
|
|
813
1189
|
return;
|
|
814
1190
|
default: {
|
|
@@ -825,19 +1201,23 @@ var WorkerClusterRuntime = class {
|
|
|
825
1201
|
if (message.targetWorkerId !== this.workerId) return;
|
|
826
1202
|
this.rememberTopic(message.topic);
|
|
827
1203
|
switch (message.action) {
|
|
828
|
-
case
|
|
1204
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
829
1205
|
this.assignedTopics.set(message.topicKey, message.topic);
|
|
830
1206
|
this.confirmRoute(message.topicKey);
|
|
831
1207
|
break;
|
|
832
|
-
case
|
|
1208
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
833
1209
|
if (this.releaseHandoffOnUnsubscribe(message)) return;
|
|
834
1210
|
break;
|
|
835
|
-
case
|
|
1211
|
+
case CONTROL_ACTION.PUBLISH:
|
|
836
1212
|
if (message.items && message.items.length > 0) {
|
|
1213
|
+
if (this.handlers.onPublishBatch) {
|
|
1214
|
+
this.handlers.onPublishBatch(message.topic, message.items);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
837
1217
|
for (const item of message.items) {
|
|
838
1218
|
const itemMeta = publicationMetadata(item.messageId, item.timestamp);
|
|
839
|
-
if (itemMeta) this.handlers.onControl(
|
|
840
|
-
else this.handlers.onControl(
|
|
1219
|
+
if (itemMeta) this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data, itemMeta.messageId, itemMeta.timestamp);
|
|
1220
|
+
else this.handlers.onControl(CONTROL_ACTION.PUBLISH, message.topic, item.data);
|
|
841
1221
|
}
|
|
842
1222
|
return;
|
|
843
1223
|
}
|
|
@@ -854,7 +1234,7 @@ var WorkerClusterRuntime = class {
|
|
|
854
1234
|
metadata.timestamp
|
|
855
1235
|
);
|
|
856
1236
|
else this.handlers.onControl(message.action, message.topic, message.data);
|
|
857
|
-
if (message.action !==
|
|
1237
|
+
if (message.action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
|
|
858
1238
|
}
|
|
859
1239
|
/**
|
|
860
1240
|
* When this worker is the previous owner in a graceful handoff and the new
|
|
@@ -866,7 +1246,7 @@ var WorkerClusterRuntime = class {
|
|
|
866
1246
|
this.assignedTopics.delete(message.topicKey);
|
|
867
1247
|
const route = this.readRoute(message.topicKey);
|
|
868
1248
|
if (route?.handoffFromWorkerId !== this.workerId) return false;
|
|
869
|
-
this.handlers.onControl(
|
|
1249
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, message.topic, void 0);
|
|
870
1250
|
this.sendRouteReleased(route.workerId, message.topic, message.topicKey, route.generation);
|
|
871
1251
|
this.updateLoad();
|
|
872
1252
|
return true;
|
|
@@ -875,7 +1255,7 @@ var WorkerClusterRuntime = class {
|
|
|
875
1255
|
* generation so only the matching new owner may act on it. */
|
|
876
1256
|
sendRouteReleased(targetWorkerId, topic, topicKey, generation) {
|
|
877
1257
|
this.send({
|
|
878
|
-
type:
|
|
1258
|
+
type: CLUSTER_MESSAGE_TYPE.ROUTE_RELEASED,
|
|
879
1259
|
sourceWorkerId: this.workerId,
|
|
880
1260
|
targetWorkerId,
|
|
881
1261
|
topic,
|
|
@@ -894,14 +1274,24 @@ var WorkerClusterRuntime = class {
|
|
|
894
1274
|
if (!route || this.isStaleRouteRelease(route, message)) return;
|
|
895
1275
|
this.assignedTopics.set(message.topicKey, message.topic);
|
|
896
1276
|
this.confirmRoute(message.topicKey);
|
|
897
|
-
this.handlers.onControl(
|
|
1277
|
+
this.handlers.onControl(CONTROL_ACTION.SUBSCRIBE, message.topic, void 0);
|
|
898
1278
|
this.updateLoad();
|
|
899
1279
|
}
|
|
900
1280
|
/** A ROUTE_RELEASED is stale (and must be dropped) unless the route still
|
|
901
1281
|
* points to us, the release comes from the recorded previous owner, and
|
|
902
|
-
* the release generation is at least as new as ours.
|
|
1282
|
+
* the release generation is at least as new as ours. A replayed ACK from an
|
|
1283
|
+
* earlier handoff round (e.g. an a↔b ping-pong) carries an older generation
|
|
1284
|
+
* and must not confirm the current round. */
|
|
903
1285
|
isStaleRouteRelease(route, message) {
|
|
904
|
-
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId ||
|
|
1286
|
+
return route.workerId !== this.workerId || route.handoffFromWorkerId !== message.sourceWorkerId || message.generation < route.generation;
|
|
1287
|
+
}
|
|
1288
|
+
/** True when an unconfirmed handoff route has been stuck longer than a
|
|
1289
|
+
* worker TTL. The ACK for a live handoff is posted synchronously with the
|
|
1290
|
+
* route write, so anything older than the TTL with a dead previous owner
|
|
1291
|
+
* will never complete — while a fresh unconfirmed route may simply be
|
|
1292
|
+
* waiting out its confirmation flush and must be left alone. */
|
|
1293
|
+
isStaleHandoff(route) {
|
|
1294
|
+
return this.environment.now() - route.updatedAt > this.workerTtlMs;
|
|
905
1295
|
}
|
|
906
1296
|
/** Full reconciliation cycle: workers, subscriptions, and assigned topics. */
|
|
907
1297
|
reconcile() {
|
|
@@ -934,20 +1324,39 @@ var WorkerClusterRuntime = class {
|
|
|
934
1324
|
*/
|
|
935
1325
|
reconcileSubscriptions(workers, activeWorkers) {
|
|
936
1326
|
const liveWorkerIds = new Set(workers.map((worker) => worker.workerId));
|
|
1327
|
+
const recoveryProjectedLoads = /* @__PURE__ */ new Map();
|
|
937
1328
|
for (const topic of this.subscribedTopics) {
|
|
938
1329
|
const topicKey = this.rememberTopic(topic);
|
|
939
1330
|
this.writeSubscriber(topicKey);
|
|
940
1331
|
const route = this.readRoute(topicKey);
|
|
941
1332
|
if (!route || !liveWorkerIds.has(route.workerId)) {
|
|
942
|
-
const owner = selectLeastLoadedWorker(activeWorkers) ?? this.currentRecord;
|
|
1333
|
+
const owner = selectLeastLoadedWorker(activeWorkers, void 0, this.loadWeighting) ?? this.currentRecord;
|
|
943
1334
|
this.writeRoute(topicKey, owner, void 0, (route?.generation ?? 0) + 1);
|
|
944
|
-
this.sendControl(owner.workerId,
|
|
1335
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
945
1336
|
this.notifyRegistry();
|
|
946
1337
|
continue;
|
|
947
1338
|
}
|
|
948
1339
|
if (route.confirmedAt === void 0) {
|
|
949
1340
|
if (!route.handoffFromWorkerId) {
|
|
950
|
-
this.sendControl(route.workerId,
|
|
1341
|
+
this.sendControl(route.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
1342
|
+
} else if (!liveWorkerIds.has(route.handoffFromWorkerId) && this.isStaleHandoff(route)) {
|
|
1343
|
+
const owner = selectLeastLoadedWorker(
|
|
1344
|
+
activeWorkers.map((worker) => ({ ...worker, load: recoveryProjectedLoads.get(worker.workerId) ?? worker.load })),
|
|
1345
|
+
void 0,
|
|
1346
|
+
this.loadWeighting
|
|
1347
|
+
) ?? this.currentRecord;
|
|
1348
|
+
recoveryProjectedLoads.set(owner.workerId, (recoveryProjectedLoads.get(owner.workerId) ?? owner.load) + 1);
|
|
1349
|
+
if (owner.workerId !== this.workerId) {
|
|
1350
|
+
const subscriberTabIds = new Set(this.readSubscriberTabIds(topicKey, workers));
|
|
1351
|
+
const ownerSubscribed = workers.some(
|
|
1352
|
+
(worker) => worker.workerId === owner.workerId && subscriberTabIds.has(worker.tabId)
|
|
1353
|
+
);
|
|
1354
|
+
if (ownerSubscribed) continue;
|
|
1355
|
+
}
|
|
1356
|
+
this.writeRoute(topicKey, owner, void 0, route.generation + 1);
|
|
1357
|
+
this.sendControl(owner.workerId, CONTROL_ACTION.SUBSCRIBE, topic, topicKey);
|
|
1358
|
+
this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_MIGRATION_RECOVERY, topic });
|
|
1359
|
+
this.notifyRegistry();
|
|
951
1360
|
}
|
|
952
1361
|
}
|
|
953
1362
|
}
|
|
@@ -958,7 +1367,7 @@ var WorkerClusterRuntime = class {
|
|
|
958
1367
|
const route = this.readRoute(topicKey);
|
|
959
1368
|
if (route?.workerId === this.workerId) continue;
|
|
960
1369
|
this.assignedTopics.delete(topicKey);
|
|
961
|
-
this.handlers.onControl(
|
|
1370
|
+
this.handlers.onControl(CONTROL_ACTION.UNSUBSCRIBE, topic, void 0);
|
|
962
1371
|
if (route?.handoffFromWorkerId === this.workerId) {
|
|
963
1372
|
this.sendRouteReleased(route.workerId, topic, topicKey, route.generation);
|
|
964
1373
|
}
|
|
@@ -973,14 +1382,14 @@ var WorkerClusterRuntime = class {
|
|
|
973
1382
|
sendControl(targetWorkerId, action, topic, topicKey, data, metadata) {
|
|
974
1383
|
if (targetWorkerId === this.workerId) {
|
|
975
1384
|
switch (action) {
|
|
976
|
-
case
|
|
1385
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
977
1386
|
this.assignedTopics.set(topicKey, topic);
|
|
978
1387
|
this.confirmRoute(topicKey);
|
|
979
1388
|
break;
|
|
980
|
-
case
|
|
1389
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
981
1390
|
this.assignedTopics.delete(topicKey);
|
|
982
1391
|
break;
|
|
983
|
-
case
|
|
1392
|
+
case CONTROL_ACTION.PUBLISH:
|
|
984
1393
|
default:
|
|
985
1394
|
break;
|
|
986
1395
|
}
|
|
@@ -992,11 +1401,11 @@ var WorkerClusterRuntime = class {
|
|
|
992
1401
|
metadata.timestamp
|
|
993
1402
|
);
|
|
994
1403
|
else this.handlers.onControl(action, topic, data);
|
|
995
|
-
if (action !==
|
|
1404
|
+
if (action !== CONTROL_ACTION.PUBLISH) this.updateLoad();
|
|
996
1405
|
return true;
|
|
997
1406
|
}
|
|
998
1407
|
return this.send({
|
|
999
|
-
type:
|
|
1408
|
+
type: CLUSTER_MESSAGE_TYPE.CONTROL,
|
|
1000
1409
|
sourceWorkerId: this.workerId,
|
|
1001
1410
|
targetWorkerId,
|
|
1002
1411
|
action,
|
|
@@ -1101,7 +1510,7 @@ var WorkerClusterRuntime = class {
|
|
|
1101
1510
|
confirmedAt: this.environment.now()
|
|
1102
1511
|
});
|
|
1103
1512
|
const topic = this.knownTopics.get(topicKey);
|
|
1104
|
-
if (topic) this.handlers.onDiagnostic?.({ operation:
|
|
1513
|
+
if (topic) this.handlers.onDiagnostic?.({ operation: RELIABILITY_OPERATION.ROUTE_ACK, topic });
|
|
1105
1514
|
}
|
|
1106
1515
|
/** Remove routes whose topic has no subscribers and whose TTL has expired. */
|
|
1107
1516
|
cleanupOrphanedRoutes(workers) {
|
|
@@ -1136,17 +1545,23 @@ var WorkerClusterRuntime = class {
|
|
|
1136
1545
|
* avoid a REGISTRY storm every 3 s; true on status/role changes that
|
|
1137
1546
|
* peers should observe promptly. */
|
|
1138
1547
|
writeRecord(notify) {
|
|
1139
|
-
|
|
1548
|
+
const now = this.environment.now();
|
|
1549
|
+
const sample = this.loadWeighting !== void 0 ? this.sampleThroughput(now) : void 0;
|
|
1550
|
+
this.currentRecord = {
|
|
1551
|
+
...this.currentRecord,
|
|
1552
|
+
heartbeatAt: now,
|
|
1553
|
+
...sample ? { throughput: sample } : {}
|
|
1554
|
+
};
|
|
1140
1555
|
if (this.storage) writeJson(this.storage, this.workerStorageKey(this.workerId), this.currentRecord);
|
|
1141
1556
|
if (notify) this.notifyRegistry();
|
|
1142
1557
|
}
|
|
1143
1558
|
/** Broadcast a REGISTRY message to trigger reconciliation on other tabs. */
|
|
1144
1559
|
notifyRegistry() {
|
|
1145
|
-
this.send({ type:
|
|
1560
|
+
this.send({ type: CLUSTER_MESSAGE_TYPE.REGISTRY, sourceWorkerId: this.workerId });
|
|
1146
1561
|
}
|
|
1147
1562
|
/** Recompute whether this worker is active (eligible to own topics) or standby. Returns true when changed. */
|
|
1148
1563
|
refreshRole(workers) {
|
|
1149
|
-
const role = this.isActiveAmong(workers) ?
|
|
1564
|
+
const role = this.isActiveAmong(workers) ? WORKER_ROLE.ACTIVE : WORKER_ROLE.STANDBY;
|
|
1150
1565
|
if (role === this.currentRecord.role) return false;
|
|
1151
1566
|
this.currentRecord = { ...this.currentRecord, role };
|
|
1152
1567
|
return true;
|
|
@@ -1244,7 +1659,7 @@ var DataBusTraceReporter = class {
|
|
|
1244
1659
|
/** Start the periodic metrics flush interval. No-op when mode is 'events'
|
|
1245
1660
|
* (no metrics to emit), when disabled, or when already running. */
|
|
1246
1661
|
start() {
|
|
1247
|
-
if (!this.enabled || this.intervalHandle || this.mode ===
|
|
1662
|
+
if (!this.enabled || this.intervalHandle || this.mode === TRACE_MODE.EVENTS) return;
|
|
1248
1663
|
this.stopped = false;
|
|
1249
1664
|
this.intervalStartedAt = this.now();
|
|
1250
1665
|
this.intervalHandle = setInterval(() => this.flush(), this.metricsIntervalMs);
|
|
@@ -1260,11 +1675,40 @@ var DataBusTraceReporter = class {
|
|
|
1260
1675
|
this.stopped = true;
|
|
1261
1676
|
this.pause();
|
|
1262
1677
|
}
|
|
1678
|
+
/** Synchronous sink state for diagnostics: whether delivery is async and how
|
|
1679
|
+
* many events are queued behind the microtask flush. A growing queue under
|
|
1680
|
+
* `asyncSink: true` is the first sign of sink back-pressure. */
|
|
1681
|
+
getSinkState() {
|
|
1682
|
+
return { asyncSink: this.asyncSink, pendingEvents: this.pendingEvents.length };
|
|
1683
|
+
}
|
|
1263
1684
|
/** Record an instantaneous trace event (lifecycle, status, error, etc.). */
|
|
1264
1685
|
event(event) {
|
|
1265
|
-
if (!this.enabled || this.mode ===
|
|
1686
|
+
if (!this.enabled || this.mode === TRACE_MODE.METRICS) return;
|
|
1266
1687
|
this.emit({ ...event, timestamp: this.now() });
|
|
1267
1688
|
}
|
|
1689
|
+
/** Synchronous snapshot of the current metrics window without resetting it.
|
|
1690
|
+
* Returns the same derived counters as a periodic `message_metrics` event,
|
|
1691
|
+
* or null when metrics recording is inactive (disabled or events-only mode).
|
|
1692
|
+
* The window keeps accumulating until the next interval flush. */
|
|
1693
|
+
getMetrics() {
|
|
1694
|
+
if (!this.metricsActive || this.stopped) return null;
|
|
1695
|
+
const timestamp = this.now();
|
|
1696
|
+
const samples = this.latencySamples;
|
|
1697
|
+
return {
|
|
1698
|
+
durationMs: Math.max(0, timestamp - this.intervalStartedAt),
|
|
1699
|
+
received: this.received,
|
|
1700
|
+
dispatched: this.dispatched,
|
|
1701
|
+
topics: this.topics.size,
|
|
1702
|
+
dispatchSamples: samples,
|
|
1703
|
+
dispatchAvgMs: roundMs(samples === 0 ? 0 : this.latencySumMs / samples),
|
|
1704
|
+
dispatchP50Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.5)),
|
|
1705
|
+
dispatchP95Ms: roundMs(percentileMs(this.latencyBuckets, samples, 0.95)),
|
|
1706
|
+
dispatchMaxMs: roundMs(percentileMs(this.latencyBuckets, samples, 1)),
|
|
1707
|
+
dedupAccepted: this.dedupAccepted,
|
|
1708
|
+
dedupSuppressed: this.dedupSuppressed,
|
|
1709
|
+
timestamp
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1268
1712
|
/** Record that a message was received on `topic`; stores its timestamp for latency tracking. */
|
|
1269
1713
|
recordReceived(topic) {
|
|
1270
1714
|
if (!this.metricsActive) return;
|
|
@@ -1322,7 +1766,7 @@ var DataBusTraceReporter = class {
|
|
|
1322
1766
|
* Extracted so the four record / flush methods share one guard expression
|
|
1323
1767
|
* instead of repeating `!this.enabled || this.mode === 'events'` at each. */
|
|
1324
1768
|
get metricsActive() {
|
|
1325
|
-
return this.enabled && this.mode !==
|
|
1769
|
+
return this.enabled && this.mode !== TRACE_MODE.EVENTS;
|
|
1326
1770
|
}
|
|
1327
1771
|
/** Emit the accumulated metrics snapshot if the interval is active. */
|
|
1328
1772
|
flush() {
|
|
@@ -1334,7 +1778,7 @@ var DataBusTraceReporter = class {
|
|
|
1334
1778
|
if (this.received > 0 || this.dispatched > 0 || this.dedupAccepted > 0 || this.dedupSuppressed > 0) {
|
|
1335
1779
|
const samples = this.latencySamples;
|
|
1336
1780
|
this.emit({
|
|
1337
|
-
type:
|
|
1781
|
+
type: TRACE_EVENT_TYPE.MESSAGE_METRICS,
|
|
1338
1782
|
durationMs: Math.max(0, timestamp - this.intervalStartedAt),
|
|
1339
1783
|
received: this.received,
|
|
1340
1784
|
dispatched: this.dispatched,
|
|
@@ -1385,7 +1829,7 @@ var DataBusTraceReporter = class {
|
|
|
1385
1829
|
this.sink(event);
|
|
1386
1830
|
} catch (error) {
|
|
1387
1831
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
1388
|
-
console.warn(
|
|
1832
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] trace sink threw:`, error);
|
|
1389
1833
|
}
|
|
1390
1834
|
}
|
|
1391
1835
|
}
|
|
@@ -1411,192 +1855,642 @@ function roundMs(value) {
|
|
|
1411
1855
|
return Math.round(value * 10) / 10;
|
|
1412
1856
|
}
|
|
1413
1857
|
|
|
1414
|
-
// src/core/
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1858
|
+
// src/core/replay-pruning.ts
|
|
1859
|
+
function pruneReplayHistory(messages, options) {
|
|
1860
|
+
const { maxPerTopic, pruneStrategy, retentionMs, now } = options;
|
|
1861
|
+
const ageEnabled = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
|
|
1862
|
+
if (!ageEnabled) {
|
|
1863
|
+
return messages.length > maxPerTopic ? messages.slice(-maxPerTopic) : messages;
|
|
1864
|
+
}
|
|
1865
|
+
const cutoff = now - retentionMs;
|
|
1866
|
+
let hasExpired = false;
|
|
1867
|
+
let timestamplessCount = 0;
|
|
1868
|
+
for (const message of messages) {
|
|
1869
|
+
if (message.timestamp === void 0) timestamplessCount += 1;
|
|
1870
|
+
else if (message.timestamp < cutoff) hasExpired = true;
|
|
1871
|
+
}
|
|
1872
|
+
let pruned = hasExpired ? messages.filter((message) => message.timestamp === void 0 || message.timestamp >= cutoff) : messages;
|
|
1873
|
+
if (pruneStrategy === PRUNE_STRATEGY.BOTH) {
|
|
1874
|
+
return pruned.length > maxPerTopic ? pruned.slice(-maxPerTopic) : pruned;
|
|
1875
|
+
}
|
|
1876
|
+
if (timestamplessCount <= maxPerTopic) return pruned;
|
|
1877
|
+
let timestamplessToDrop = timestamplessCount - maxPerTopic;
|
|
1878
|
+
pruned = pruned.filter((message) => {
|
|
1879
|
+
if (message.timestamp === void 0 && timestamplessToDrop > 0) {
|
|
1880
|
+
timestamplessToDrop -= 1;
|
|
1881
|
+
return false;
|
|
1882
|
+
}
|
|
1883
|
+
return true;
|
|
1884
|
+
});
|
|
1885
|
+
return pruned;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// src/core/replay-manager.ts
|
|
1418
1889
|
var PersistenceRetryCancelledError = class extends Error {
|
|
1419
1890
|
constructor() {
|
|
1420
1891
|
super("Persistence retry cancelled by lifecycle transition.");
|
|
1421
1892
|
this.name = "PersistenceRetryCancelledError";
|
|
1422
1893
|
}
|
|
1423
1894
|
};
|
|
1424
|
-
var
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1895
|
+
var MAX_RETRY_DELAY_MS2 = 1600;
|
|
1896
|
+
var ReplayManager = class {
|
|
1897
|
+
constructor(deps) {
|
|
1898
|
+
this.deps = deps;
|
|
1899
|
+
this.buffers = deps.enabled ? /* @__PURE__ */ new Map() : null;
|
|
1900
|
+
this.maxPerTopic = deps.maxPerTopic;
|
|
1901
|
+
this.persistence = deps.persistence ?? null;
|
|
1902
|
+
this.retentionMs = deps.retentionMs;
|
|
1903
|
+
this.pruneStrategy = deps.pruneStrategy;
|
|
1904
|
+
this.retentionSweepMs = deps.retentionSweepMs;
|
|
1905
|
+
this.persistenceRetryMaxAttempts = deps.persistenceRetryMaxAttempts;
|
|
1906
|
+
this.persistenceRetryBackoffMs = deps.persistenceRetryBackoffMs;
|
|
1907
|
+
this.now = deps.now;
|
|
1908
|
+
this.trace = deps.trace;
|
|
1909
|
+
this.onPersistenceError = deps.onPersistenceError;
|
|
1910
|
+
this.onDispatchError = deps.onDispatchError;
|
|
1911
|
+
this.hydration = this.hydrate();
|
|
1912
|
+
}
|
|
1913
|
+
deps;
|
|
1914
|
+
buffers;
|
|
1915
|
+
maxPerTopic;
|
|
1916
|
+
persistence;
|
|
1917
|
+
retentionMs;
|
|
1918
|
+
pruneStrategy;
|
|
1919
|
+
retentionSweepMs;
|
|
1442
1920
|
persistenceRetryMaxAttempts;
|
|
1443
1921
|
persistenceRetryBackoffMs;
|
|
1444
|
-
persistenceRetryGeneration = 0;
|
|
1445
|
-
pendingReplayPersistence = [];
|
|
1446
|
-
replayPersistenceFlushScheduled = false;
|
|
1447
|
-
replayHydration;
|
|
1448
|
-
// Retention cleanup is coalesced so a burst of publications does not issue
|
|
1449
|
-
// one IndexedDB read/write transaction per message. The newest cutoff wins.
|
|
1450
|
-
replayRetentionCleanup = null;
|
|
1451
|
-
replayRetentionCutoff = null;
|
|
1452
|
-
replayRetentionTimer = null;
|
|
1453
|
-
initialConfig;
|
|
1454
|
-
hasInitialConfig;
|
|
1455
|
-
trace;
|
|
1456
|
-
dedupMaxEntries;
|
|
1457
|
-
dedupTtlMs;
|
|
1458
|
-
dedupAdaptiveBounds;
|
|
1459
|
-
dedupWindowStartedAt = 0;
|
|
1460
|
-
dedupWindowAccepted = 0;
|
|
1461
|
-
dedupAdaptiveWindowMs = 5e3;
|
|
1462
|
-
dedupSweepMs;
|
|
1463
|
-
dedupSweepTimer = null;
|
|
1464
|
-
dedupEnabled;
|
|
1465
1922
|
now;
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
if (replay) {
|
|
1509
|
-
const maxPerTopic = replay.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC;
|
|
1510
|
-
if (!Number.isSafeInteger(maxPerTopic) || maxPerTopic <= 0) {
|
|
1511
|
-
throw new TypeError(
|
|
1512
|
-
`replay.maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`
|
|
1513
|
-
);
|
|
1514
|
-
}
|
|
1923
|
+
trace;
|
|
1924
|
+
onPersistenceError;
|
|
1925
|
+
onDispatchError;
|
|
1926
|
+
/** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
|
|
1927
|
+
retryGeneration = 0;
|
|
1928
|
+
pendingReplayPersistence = [];
|
|
1929
|
+
persistenceFlushScheduled = false;
|
|
1930
|
+
hydration;
|
|
1931
|
+
/** Coalesced retention cleanup: the newest cutoff wins while one is running. */
|
|
1932
|
+
retentionCleanup = null;
|
|
1933
|
+
retentionCutoff = null;
|
|
1934
|
+
retentionTimer = null;
|
|
1935
|
+
/** True when replay buffering is enabled. */
|
|
1936
|
+
get enabled() {
|
|
1937
|
+
return this.buffers !== null;
|
|
1938
|
+
}
|
|
1939
|
+
/** Append a dispatched publication to the topic's replay ring buffer.
|
|
1940
|
+
* No-op when replay is disabled. */
|
|
1941
|
+
record(message) {
|
|
1942
|
+
if (!this.buffers) return;
|
|
1943
|
+
let buffer = this.buffers.get(message.topic);
|
|
1944
|
+
if (!buffer) {
|
|
1945
|
+
buffer = [];
|
|
1946
|
+
this.buffers.set(message.topic, buffer);
|
|
1947
|
+
}
|
|
1948
|
+
buffer.push(message);
|
|
1949
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
1950
|
+
maxPerTopic: this.maxPerTopic,
|
|
1951
|
+
pruneStrategy: this.pruneStrategy,
|
|
1952
|
+
retentionMs: this.retentionMs,
|
|
1953
|
+
now: this.now()
|
|
1954
|
+
});
|
|
1955
|
+
if (pruned !== buffer) {
|
|
1956
|
+
buffer = pruned;
|
|
1957
|
+
this.buffers.set(message.topic, buffer);
|
|
1958
|
+
}
|
|
1959
|
+
if (!this.persistence) return;
|
|
1960
|
+
if (this.persistence.appendBatch) {
|
|
1961
|
+
this.pendingReplayPersistence.push(message);
|
|
1962
|
+
this.schedulePersistenceFlush();
|
|
1963
|
+
} else {
|
|
1964
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.append(message)).catch((error) => this.onPersistenceError(error));
|
|
1515
1965
|
}
|
|
1516
|
-
this.
|
|
1517
|
-
|
|
1518
|
-
this.replayPersistence = replay?.persistence ?? null;
|
|
1519
|
-
this.replayRetentionMs = replay?.retentionMs;
|
|
1520
|
-
this.replayPruneStrategy = replay?.pruneStrategy ?? "count";
|
|
1521
|
-
if (!["count", "age", "both"].includes(this.replayPruneStrategy)) throw new TypeError("replay.pruneStrategy must be count, age, or both.");
|
|
1522
|
-
if (this.replayRetentionMs !== void 0 && (!Number.isFinite(this.replayRetentionMs) || this.replayRetentionMs <= 0)) {
|
|
1523
|
-
throw new TypeError("replay.retentionMs must be a positive finite number.");
|
|
1966
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
1967
|
+
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
1524
1968
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1969
|
+
}
|
|
1970
|
+
/** Deliver buffered history to a newly-registered handler. For an exact
|
|
1971
|
+
* topic this is that topic's ring; for a wildcard subscription every
|
|
1972
|
+
* buffered topic matching the pattern contributes (in buffer insertion
|
|
1973
|
+
* order). Replay deliveries are marked `replayed: true` and are not counted
|
|
1974
|
+
* into trace metrics.
|
|
1975
|
+
*
|
|
1976
|
+
* When a durable persistence backend is present, delivery waits for the
|
|
1977
|
+
* hydration load to settle first; `isHandlerActive` is then consulted so a
|
|
1978
|
+
* handler that unsubscribed during the async load does not receive history.
|
|
1979
|
+
*/
|
|
1980
|
+
deliverReplay(topic, replayOption, handler, isHandlerActive) {
|
|
1981
|
+
if (!this.buffers) return;
|
|
1982
|
+
const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
|
|
1983
|
+
if (this.persistence) {
|
|
1984
|
+
void this.hydration.then(() => {
|
|
1985
|
+
if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
|
|
1986
|
+
});
|
|
1987
|
+
return;
|
|
1528
1988
|
}
|
|
1529
|
-
this.
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1989
|
+
this.deliver(topic, limit, handler);
|
|
1990
|
+
}
|
|
1991
|
+
/** Clean up a topic that lost its last local handler: drop the ring buffer,
|
|
1992
|
+
* filter queued batch flushes (so an in-flight append cannot undo the
|
|
1993
|
+
* clearTopic), and prune durable history. */
|
|
1994
|
+
onTopicUnsubscribed(topic) {
|
|
1995
|
+
if (!this.buffers) return;
|
|
1996
|
+
this.buffers.delete(topic);
|
|
1997
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
1998
|
+
if (this.persistence?.clearTopic) {
|
|
1999
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
/** Clear all in-memory replay buffers and, when supported, durable history.
|
|
2003
|
+
* Reports persistence failures and rethrows, mirroring the public API
|
|
2004
|
+
* contract that callers can observe a failed clear. */
|
|
2005
|
+
async clearAll() {
|
|
2006
|
+
if (!this.buffers) return;
|
|
2007
|
+
this.buffers.clear();
|
|
2008
|
+
this.pendingReplayPersistence = [];
|
|
2009
|
+
if (this.persistence?.clear) {
|
|
2010
|
+
try {
|
|
2011
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
this.onPersistenceError(error);
|
|
2014
|
+
throw error;
|
|
2015
|
+
}
|
|
1533
2016
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
2017
|
+
}
|
|
2018
|
+
/** Clear replay history for one exact topic, including durable storage. */
|
|
2019
|
+
async clearTopic(topic) {
|
|
2020
|
+
if (!this.buffers) return;
|
|
2021
|
+
this.buffers.delete(topic);
|
|
2022
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
2023
|
+
if (this.persistence?.clearTopic) {
|
|
2024
|
+
try {
|
|
2025
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
this.onPersistenceError(error);
|
|
2028
|
+
throw error;
|
|
2029
|
+
}
|
|
1536
2030
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
if (!Number.isFinite(
|
|
1541
|
-
|
|
2031
|
+
}
|
|
2032
|
+
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
2033
|
+
async clearBefore(timestamp) {
|
|
2034
|
+
if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
|
|
2035
|
+
if (this.buffers) {
|
|
2036
|
+
for (const [topic, messages] of this.buffers) {
|
|
2037
|
+
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
2038
|
+
if (kept.length) this.buffers.set(topic, kept);
|
|
2039
|
+
else this.buffers.delete(topic);
|
|
2040
|
+
}
|
|
1542
2041
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
2042
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
|
|
2043
|
+
(message) => message.timestamp === void 0 || message.timestamp >= timestamp
|
|
2044
|
+
);
|
|
2045
|
+
if (this.persistence?.clearBefore) {
|
|
2046
|
+
try {
|
|
2047
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
this.onPersistenceError(error);
|
|
2050
|
+
throw error;
|
|
2051
|
+
}
|
|
1545
2052
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
this.
|
|
1551
|
-
this.
|
|
1552
|
-
|
|
1553
|
-
this.
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
this.
|
|
1558
|
-
this.
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
2053
|
+
}
|
|
2054
|
+
/** Start the periodic retention sweep. No-op when no durable retention
|
|
2055
|
+
* config makes it necessary. */
|
|
2056
|
+
start() {
|
|
2057
|
+
if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
|
|
2058
|
+
this.retentionTimer = setInterval(() => {
|
|
2059
|
+
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
2060
|
+
}, this.retentionSweepMs);
|
|
2061
|
+
}
|
|
2062
|
+
/** Stop the periodic retention sweep. */
|
|
2063
|
+
stop() {
|
|
2064
|
+
if (this.retentionTimer) clearInterval(this.retentionTimer);
|
|
2065
|
+
this.retentionTimer = null;
|
|
2066
|
+
}
|
|
2067
|
+
/** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
|
|
2068
|
+
* or stopped bus does not keep hammering the store) and stop the sweep. */
|
|
2069
|
+
suspend() {
|
|
2070
|
+
this.retryGeneration += 1;
|
|
2071
|
+
this.stop();
|
|
2072
|
+
}
|
|
2073
|
+
/** Drop all in-memory buffers (used on full teardown). */
|
|
2074
|
+
resetBuffers() {
|
|
2075
|
+
this.buffers?.clear();
|
|
2076
|
+
}
|
|
2077
|
+
/** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
|
|
2078
|
+
* payload footprint (same heuristic as adaptive load weighting), computed on
|
|
2079
|
+
* demand so the hot append path never pays for it. */
|
|
2080
|
+
getStats() {
|
|
2081
|
+
let messages = 0;
|
|
2082
|
+
let bytes = 0;
|
|
2083
|
+
if (this.buffers) {
|
|
2084
|
+
for (const buffer of this.buffers.values()) {
|
|
2085
|
+
messages += buffer.length;
|
|
2086
|
+
for (const message of buffer) bytes += approximatePayloadBytes(message.data);
|
|
2087
|
+
}
|
|
1567
2088
|
}
|
|
1568
|
-
this.
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
2089
|
+
return { enabled: this.enabled, topics: this.buffers?.size ?? 0, messages, bytes };
|
|
2090
|
+
}
|
|
2091
|
+
/** Deliver history from one topic's ring to a handler, isolating a throwing
|
|
2092
|
+
* handler so the remaining buffers are still delivered. */
|
|
2093
|
+
deliver(topic, limit, handler) {
|
|
2094
|
+
if (!this.buffers || limit <= 0) return;
|
|
2095
|
+
const deliverBuffer = (buffer2) => {
|
|
2096
|
+
for (const message of buffer2.slice(-limit)) {
|
|
2097
|
+
try {
|
|
2098
|
+
handler({ ...message, replayed: true });
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
this.onDispatchError(error);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
};
|
|
2104
|
+
if (isWildcardTopic(topic)) {
|
|
2105
|
+
for (const [bufferedTopic, buffer2] of this.buffers) {
|
|
2106
|
+
if (topicMatchesPattern(topic, bufferedTopic)) deliverBuffer(buffer2);
|
|
2107
|
+
}
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
const buffer = this.buffers.get(topic);
|
|
2111
|
+
if (buffer) deliverBuffer(buffer);
|
|
2112
|
+
}
|
|
2113
|
+
/** Coalesce queued persistence appends into a single microtask batch so a
|
|
2114
|
+
* burst of publications does not issue one IndexedDB transaction each.
|
|
2115
|
+
* Only reachable when the backend advertises `appendBatch` (the sole queuer,
|
|
2116
|
+
* `record()`, guards on it), so the batched path is unconditional here. */
|
|
2117
|
+
schedulePersistenceFlush() {
|
|
2118
|
+
if (this.persistenceFlushScheduled) return;
|
|
2119
|
+
this.persistenceFlushScheduled = true;
|
|
2120
|
+
queueMicrotask(() => {
|
|
2121
|
+
this.persistenceFlushScheduled = false;
|
|
2122
|
+
const batch = this.pendingReplayPersistence.splice(0);
|
|
2123
|
+
if (batch.length === 0 || !this.persistence) return;
|
|
2124
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
/** Load durable history into the in-memory rings once at startup, pruning
|
|
2128
|
+
* entries past the retention window first. Failures are reported but do not
|
|
2129
|
+
* block startup — the bus runs with whatever survived. */
|
|
2130
|
+
async hydrate() {
|
|
2131
|
+
if (!this.buffers || !this.persistence) {
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
try {
|
|
2135
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
2136
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
2137
|
+
}
|
|
2138
|
+
const loaded = await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load());
|
|
2139
|
+
for (const message of loaded) {
|
|
2140
|
+
let buffer = this.buffers.get(message.topic);
|
|
2141
|
+
if (!buffer) {
|
|
2142
|
+
buffer = [];
|
|
2143
|
+
this.buffers.set(message.topic, buffer);
|
|
2144
|
+
}
|
|
2145
|
+
buffer.push(message);
|
|
2146
|
+
}
|
|
2147
|
+
const hydrationNow = this.now();
|
|
2148
|
+
for (const [topic, buffer] of this.buffers) {
|
|
2149
|
+
const pruned = pruneReplayHistory(buffer, {
|
|
2150
|
+
maxPerTopic: this.maxPerTopic,
|
|
2151
|
+
pruneStrategy: this.pruneStrategy,
|
|
2152
|
+
retentionMs: this.retentionMs,
|
|
2153
|
+
now: hydrationNow
|
|
2154
|
+
});
|
|
2155
|
+
if (pruned !== buffer) this.buffers.set(topic, pruned);
|
|
2156
|
+
}
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
this.onPersistenceError(error);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
/** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
|
|
2162
|
+
* so a burst of publications issues at most one clearBefore transaction. */
|
|
2163
|
+
scheduleRetentionCleanup(cutoff) {
|
|
2164
|
+
if (!this.persistence?.clearBefore) return;
|
|
2165
|
+
if (this.retentionCutoff === null || cutoff > this.retentionCutoff) {
|
|
2166
|
+
this.retentionCutoff = cutoff;
|
|
2167
|
+
}
|
|
2168
|
+
if (this.retentionCleanup) return;
|
|
2169
|
+
this.retentionCleanup = (async () => {
|
|
2170
|
+
while (this.retentionCutoff !== null) {
|
|
2171
|
+
const nextCutoff = this.retentionCutoff;
|
|
2172
|
+
this.retentionCutoff = null;
|
|
2173
|
+
try {
|
|
2174
|
+
await this.persistence.clearBefore(nextCutoff);
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
this.onPersistenceError(error);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
})().finally(() => {
|
|
2180
|
+
this.retentionCleanup = null;
|
|
2181
|
+
if (this.retentionCutoff !== null) {
|
|
2182
|
+
this.scheduleRetentionCleanup(this.retentionCutoff);
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
/** Run a persistence operation with exponential backoff on transient failure.
|
|
2187
|
+
* Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
|
|
2188
|
+
* structurally failing operation throws after `persistenceRetryMaxAttempts`,
|
|
2189
|
+
* leaving the ring buffer intact so the bus keeps working. */
|
|
2190
|
+
async withPersistenceRetry(persistenceOperation, operation) {
|
|
2191
|
+
const generation = this.retryGeneration;
|
|
2192
|
+
let attempt = 0;
|
|
2193
|
+
let delay = this.persistenceRetryBackoffMs;
|
|
2194
|
+
while (true) {
|
|
2195
|
+
attempt += 1;
|
|
2196
|
+
try {
|
|
2197
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2198
|
+
return await operation();
|
|
2199
|
+
} catch (error) {
|
|
2200
|
+
if (error instanceof PersistenceRetryCancelledError || generation !== this.retryGeneration) {
|
|
2201
|
+
throw new PersistenceRetryCancelledError();
|
|
2202
|
+
}
|
|
2203
|
+
if (attempt >= this.persistenceRetryMaxAttempts) throw error;
|
|
2204
|
+
this.trace.event({
|
|
2205
|
+
type: TRACE_EVENT_TYPE.RELIABILITY,
|
|
2206
|
+
operation: RELIABILITY_OPERATION.PERSISTENCE_RETRY,
|
|
2207
|
+
persistenceOperation,
|
|
2208
|
+
attempt
|
|
2209
|
+
});
|
|
2210
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2211
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2212
|
+
delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS2);
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
};
|
|
2217
|
+
|
|
2218
|
+
// src/core/dedup-manager.ts
|
|
2219
|
+
var ADAPTIVE_WINDOW_MS = 5e3;
|
|
2220
|
+
var QUIET_RATE_PER_MS = 0.01;
|
|
2221
|
+
var DedupManager = class {
|
|
2222
|
+
enabled;
|
|
2223
|
+
maxEntries;
|
|
2224
|
+
ttlMs;
|
|
2225
|
+
adaptiveBounds;
|
|
2226
|
+
sweepMs;
|
|
2227
|
+
now;
|
|
2228
|
+
trace;
|
|
2229
|
+
seenMessageIds = /* @__PURE__ */ new Map();
|
|
2230
|
+
sweepTimer = null;
|
|
2231
|
+
windowStartedAt = 0;
|
|
2232
|
+
windowAccepted = 0;
|
|
2233
|
+
suppressed = 0;
|
|
2234
|
+
accepted = 0;
|
|
2235
|
+
constructor(options) {
|
|
2236
|
+
this.enabled = options.enabled;
|
|
2237
|
+
this.maxEntries = options.maxEntries;
|
|
2238
|
+
this.ttlMs = options.ttlMs;
|
|
2239
|
+
this.adaptiveBounds = options.adaptiveBounds;
|
|
2240
|
+
this.sweepMs = options.sweepMs;
|
|
2241
|
+
this.now = options.now;
|
|
2242
|
+
this.trace = options.trace;
|
|
2243
|
+
this.windowStartedAt = this.now();
|
|
2244
|
+
}
|
|
2245
|
+
/** True when a publication carrying `messageId` was already seen. Records the
|
|
2246
|
+
* ID and updates counters/trace on acceptance. Disabled or ID-less messages
|
|
2247
|
+
* always pass through. */
|
|
2248
|
+
isDuplicate(messageId, topic) {
|
|
2249
|
+
if (!this.enabled || !messageId) return false;
|
|
2250
|
+
const now = this.now();
|
|
2251
|
+
const ttlMs = this.currentTtl();
|
|
2252
|
+
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2253
|
+
if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
|
|
2254
|
+
}
|
|
2255
|
+
if (this.seenMessageIds.has(messageId)) {
|
|
2256
|
+
this.suppressed += 1;
|
|
2257
|
+
this.trace.event({
|
|
2258
|
+
type: TRACE_EVENT_TYPE.RELIABILITY,
|
|
2259
|
+
operation: RELIABILITY_OPERATION.DEDUP_SUPPRESSED,
|
|
2260
|
+
topic
|
|
2261
|
+
});
|
|
2262
|
+
this.trace.recordDedupSuppressed();
|
|
2263
|
+
return true;
|
|
2264
|
+
}
|
|
2265
|
+
this.seenMessageIds.set(messageId, now);
|
|
2266
|
+
this.accepted += 1;
|
|
2267
|
+
this.windowAccepted += 1;
|
|
2268
|
+
this.trace.recordDedupAccepted();
|
|
2269
|
+
while (this.seenMessageIds.size > this.maxEntries) {
|
|
2270
|
+
const oldest = this.seenMessageIds.keys().next().value;
|
|
2271
|
+
if (oldest === void 0) break;
|
|
2272
|
+
this.seenMessageIds.delete(oldest);
|
|
2273
|
+
}
|
|
2274
|
+
return false;
|
|
2275
|
+
}
|
|
2276
|
+
/** Start the periodic expiry sweep. No-op when disabled or no sweepMs was
|
|
2277
|
+
* configured (the hot-path opportunistic expiry still bounds the map). */
|
|
2278
|
+
start() {
|
|
2279
|
+
if (this.sweepTimer || !this.enabled || !this.sweepMs) return;
|
|
2280
|
+
this.sweepTimer = setInterval(() => this.pruneExpired(), this.sweepMs);
|
|
2281
|
+
}
|
|
2282
|
+
/** Stop the periodic expiry sweep. */
|
|
2283
|
+
stop() {
|
|
2284
|
+
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
2285
|
+
this.sweepTimer = null;
|
|
2286
|
+
}
|
|
2287
|
+
/** Return bounded deduplication counters for diagnostics and health checks. */
|
|
2288
|
+
getStats() {
|
|
2289
|
+
return {
|
|
2290
|
+
enabled: this.enabled,
|
|
2291
|
+
tracked: this.seenMessageIds.size,
|
|
2292
|
+
suppressed: this.suppressed,
|
|
2293
|
+
accepted: this.accepted,
|
|
2294
|
+
...this.adaptiveBounds ? { ttlMs: this.currentTtl() } : {}
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
/** Drop all remembered IDs and reset dedup counters. */
|
|
2298
|
+
reset() {
|
|
2299
|
+
this.seenMessageIds.clear();
|
|
2300
|
+
this.suppressed = 0;
|
|
2301
|
+
this.accepted = 0;
|
|
2302
|
+
this.windowStartedAt = this.now();
|
|
2303
|
+
this.windowAccepted = 0;
|
|
2304
|
+
}
|
|
2305
|
+
/** Remove IDs whose timestamp predates the effective TTL cutoff. */
|
|
2306
|
+
pruneExpired() {
|
|
2307
|
+
const cutoff = this.now() - this.currentTtl();
|
|
2308
|
+
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2309
|
+
if (timestamp < cutoff) this.seenMessageIds.delete(id);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
/** Effective TTL: the fixed `ttlMs` unless adaptive bounds are configured, in
|
|
2313
|
+
* which case a higher recent message rate shortens the window (dedup only
|
|
2314
|
+
* needs to live long enough to bridge duplicate bursts). The window resets
|
|
2315
|
+
* every ADAPTIVE_WINDOW_MS. */
|
|
2316
|
+
currentTtl() {
|
|
2317
|
+
if (!this.adaptiveBounds) return this.ttlMs;
|
|
2318
|
+
const now = this.now();
|
|
2319
|
+
const elapsed = now - this.windowStartedAt;
|
|
2320
|
+
if (elapsed >= ADAPTIVE_WINDOW_MS) {
|
|
2321
|
+
this.windowStartedAt = now;
|
|
2322
|
+
this.windowAccepted = 0;
|
|
2323
|
+
return this.adaptiveBounds.maxMs;
|
|
2324
|
+
}
|
|
2325
|
+
const rate = this.windowAccepted / Math.max(1, elapsed);
|
|
2326
|
+
const factor = Math.min(1, rate / QUIET_RATE_PER_MS);
|
|
2327
|
+
return this.adaptiveBounds.maxMs - (this.adaptiveBounds.maxMs - this.adaptiveBounds.minMs) * factor;
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
|
|
2331
|
+
// src/core/version.ts
|
|
2332
|
+
var SDK_VERSION = true ? "0.20.86" : "";
|
|
2333
|
+
|
|
2334
|
+
// src/core/data-bus.ts
|
|
2335
|
+
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
2336
|
+
var CrossTabDataBus = class {
|
|
2337
|
+
transport;
|
|
2338
|
+
cluster;
|
|
2339
|
+
// Map of topic → set of local subscribers.
|
|
2340
|
+
topicHandlers = /* @__PURE__ */ new Map();
|
|
2341
|
+
// Topics for which the transport has been asked to subscribe (used to avoid
|
|
2342
|
+
// duplicate subscribe calls during reconnection).
|
|
2343
|
+
transportSubscribedTopics = /* @__PURE__ */ new Set();
|
|
2344
|
+
statusHandlers = /* @__PURE__ */ new Set();
|
|
2345
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
2346
|
+
replayManager;
|
|
2347
|
+
initialConfig;
|
|
2348
|
+
hasInitialConfig;
|
|
2349
|
+
trace;
|
|
2350
|
+
dedupManager;
|
|
2351
|
+
now;
|
|
2352
|
+
activeConfig;
|
|
2353
|
+
status = WORKER_STATUS.DISCONNECTED;
|
|
2354
|
+
started = false;
|
|
2355
|
+
stopping = false;
|
|
2356
|
+
transportReady = false;
|
|
2357
|
+
// Last transport failure, retained so ready() can surface it to callers who
|
|
2358
|
+
// never awaited start() directly. Cleared on the next successful start.
|
|
2359
|
+
lastError = null;
|
|
2360
|
+
lastErrorAt = null;
|
|
2361
|
+
// Unified failure ledger for the health summary: the most recent failure of
|
|
2362
|
+
// any source (transport, persistence, dispatch) since the last explicit start.
|
|
2363
|
+
lastFailure = null;
|
|
2364
|
+
persistenceFailureCount = 0;
|
|
2365
|
+
persistenceLastFailureAt = null;
|
|
2366
|
+
persistenceLastErrorMessage = null;
|
|
2367
|
+
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
2368
|
+
// transition at a time. Resets to null once the operation settles.
|
|
2369
|
+
startPromise = null;
|
|
2370
|
+
// Gate for an explicit stop(). Concurrent stop() calls share it, and a
|
|
2371
|
+
// start() received while stopping chains a fresh start after it.
|
|
2372
|
+
stopPromise = null;
|
|
2373
|
+
// A start() requested while an explicit stop() is still settling. Kept
|
|
2374
|
+
// separate from startPromise because stop()'s finally block clears the
|
|
2375
|
+
// ordinary lifecycle gate before the queued start is allowed to run.
|
|
2376
|
+
queuedStart = null;
|
|
2377
|
+
// Lazy readiness view of queuedStart. start() keeps its documented
|
|
2378
|
+
// resolve-on-cancellation contract, while ready() must reject when the
|
|
2379
|
+
// queued intent was superseded by a later stop().
|
|
2380
|
+
queuedStartReady = null;
|
|
2381
|
+
queuedStartReadyToken = 0;
|
|
2382
|
+
// The queued continuation is chained to the stop promise and cannot be
|
|
2383
|
+
// un-scheduled once scheduled. A later stop() therefore invalidates the
|
|
2384
|
+
// current intent by recording its token; a subsequent start() issues a
|
|
2385
|
+
// higher token so the latest lifecycle request still wins.
|
|
2386
|
+
queuedStartToken = 0;
|
|
2387
|
+
canceledQueuedStartToken = 0;
|
|
2388
|
+
// Timestamp of the last automatic transport recovery attempt.
|
|
2389
|
+
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
2390
|
+
lastRecoveryAt = 0;
|
|
2391
|
+
// Monotonic attempt number within one runtime recovery sequence; reset once
|
|
2392
|
+
// a transport reopen succeeds so traces can correlate repeated failures.
|
|
2393
|
+
recoveryAttempt = 0;
|
|
2394
|
+
recoveryExhausted = false;
|
|
2395
|
+
/** Monotonic generation incremented on every successful transport open.
|
|
2396
|
+
* Stays in lockstep with `lastSuccessAt` so callers can detect that the
|
|
2397
|
+
* transport has been reopened even if the timestamp window is short. */
|
|
2398
|
+
recoveryGeneration = 0;
|
|
2399
|
+
/** Timestamp of the most recent successful transport open. Null until the
|
|
2400
|
+
* transport has reached the `ready` state at least once. */
|
|
2401
|
+
lastSuccessAt = null;
|
|
2402
|
+
// True while the tab is hidden so an in-flight transport start does not mark
|
|
2403
|
+
// the transport ready after suspendTransport() has stopped it.
|
|
2404
|
+
suspended = false;
|
|
2405
|
+
// Single gate for async transport.stop() cleanup, shared by failed opens and
|
|
2406
|
+
// page-hide suspension. Kept separate from startPromise so ready() still
|
|
2407
|
+
// surfaces a failure while later opens and automatic recovery wait for the
|
|
2408
|
+
// stop to settle.
|
|
2409
|
+
pendingStop = null;
|
|
2410
|
+
// Ownership token for asynchronous transport opens. Every lifecycle
|
|
2411
|
+
// transition invalidates callbacks and failure cleanup from older opens.
|
|
2412
|
+
lifecycleEpoch = 0;
|
|
2413
|
+
// Minimum interval in ms between automatic recovery attempts.
|
|
2414
|
+
recoveryCooldownMs;
|
|
2415
|
+
recoveryMaxAttempts;
|
|
2416
|
+
constructor(options) {
|
|
2417
|
+
const replay = options.replay;
|
|
2418
|
+
assertReplayOptions(replay);
|
|
2419
|
+
const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
|
|
2420
|
+
assertRecoveryOptions(recovery);
|
|
2421
|
+
this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
|
|
2422
|
+
this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
|
|
2423
|
+
this.now = dedup?.now ?? Date.now;
|
|
2424
|
+
this.transport = transport;
|
|
2425
|
+
this.initialConfig = initialConfig;
|
|
2426
|
+
this.hasInitialConfig = "initialConfig" in options;
|
|
2427
|
+
this.trace = new DataBusTraceReporter(trace);
|
|
2428
|
+
this.replayManager = new ReplayManager({
|
|
2429
|
+
enabled: replay !== void 0,
|
|
2430
|
+
maxPerTopic: replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC,
|
|
2431
|
+
persistence: replay?.persistence ?? null,
|
|
2432
|
+
retentionMs: replay?.retentionMs,
|
|
2433
|
+
pruneStrategy: replay?.pruneStrategy ?? PRUNE_STRATEGY.COUNT,
|
|
2434
|
+
retentionSweepMs: replay?.retentionSweepMs,
|
|
2435
|
+
persistenceRetryMaxAttempts: replay?.persistenceRetry?.maxAttempts ?? 1,
|
|
2436
|
+
persistenceRetryBackoffMs: replay?.persistenceRetry?.backoffMs ?? 50,
|
|
2437
|
+
now: this.now,
|
|
2438
|
+
trace: this.trace,
|
|
2439
|
+
onPersistenceError: (error) => this.reportPersistenceError(error),
|
|
2440
|
+
onDispatchError: (error) => this.reportError(error, FAILURE_SOURCE.DISPATCH)
|
|
2441
|
+
});
|
|
2442
|
+
assertDedupOptions(dedup);
|
|
2443
|
+
this.dedupManager = new DedupManager({
|
|
2444
|
+
enabled: dedup !== void 0,
|
|
2445
|
+
maxEntries: dedup?.maxEntries ?? 1e3,
|
|
2446
|
+
ttlMs: dedup?.ttlMs ?? 6e4,
|
|
2447
|
+
adaptiveBounds: dedup?.adaptiveTtl,
|
|
2448
|
+
sweepMs: dedup?.sweepMs,
|
|
2449
|
+
now: this.now,
|
|
2450
|
+
trace: this.trace
|
|
2451
|
+
});
|
|
2452
|
+
this.cluster = new WorkerClusterRuntime({
|
|
2453
|
+
...clusterOptions,
|
|
2454
|
+
handlers: {
|
|
2455
|
+
// The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
|
|
2456
|
+
// control message — meaning the owning Worker has delegated the action to us.
|
|
2457
|
+
onControl: (action, topic, data, messageId, timestamp) => {
|
|
2458
|
+
switch (action) {
|
|
2459
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
2460
|
+
if (this.subscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.SUBSCRIBE, topic);
|
|
2461
|
+
break;
|
|
2462
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
2463
|
+
if (this.unsubscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.UNSUBSCRIBE, topic);
|
|
2464
|
+
break;
|
|
2465
|
+
case CONTROL_ACTION.PUBLISH:
|
|
2466
|
+
this.runTransport(() => this.transport.publish(topic, data, publicationMetadata(messageId, timestamp)));
|
|
2467
|
+
break;
|
|
2468
|
+
default:
|
|
2469
|
+
break;
|
|
2470
|
+
}
|
|
2471
|
+
},
|
|
2472
|
+
// Batched variant of the PUBLISH action (CONTROL frames carrying
|
|
2473
|
+
// multiple items, and the local publishBatch fast path). Uses the
|
|
2474
|
+
// transport's one-frame publishBatch when available, preserving
|
|
2475
|
+
// per-item metadata; otherwise falls back to per-item publishes.
|
|
2476
|
+
onPublishBatch: (topic, items) => {
|
|
2477
|
+
if (typeof this.transport.publishBatch === "function") {
|
|
2478
|
+
this.runTransport(() => this.transport.publishBatch(topic, items));
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
for (const item of items) {
|
|
2482
|
+
this.runTransport(() => this.transport.publish(
|
|
2483
|
+
topic,
|
|
2484
|
+
item.data,
|
|
2485
|
+
publicationMetadata(item.messageId, item.timestamp)
|
|
2486
|
+
));
|
|
2487
|
+
}
|
|
2488
|
+
},
|
|
2489
|
+
// The cluster calls `onEvent` when a publication broadcast arrives from
|
|
2490
|
+
// another tab. Dispatch locally if we have subscribers. The payload is
|
|
2491
|
+
// typed `unknown` at the cluster boundary (the cluster is transport-
|
|
2492
|
+
// agnostic); here we narrow it to DataBusMessage — the sender is our
|
|
2493
|
+
// own broadcastEvent call, which always posts a DataBusMessage.
|
|
1600
2494
|
onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
|
|
1601
2495
|
if (eventType !== PUBLICATION_EVENT) return;
|
|
1602
2496
|
const incoming = payload;
|
|
@@ -1604,22 +2498,21 @@ var CrossTabDataBus = class {
|
|
|
1604
2498
|
if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
|
|
1605
2499
|
},
|
|
1606
2500
|
onSuspend: () => {
|
|
1607
|
-
if (!this.stopping) this.trace.event({ type:
|
|
2501
|
+
if (!this.stopping) this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.SUSPEND });
|
|
1608
2502
|
this.trace.pause();
|
|
1609
|
-
this.
|
|
2503
|
+
this.replayManager.suspend();
|
|
1610
2504
|
this.stopDedupSweep();
|
|
1611
|
-
this.stopReplayRetentionSweep();
|
|
1612
2505
|
this.suspendTransport();
|
|
1613
2506
|
},
|
|
1614
2507
|
onResume: () => {
|
|
1615
|
-
this.trace.event({ type:
|
|
2508
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
|
|
1616
2509
|
this.trace.start();
|
|
1617
2510
|
this.startDedupSweep();
|
|
1618
|
-
this.
|
|
2511
|
+
this.replayManager.start();
|
|
1619
2512
|
this.resumeTransport();
|
|
1620
2513
|
},
|
|
1621
2514
|
onDiagnostic: (event) => {
|
|
1622
|
-
this.trace.event({ type:
|
|
2515
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
|
|
1623
2516
|
}
|
|
1624
2517
|
}
|
|
1625
2518
|
});
|
|
@@ -1629,40 +2522,51 @@ var CrossTabDataBus = class {
|
|
|
1629
2522
|
* Start the DataBus with the given transport config.
|
|
1630
2523
|
*
|
|
1631
2524
|
* The first call starts the cluster and opens the transport. Concurrent calls
|
|
1632
|
-
* during an in-flight
|
|
1633
|
-
*
|
|
1634
|
-
*
|
|
2525
|
+
* during an in-flight open return the same promise. A call received while an
|
|
2526
|
+
* explicit stop() is settling queues one fresh start after cleanup; a later
|
|
2527
|
+
* stop() before that queued start runs cancels it, so the latest lifecycle
|
|
2528
|
+
* intent wins. Once an operation settles (success or failure) its promise
|
|
2529
|
+
* gate is cleared so a subsequent start() or resumeTransport() can open a
|
|
2530
|
+
* fresh lifecycle.
|
|
1635
2531
|
*/
|
|
1636
2532
|
start(config) {
|
|
1637
|
-
if (this.
|
|
1638
|
-
if (this.
|
|
2533
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2534
|
+
if (this.stopping) return this.queueStartAfterStop(config);
|
|
2535
|
+
if (this.startPromise && this.startPromise !== this.pendingStop) return this.startPromise;
|
|
2536
|
+
if (this.started) {
|
|
2537
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2538
|
+
if (!transportDown) return Promise.resolve();
|
|
2539
|
+
this.activeConfig = config;
|
|
2540
|
+
this.resetFailureState();
|
|
2541
|
+
return this.reopenTransport();
|
|
2542
|
+
}
|
|
1639
2543
|
this.started = true;
|
|
1640
2544
|
this.stopping = false;
|
|
1641
2545
|
this.suspended = false;
|
|
1642
2546
|
this.activeConfig = config;
|
|
1643
|
-
this.
|
|
1644
|
-
this.trace.event({ type:
|
|
2547
|
+
this.resetFailureState();
|
|
2548
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
1645
2549
|
this.trace.start();
|
|
1646
2550
|
this.startDedupSweep();
|
|
1647
|
-
this.
|
|
1648
|
-
this.updateStatus(
|
|
2551
|
+
this.replayManager.start();
|
|
2552
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
1649
2553
|
this.cluster.start();
|
|
1650
|
-
const
|
|
2554
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2555
|
+
const opening = this.openTransport(
|
|
2556
|
+
config,
|
|
2557
|
+
this.pendingStop ?? Promise.resolve(),
|
|
2558
|
+
true,
|
|
2559
|
+
lifecycleEpoch
|
|
2560
|
+
);
|
|
1651
2561
|
this.startPromise = opening;
|
|
1652
2562
|
for (const topic of this.topicHandlers.keys()) {
|
|
1653
2563
|
this.cluster.subscribe(topic);
|
|
1654
2564
|
}
|
|
1655
|
-
const snapshot = this.cluster.getSnapshot();
|
|
1656
|
-
this.trace.event({
|
|
1657
|
-
type: "coordination",
|
|
1658
|
-
coordinated: snapshot.coordinated,
|
|
1659
|
-
activeWorkers: snapshot.workers.filter((worker) => worker.role === "active").length,
|
|
1660
|
-
workers: snapshot.workers.map(formatWorkerTrace),
|
|
1661
|
-
routes: snapshot.routes.map(formatRouteTrace)
|
|
1662
|
-
});
|
|
1663
2565
|
void opening.then(
|
|
1664
2566
|
() => {
|
|
1665
|
-
if (this.startPromise
|
|
2567
|
+
if (this.startPromise !== opening) return;
|
|
2568
|
+
this.emitCoordinationTrace();
|
|
2569
|
+
this.startPromise = null;
|
|
1666
2570
|
},
|
|
1667
2571
|
() => {
|
|
1668
2572
|
if (this.startPromise === opening) this.startPromise = null;
|
|
@@ -1670,25 +2574,81 @@ var CrossTabDataBus = class {
|
|
|
1670
2574
|
);
|
|
1671
2575
|
return opening;
|
|
1672
2576
|
}
|
|
2577
|
+
/** Return a cancellation-aware readiness view of the current queued start. */
|
|
2578
|
+
getQueuedStartReady() {
|
|
2579
|
+
const queued = this.queuedStart;
|
|
2580
|
+
if (!queued) {
|
|
2581
|
+
return Promise.reject(new Error("No queued start is in flight."));
|
|
2582
|
+
}
|
|
2583
|
+
const token = this.queuedStartToken;
|
|
2584
|
+
if (this.queuedStartReady && this.queuedStartReadyToken === token) {
|
|
2585
|
+
return this.queuedStartReady;
|
|
2586
|
+
}
|
|
2587
|
+
this.queuedStartReadyToken = token;
|
|
2588
|
+
this.queuedStartReady = queued.then(() => {
|
|
2589
|
+
if (token <= this.canceledQueuedStartToken) {
|
|
2590
|
+
throw new Error(
|
|
2591
|
+
"CrossTabDataBus start was canceled by a later stop(); ready() cannot report readiness. Call start() again after stop() resolves."
|
|
2592
|
+
);
|
|
2593
|
+
}
|
|
2594
|
+
if (!this.started || !this.transportReady) {
|
|
2595
|
+
throw new Error("CrossTabDataBus restart completed without a ready transport.");
|
|
2596
|
+
}
|
|
2597
|
+
});
|
|
2598
|
+
return this.queuedStartReady;
|
|
2599
|
+
}
|
|
2600
|
+
/** Queue exactly one fresh start after an in-flight explicit stop settles. */
|
|
2601
|
+
queueStartAfterStop(config) {
|
|
2602
|
+
if (this.queuedStart) return this.queuedStart;
|
|
2603
|
+
const stop = this.stopPromise ?? Promise.resolve();
|
|
2604
|
+
const token = ++this.queuedStartToken;
|
|
2605
|
+
const queued = stop.catch(() => void 0).then(() => {
|
|
2606
|
+
if (this.queuedStart === queued) this.queuedStart = null;
|
|
2607
|
+
if (token <= this.canceledQueuedStartToken) return;
|
|
2608
|
+
return this.start(config);
|
|
2609
|
+
});
|
|
2610
|
+
this.queuedStart = queued;
|
|
2611
|
+
return queued;
|
|
2612
|
+
}
|
|
2613
|
+
/** Reset failure and recovery diagnostics for a new explicit start session. */
|
|
2614
|
+
resetFailureState() {
|
|
2615
|
+
this.lastError = null;
|
|
2616
|
+
this.lastErrorAt = null;
|
|
2617
|
+
this.lastFailure = null;
|
|
2618
|
+
this.persistenceFailureCount = 0;
|
|
2619
|
+
this.persistenceLastFailureAt = null;
|
|
2620
|
+
this.persistenceLastErrorMessage = null;
|
|
2621
|
+
this.recoveryAttempt = 0;
|
|
2622
|
+
this.recoveryExhausted = false;
|
|
2623
|
+
this.lastRecoveryAt = 0;
|
|
2624
|
+
}
|
|
1673
2625
|
/**
|
|
1674
2626
|
* Open the transport, chained after `before` to ensure lifecycle ordering.
|
|
1675
2627
|
* When `stopClusterOnFailure` is true (initial start), a transport failure
|
|
1676
2628
|
* tears down the cluster as well.
|
|
1677
2629
|
*/
|
|
1678
|
-
openTransport(config, before, stopClusterOnFailure) {
|
|
2630
|
+
openTransport(config, before, stopClusterOnFailure, lifecycleEpoch) {
|
|
1679
2631
|
this.transportReady = false;
|
|
1680
2632
|
const chainedPendingStop = this.pendingStop;
|
|
2633
|
+
const isCurrentLifecycle = () => lifecycleEpoch === this.lifecycleEpoch;
|
|
1681
2634
|
return before.catch(() => void 0).then(() => {
|
|
1682
|
-
if (this.stopping || this.suspended) return;
|
|
2635
|
+
if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
|
|
1683
2636
|
if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
|
|
1684
2637
|
return Promise.resolve(
|
|
1685
2638
|
this.transport.start(config, {
|
|
1686
|
-
onMessage: (message) =>
|
|
1687
|
-
|
|
1688
|
-
|
|
2639
|
+
onMessage: (message) => {
|
|
2640
|
+
if (isCurrentLifecycle()) this.handleTransportMessage(message);
|
|
2641
|
+
},
|
|
2642
|
+
onStatus: (status) => {
|
|
2643
|
+
if (isCurrentLifecycle()) this.updateStatus(status);
|
|
2644
|
+
},
|
|
2645
|
+
onError: (error) => {
|
|
2646
|
+
if (isCurrentLifecycle()) this.reportError(error);
|
|
2647
|
+
}
|
|
1689
2648
|
})
|
|
1690
2649
|
).then(() => {
|
|
1691
|
-
if (
|
|
2650
|
+
if (!isCurrentLifecycle()) return;
|
|
2651
|
+
if (this.status === WORKER_STATUS.ERROR) {
|
|
1692
2652
|
throw new Error("Transport failed during startup.");
|
|
1693
2653
|
}
|
|
1694
2654
|
if (!this.suspended && !this.stopping) {
|
|
@@ -1698,11 +2658,12 @@ var CrossTabDataBus = class {
|
|
|
1698
2658
|
}
|
|
1699
2659
|
});
|
|
1700
2660
|
}).catch((error) => {
|
|
2661
|
+
if (!isCurrentLifecycle()) throw error;
|
|
1701
2662
|
if (stopClusterOnFailure) this.started = false;
|
|
1702
2663
|
if (!this.pendingStop) {
|
|
1703
2664
|
this.pendingStop = this.createStopPromise();
|
|
1704
2665
|
}
|
|
1705
|
-
this.updateStatus(
|
|
2666
|
+
this.updateStatus(WORKER_STATUS.ERROR);
|
|
1706
2667
|
this.reportError(error);
|
|
1707
2668
|
this.lastError = error;
|
|
1708
2669
|
this.lastErrorAt = this.now();
|
|
@@ -1712,16 +2673,26 @@ var CrossTabDataBus = class {
|
|
|
1712
2673
|
this.cluster.stop();
|
|
1713
2674
|
this.stopping = false;
|
|
1714
2675
|
}
|
|
1715
|
-
this.startPromise = null;
|
|
1716
2676
|
throw error;
|
|
1717
2677
|
});
|
|
1718
2678
|
}
|
|
1719
2679
|
/**
|
|
1720
2680
|
* Await the DataBus to be fully started (lazy init when using initialConfig).
|
|
1721
2681
|
* Returns a rejected promise when the transport has failed and no start is in
|
|
1722
|
-
* flight — the caller can retry by calling start() or ready() again.
|
|
2682
|
+
* flight — the caller can retry by calling start() or ready() again. While an
|
|
2683
|
+
* explicit stop() is settling, this rejects unless a restart is queued behind
|
|
2684
|
+
* it; false readiness during teardown is never reported.
|
|
1723
2685
|
*/
|
|
1724
2686
|
ready() {
|
|
2687
|
+
if (this.queuedStart) return this.getQueuedStartReady();
|
|
2688
|
+
if (this.stopping) {
|
|
2689
|
+
return Promise.reject(new Error(
|
|
2690
|
+
"CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
|
|
2691
|
+
));
|
|
2692
|
+
}
|
|
2693
|
+
if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
|
|
2694
|
+
return Promise.reject(this.lastError);
|
|
2695
|
+
}
|
|
1725
2696
|
try {
|
|
1726
2697
|
this.ensureStarted();
|
|
1727
2698
|
} catch (error) {
|
|
@@ -1737,9 +2708,18 @@ var CrossTabDataBus = class {
|
|
|
1737
2708
|
/**
|
|
1738
2709
|
* Register a handler for `topic`. The handler fires on every publication
|
|
1739
2710
|
* delivered to this tab, regardless of which tab published it. Returns an
|
|
1740
|
-
* unsubscribe function for convenience.
|
|
2711
|
+
* unsubscribe function for convenience. During an explicit stop() the
|
|
2712
|
+
* registration is rejected through onError and a no-op cleanup is returned,
|
|
2713
|
+
* so a late subscriber cannot leak into a future restart.
|
|
1741
2714
|
*/
|
|
1742
2715
|
subscribe(topic, handler, options) {
|
|
2716
|
+
if (this.stopping) {
|
|
2717
|
+
this.reportError(new Error(
|
|
2718
|
+
"CrossTabDataBus is stopping; subscribe() was not registered. Wait for stop() to resolve, then call start() before subscribing again."
|
|
2719
|
+
));
|
|
2720
|
+
return () => {
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
1743
2723
|
this.ensureStarted();
|
|
1744
2724
|
const handlers = this.topicHandlers.get(topic) ?? /* @__PURE__ */ new Set();
|
|
1745
2725
|
const wasUnused = handlers.size === 0;
|
|
@@ -1747,17 +2727,12 @@ var CrossTabDataBus = class {
|
|
|
1747
2727
|
this.topicHandlers.set(topic, handlers);
|
|
1748
2728
|
if (wasUnused) this.cluster.subscribe(topic);
|
|
1749
2729
|
if (options?.replay) {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2730
|
+
this.replayManager.deliverReplay(
|
|
2731
|
+
topic,
|
|
2732
|
+
options.replay,
|
|
2733
|
+
handler,
|
|
2734
|
+
() => Boolean(this.topicHandlers.get(topic)?.has(handler))
|
|
1753
2735
|
);
|
|
1754
|
-
if (this.replayPersistence) {
|
|
1755
|
-
void this.replayHydration.then(() => {
|
|
1756
|
-
if (this.topicHandlers.get(topic)?.has(handler)) this.deliverReplay(topic, limit, handler);
|
|
1757
|
-
});
|
|
1758
|
-
} else {
|
|
1759
|
-
this.deliverReplay(topic, limit, handler);
|
|
1760
|
-
}
|
|
1761
2736
|
}
|
|
1762
2737
|
return () => this.unsubscribe(topic, handler);
|
|
1763
2738
|
}
|
|
@@ -1772,74 +2747,33 @@ var CrossTabDataBus = class {
|
|
|
1772
2747
|
else handlers.clear();
|
|
1773
2748
|
if (handlers.size > 0) return;
|
|
1774
2749
|
this.topicHandlers.delete(topic);
|
|
1775
|
-
this.
|
|
1776
|
-
if (this.replayPersistence?.clearTopic) {
|
|
1777
|
-
void this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic)).catch((error) => this.reportPersistenceError(error));
|
|
1778
|
-
}
|
|
2750
|
+
this.replayManager.onTopicUnsubscribed(topic);
|
|
1779
2751
|
this.cluster.unsubscribe(topic);
|
|
1780
2752
|
}
|
|
1781
2753
|
/** Clear all in-memory replay buffers and, when supported, durable history. */
|
|
1782
2754
|
async clearReplay() {
|
|
1783
|
-
this.
|
|
1784
|
-
if (this.replayPersistence?.clear) {
|
|
1785
|
-
try {
|
|
1786
|
-
await this.withPersistenceRetry("clear", () => this.replayPersistence.clear());
|
|
1787
|
-
} catch (error) {
|
|
1788
|
-
this.reportPersistenceError(error);
|
|
1789
|
-
throw error;
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
2755
|
+
await this.replayManager.clearAll();
|
|
1792
2756
|
}
|
|
1793
2757
|
/** Clear replay history for one exact topic, including durable storage. */
|
|
1794
2758
|
async clearReplayTopic(topic) {
|
|
1795
|
-
this.
|
|
1796
|
-
if (this.replayPersistence?.clearTopic) {
|
|
1797
|
-
try {
|
|
1798
|
-
await this.withPersistenceRetry("clearTopic", () => this.replayPersistence.clearTopic(topic));
|
|
1799
|
-
} catch (error) {
|
|
1800
|
-
this.reportPersistenceError(error);
|
|
1801
|
-
throw error;
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
2759
|
+
await this.replayManager.clearTopic(topic);
|
|
1804
2760
|
}
|
|
1805
2761
|
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
1806
2762
|
async clearReplayBefore(timestamp) {
|
|
1807
|
-
|
|
1808
|
-
if (this.replayBuffers) {
|
|
1809
|
-
for (const [topic, messages] of this.replayBuffers) {
|
|
1810
|
-
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
1811
|
-
if (kept.length) this.replayBuffers.set(topic, kept);
|
|
1812
|
-
else this.replayBuffers.delete(topic);
|
|
1813
|
-
}
|
|
1814
|
-
}
|
|
1815
|
-
if (this.replayPersistence?.clearBefore) {
|
|
1816
|
-
try {
|
|
1817
|
-
await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(timestamp));
|
|
1818
|
-
} catch (error) {
|
|
1819
|
-
this.reportPersistenceError(error);
|
|
1820
|
-
throw error;
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
2763
|
+
await this.replayManager.clearBefore(timestamp);
|
|
1823
2764
|
}
|
|
1824
2765
|
/** Return bounded deduplication counters for diagnostics and health checks. */
|
|
1825
2766
|
getDedupStats() {
|
|
1826
|
-
return
|
|
1827
|
-
enabled: this.dedupEnabled,
|
|
1828
|
-
tracked: this.seenMessageIds.size,
|
|
1829
|
-
suppressed: this.dedupSuppressed,
|
|
1830
|
-
accepted: this.dedupAccepted,
|
|
1831
|
-
...this.dedupAdaptiveBounds ? { ttlMs: this.currentDedupTtl() } : {}
|
|
1832
|
-
};
|
|
2767
|
+
return this.dedupManager.getStats();
|
|
1833
2768
|
}
|
|
1834
2769
|
/** Drop all remembered IDs and reset dedup counters. */
|
|
1835
2770
|
resetDedup() {
|
|
1836
|
-
this.
|
|
1837
|
-
this.dedupSuppressed = 0;
|
|
1838
|
-
this.dedupAccepted = 0;
|
|
2771
|
+
this.dedupManager.reset();
|
|
1839
2772
|
}
|
|
1840
2773
|
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
1841
2774
|
publish(topic, data, options) {
|
|
1842
2775
|
this.ensureStarted();
|
|
2776
|
+
if (this.rejectPublishDuringStop("publish")) return;
|
|
1843
2777
|
if (!this.cluster.publish(topic, data, options)) {
|
|
1844
2778
|
this.reportError(
|
|
1845
2779
|
new Error("Failed to send the publish control message to the owning worker.")
|
|
@@ -1856,6 +2790,7 @@ var CrossTabDataBus = class {
|
|
|
1856
2790
|
publishBatch(topic, items) {
|
|
1857
2791
|
this.ensureStarted();
|
|
1858
2792
|
if (items.length === 0) return;
|
|
2793
|
+
if (this.rejectPublishDuringStop("publishBatch")) return;
|
|
1859
2794
|
if (items.length === 1) {
|
|
1860
2795
|
const first = items[0];
|
|
1861
2796
|
this.publish(topic, first.data, first.options);
|
|
@@ -1863,8 +2798,7 @@ var CrossTabDataBus = class {
|
|
|
1863
2798
|
}
|
|
1864
2799
|
const mapped = items.map((item) => ({
|
|
1865
2800
|
data: item.data,
|
|
1866
|
-
...item.options?.messageId
|
|
1867
|
-
...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
|
|
2801
|
+
...publicationMetadata(item.options?.messageId, item.options?.timestamp)
|
|
1868
2802
|
}));
|
|
1869
2803
|
if (!this.cluster.publishBatch(topic, mapped)) {
|
|
1870
2804
|
this.reportError(
|
|
@@ -1909,6 +2843,41 @@ var CrossTabDataBus = class {
|
|
|
1909
2843
|
lastSuccessAt: this.lastSuccessAt
|
|
1910
2844
|
};
|
|
1911
2845
|
}
|
|
2846
|
+
/** Bounded failure counters for the replay persistence backend. */
|
|
2847
|
+
getPersistenceStats() {
|
|
2848
|
+
return {
|
|
2849
|
+
failures: this.persistenceFailureCount,
|
|
2850
|
+
lastFailureAt: this.persistenceLastFailureAt,
|
|
2851
|
+
lastErrorMessage: this.persistenceLastErrorMessage
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
/** Compact health verdict for dashboards, readiness probes, and support
|
|
2855
|
+
* bundles. Answers "is the bus usable right now" first, then attaches the
|
|
2856
|
+
* unified failure ledger and recovery context that explains the verdict. */
|
|
2857
|
+
getHealthSummary() {
|
|
2858
|
+
const transport = this.transport;
|
|
2859
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2860
|
+
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;
|
|
2861
|
+
return {
|
|
2862
|
+
healthy: state === HEALTH_STATE.HEALTHY,
|
|
2863
|
+
state,
|
|
2864
|
+
status: this.status,
|
|
2865
|
+
sdkVersion: SDK_VERSION,
|
|
2866
|
+
started: this.started,
|
|
2867
|
+
suspended: this.suspended,
|
|
2868
|
+
transport: {
|
|
2869
|
+
name: transport.diagnosticsName ?? transport.constructor.name,
|
|
2870
|
+
backend: transport.diagnosticsBackend ?? null,
|
|
2871
|
+
ready: this.transportReady,
|
|
2872
|
+
status: this.status
|
|
2873
|
+
},
|
|
2874
|
+
recovery: this.getRecoveryStats(),
|
|
2875
|
+
lastFailure: this.lastFailure,
|
|
2876
|
+
persistence: this.getPersistenceStats(),
|
|
2877
|
+
metrics: this.trace.getMetrics(),
|
|
2878
|
+
trace: this.trace.getSinkState()
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
1912
2881
|
/** Snapshot of the cluster state (workers, routes, assignments).
|
|
1913
2882
|
* For diagnostics only — the returned object is a shallow copy but
|
|
1914
2883
|
* nested arrays are snapshots at call time. */
|
|
@@ -1917,8 +2886,7 @@ var CrossTabDataBus = class {
|
|
|
1917
2886
|
}
|
|
1918
2887
|
/** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
|
|
1919
2888
|
getDiagnostics() {
|
|
1920
|
-
|
|
1921
|
-
if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
|
|
2889
|
+
const replay = this.replayManager.getStats();
|
|
1922
2890
|
const cluster = this.cluster.getSnapshot();
|
|
1923
2891
|
const unknownMessages = this.cluster.getUnknownMessageStats();
|
|
1924
2892
|
const transport = this.transport;
|
|
@@ -1929,26 +2897,62 @@ var CrossTabDataBus = class {
|
|
|
1929
2897
|
transportReady: this.transportReady,
|
|
1930
2898
|
recovery: this.getRecoveryStats(),
|
|
1931
2899
|
dedup: this.getDedupStats(),
|
|
1932
|
-
replay: { enabled:
|
|
2900
|
+
replay: { enabled: replay.enabled, topics: replay.topics, messages: replay.messages, bytes: replay.bytes },
|
|
2901
|
+
persistence: this.getPersistenceStats(),
|
|
1933
2902
|
protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
|
|
1934
|
-
transport: {
|
|
1935
|
-
|
|
2903
|
+
transport: {
|
|
2904
|
+
name: transport.diagnosticsName ?? transport.constructor.name,
|
|
2905
|
+
backend: transport.diagnosticsBackend ?? null,
|
|
2906
|
+
status: this.status,
|
|
2907
|
+
suspended: this.suspended
|
|
2908
|
+
},
|
|
2909
|
+
cluster,
|
|
2910
|
+
metrics: this.trace.getMetrics(),
|
|
2911
|
+
trace: this.trace.getSinkState()
|
|
1936
2912
|
};
|
|
1937
2913
|
}
|
|
2914
|
+
/** Synchronous snapshot of the current trace metrics window (throughput,
|
|
2915
|
+
* dispatch latency, dedup outcomes), without flushing or resetting it.
|
|
2916
|
+
* Returns null when trace metrics are inactive (disabled or events-only). */
|
|
2917
|
+
getMetrics() {
|
|
2918
|
+
return this.trace.getMetrics();
|
|
2919
|
+
}
|
|
1938
2920
|
/**
|
|
1939
2921
|
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
1940
|
-
* and close the transport.
|
|
2922
|
+
* and close the transport. Concurrent and repeated calls share the in-flight
|
|
2923
|
+
* stop promise. A start() received while stopping runs after this completes,
|
|
2924
|
+
* unless another stop() arrives first and cancels that queued restart.
|
|
1941
2925
|
*/
|
|
1942
|
-
|
|
1943
|
-
if (
|
|
2926
|
+
stop() {
|
|
2927
|
+
if (this.queuedStart) {
|
|
2928
|
+
this.canceledQueuedStartToken = this.queuedStartToken;
|
|
2929
|
+
this.queuedStart = null;
|
|
2930
|
+
}
|
|
2931
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2932
|
+
if (!this.started && !this.startPromise && !this.pendingStop && !this.transportReady) {
|
|
2933
|
+
return Promise.resolve();
|
|
2934
|
+
}
|
|
2935
|
+
const stopPromise = this.performStop();
|
|
2936
|
+
this.stopPromise = stopPromise;
|
|
2937
|
+
void stopPromise.then(
|
|
2938
|
+
() => {
|
|
2939
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
2940
|
+
},
|
|
2941
|
+
() => {
|
|
2942
|
+
if (this.stopPromise === stopPromise) this.stopPromise = null;
|
|
2943
|
+
}
|
|
2944
|
+
);
|
|
2945
|
+
return stopPromise;
|
|
2946
|
+
}
|
|
2947
|
+
async performStop() {
|
|
2948
|
+
this.lifecycleEpoch += 1;
|
|
1944
2949
|
this.stopping = true;
|
|
1945
|
-
this.
|
|
1946
|
-
this.trace.event({ type:
|
|
2950
|
+
this.replayManager.suspend();
|
|
2951
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
1947
2952
|
this.trace.stop();
|
|
1948
2953
|
this.stopDedupSweep();
|
|
1949
|
-
this.stopReplayRetentionSweep();
|
|
1950
2954
|
this.topicHandlers.clear();
|
|
1951
|
-
this.
|
|
2955
|
+
this.replayManager.resetBuffers();
|
|
1952
2956
|
this.cluster.stop();
|
|
1953
2957
|
try {
|
|
1954
2958
|
await this.startPromise?.catch(() => void 0);
|
|
@@ -1969,7 +2973,7 @@ var CrossTabDataBus = class {
|
|
|
1969
2973
|
this.activeConfig = void 0;
|
|
1970
2974
|
this.recoveryAttempt = 0;
|
|
1971
2975
|
this.recoveryExhausted = false;
|
|
1972
|
-
this.updateStatus(
|
|
2976
|
+
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
1973
2977
|
}
|
|
1974
2978
|
}
|
|
1975
2979
|
/**
|
|
@@ -1978,7 +2982,7 @@ var CrossTabDataBus = class {
|
|
|
1978
2982
|
* and dispatches locally.
|
|
1979
2983
|
*/
|
|
1980
2984
|
handleTransportMessage(message) {
|
|
1981
|
-
if (this.isDuplicate(message)) return;
|
|
2985
|
+
if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
|
|
1982
2986
|
this.trace.recordReceived(message.topic);
|
|
1983
2987
|
if (!this.cluster.isAssigned(message.topic)) {
|
|
1984
2988
|
this.trace.recordDiscarded(message.topic);
|
|
@@ -1992,55 +2996,13 @@ var CrossTabDataBus = class {
|
|
|
1992
2996
|
}
|
|
1993
2997
|
this.trace.recordDiscarded(message.topic);
|
|
1994
2998
|
}
|
|
1995
|
-
|
|
1996
|
-
if (!this.dedupEnabled || !message.messageId) return false;
|
|
1997
|
-
const now = this.now();
|
|
1998
|
-
for (const [id, timestamp] of this.seenMessageIds) {
|
|
1999
|
-
if (now - timestamp > this.dedupTtlMs) this.seenMessageIds.delete(id);
|
|
2000
|
-
}
|
|
2001
|
-
if (this.seenMessageIds.has(message.messageId)) {
|
|
2002
|
-
this.trace.event({ type: "reliability", operation: "dedup_suppressed", topic: message.topic });
|
|
2003
|
-
this.dedupSuppressed += 1;
|
|
2004
|
-
this.trace.recordDedupSuppressed();
|
|
2005
|
-
return true;
|
|
2006
|
-
}
|
|
2007
|
-
this.seenMessageIds.set(message.messageId, now);
|
|
2008
|
-
this.dedupAccepted += 1;
|
|
2009
|
-
this.dedupWindowAccepted += 1;
|
|
2010
|
-
this.trace.recordDedupAccepted();
|
|
2011
|
-
while (this.seenMessageIds.size > this.dedupMaxEntries) {
|
|
2012
|
-
const oldest = this.seenMessageIds.keys().next().value;
|
|
2013
|
-
if (oldest === void 0) break;
|
|
2014
|
-
this.seenMessageIds.delete(oldest);
|
|
2015
|
-
}
|
|
2016
|
-
return false;
|
|
2017
|
-
}
|
|
2999
|
+
/** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
|
|
2018
3000
|
startDedupSweep() {
|
|
2019
|
-
|
|
2020
|
-
this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
|
|
3001
|
+
this.dedupManager.start();
|
|
2021
3002
|
}
|
|
3003
|
+
/** Stop enqueuing the dedup expiry sweep. */
|
|
2022
3004
|
stopDedupSweep() {
|
|
2023
|
-
|
|
2024
|
-
this.dedupSweepTimer = null;
|
|
2025
|
-
}
|
|
2026
|
-
pruneExpiredDedup() {
|
|
2027
|
-
const cutoff = this.now() - this.currentDedupTtl();
|
|
2028
|
-
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2029
|
-
if (timestamp < cutoff) this.seenMessageIds.delete(id);
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
2032
|
-
currentDedupTtl() {
|
|
2033
|
-
if (!this.dedupAdaptiveBounds) return this.dedupTtlMs;
|
|
2034
|
-
const now = this.now();
|
|
2035
|
-
const elapsed = now - this.dedupWindowStartedAt;
|
|
2036
|
-
if (elapsed >= this.dedupAdaptiveWindowMs) {
|
|
2037
|
-
this.dedupWindowStartedAt = now;
|
|
2038
|
-
this.dedupWindowAccepted = 0;
|
|
2039
|
-
return this.dedupAdaptiveBounds.maxMs;
|
|
2040
|
-
}
|
|
2041
|
-
const rate = this.dedupWindowAccepted / Math.max(1, elapsed);
|
|
2042
|
-
const factor = Math.min(1, rate / 0.01);
|
|
2043
|
-
return this.dedupAdaptiveBounds.maxMs - (this.dedupAdaptiveBounds.maxMs - this.dedupAdaptiveBounds.minMs) * factor;
|
|
3005
|
+
this.dedupManager.stop();
|
|
2044
3006
|
}
|
|
2045
3007
|
/** Deliver a message to every local handler registered for its topic,
|
|
2046
3008
|
* plus every handler registered with a wildcard subscription that matches
|
|
@@ -2053,153 +3015,7 @@ var CrossTabDataBus = class {
|
|
|
2053
3015
|
this.invokeHandlers(handlers, (handler) => handler(message));
|
|
2054
3016
|
}
|
|
2055
3017
|
}
|
|
2056
|
-
this.
|
|
2057
|
-
}
|
|
2058
|
-
/** Append a dispatched publication to the topic's replay ring buffer.
|
|
2059
|
-
* No-op when replay is disabled. */
|
|
2060
|
-
recordReplay(message) {
|
|
2061
|
-
if (!this.replayBuffers) return;
|
|
2062
|
-
let buffer = this.replayBuffers.get(message.topic);
|
|
2063
|
-
if (!buffer) {
|
|
2064
|
-
buffer = [];
|
|
2065
|
-
this.replayBuffers.set(message.topic, buffer);
|
|
2066
|
-
}
|
|
2067
|
-
const storedMessage = message;
|
|
2068
|
-
buffer.push(storedMessage);
|
|
2069
|
-
if (this.replayPruneStrategy !== "age") {
|
|
2070
|
-
while (buffer.length > this.replayMaxPerTopic) buffer.shift();
|
|
2071
|
-
}
|
|
2072
|
-
if (this.replayPruneStrategy !== "count" && this.replayRetentionMs !== void 0) {
|
|
2073
|
-
const cutoff = this.now() - this.replayRetentionMs;
|
|
2074
|
-
while (buffer.length > 0) {
|
|
2075
|
-
const first = buffer[0];
|
|
2076
|
-
if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
|
|
2077
|
-
buffer.shift();
|
|
2078
|
-
}
|
|
2079
|
-
}
|
|
2080
|
-
if (this.replayPersistence) {
|
|
2081
|
-
if (this.replayPersistence.appendBatch) {
|
|
2082
|
-
this.pendingReplayPersistence.push(storedMessage);
|
|
2083
|
-
this.scheduleReplayPersistenceFlush();
|
|
2084
|
-
} else {
|
|
2085
|
-
void this.withPersistenceRetry("append", () => this.replayPersistence.append(storedMessage)).catch((error) => this.reportPersistenceError(error));
|
|
2086
|
-
}
|
|
2087
|
-
if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
|
|
2088
|
-
this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
|
|
2089
|
-
}
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
scheduleReplayPersistenceFlush() {
|
|
2093
|
-
if (this.replayPersistenceFlushScheduled) return;
|
|
2094
|
-
this.replayPersistenceFlushScheduled = true;
|
|
2095
|
-
queueMicrotask(() => {
|
|
2096
|
-
this.replayPersistenceFlushScheduled = false;
|
|
2097
|
-
const batch = this.pendingReplayPersistence.splice(0);
|
|
2098
|
-
if (batch.length === 0 || !this.replayPersistence) return;
|
|
2099
|
-
const operation = this.replayPersistence.appendBatch ? () => this.replayPersistence.appendBatch(batch) : () => Promise.all(batch.map((message) => this.replayPersistence.append(message))).then(() => void 0);
|
|
2100
|
-
void this.withPersistenceRetry("append", operation).catch((error) => this.reportPersistenceError(error));
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2103
|
-
async hydrateReplay() {
|
|
2104
|
-
if (!this.replayBuffers || !this.replayPersistence) {
|
|
2105
|
-
return;
|
|
2106
|
-
}
|
|
2107
|
-
try {
|
|
2108
|
-
if (this.replayRetentionMs !== void 0 && this.replayPersistence.clearBefore) {
|
|
2109
|
-
await this.withPersistenceRetry("clearBefore", () => this.replayPersistence.clearBefore(this.now() - this.replayRetentionMs));
|
|
2110
|
-
}
|
|
2111
|
-
for (const message of await this.withPersistenceRetry("load", () => this.replayPersistence.load())) {
|
|
2112
|
-
let buffer = this.replayBuffers.get(message.topic);
|
|
2113
|
-
if (!buffer) {
|
|
2114
|
-
buffer = [];
|
|
2115
|
-
this.replayBuffers.set(message.topic, buffer);
|
|
2116
|
-
}
|
|
2117
|
-
buffer.push(message);
|
|
2118
|
-
if (buffer.length > this.replayMaxPerTopic) buffer.shift();
|
|
2119
|
-
}
|
|
2120
|
-
} catch (error) {
|
|
2121
|
-
this.reportPersistenceError(error);
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
scheduleReplayRetentionCleanup(cutoff) {
|
|
2125
|
-
if (!this.replayPersistence?.clearBefore) return;
|
|
2126
|
-
if (this.replayRetentionCutoff === null || cutoff > this.replayRetentionCutoff) {
|
|
2127
|
-
this.replayRetentionCutoff = cutoff;
|
|
2128
|
-
}
|
|
2129
|
-
if (this.replayRetentionCleanup) return;
|
|
2130
|
-
this.replayRetentionCleanup = (async () => {
|
|
2131
|
-
while (this.replayRetentionCutoff !== null) {
|
|
2132
|
-
const nextCutoff = this.replayRetentionCutoff;
|
|
2133
|
-
this.replayRetentionCutoff = null;
|
|
2134
|
-
try {
|
|
2135
|
-
await this.replayPersistence.clearBefore(nextCutoff);
|
|
2136
|
-
} catch (error) {
|
|
2137
|
-
this.reportPersistenceError(error);
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
})().finally(() => {
|
|
2141
|
-
this.replayRetentionCleanup = null;
|
|
2142
|
-
if (this.replayRetentionCutoff !== null) {
|
|
2143
|
-
this.scheduleReplayRetentionCleanup(this.replayRetentionCutoff);
|
|
2144
|
-
}
|
|
2145
|
-
});
|
|
2146
|
-
}
|
|
2147
|
-
startReplayRetentionSweep() {
|
|
2148
|
-
if (this.replayRetentionTimer || !this.replayRetentionMs || !this.replayRetentionSweepMs || !this.replayPersistence?.clearBefore) return;
|
|
2149
|
-
this.replayRetentionTimer = setInterval(() => {
|
|
2150
|
-
this.scheduleReplayRetentionCleanup(this.now() - this.replayRetentionMs);
|
|
2151
|
-
}, this.replayRetentionSweepMs);
|
|
2152
|
-
}
|
|
2153
|
-
stopReplayRetentionSweep() {
|
|
2154
|
-
if (this.replayRetentionTimer) clearInterval(this.replayRetentionTimer);
|
|
2155
|
-
this.replayRetentionTimer = null;
|
|
2156
|
-
}
|
|
2157
|
-
async withPersistenceRetry(persistenceOperation, operation) {
|
|
2158
|
-
const generation = this.persistenceRetryGeneration;
|
|
2159
|
-
let attempt = 0;
|
|
2160
|
-
let delay = this.persistenceRetryBackoffMs;
|
|
2161
|
-
while (true) {
|
|
2162
|
-
attempt += 1;
|
|
2163
|
-
try {
|
|
2164
|
-
if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
|
|
2165
|
-
return await operation();
|
|
2166
|
-
} catch (error) {
|
|
2167
|
-
if (error instanceof PersistenceRetryCancelledError || generation !== this.persistenceRetryGeneration) {
|
|
2168
|
-
throw new PersistenceRetryCancelledError();
|
|
2169
|
-
}
|
|
2170
|
-
if (attempt >= this.persistenceRetryMaxAttempts) throw error;
|
|
2171
|
-
this.trace.event({
|
|
2172
|
-
type: "reliability",
|
|
2173
|
-
operation: "persistence_retry",
|
|
2174
|
-
persistenceOperation,
|
|
2175
|
-
attempt
|
|
2176
|
-
});
|
|
2177
|
-
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2178
|
-
if (generation !== this.persistenceRetryGeneration) throw new PersistenceRetryCancelledError();
|
|
2179
|
-
delay = Math.min(delay * 2, 1600);
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
/** Deliver buffered history to a newly-registered handler. For an exact
|
|
2184
|
-
* topic this is that topic's ring; for a wildcard subscription every
|
|
2185
|
-
* buffered topic matching the pattern contributes (in buffer insertion
|
|
2186
|
-
* order). Replay deliveries are marked `replayed: true` and are not
|
|
2187
|
-
* counted into trace metrics. */
|
|
2188
|
-
deliverReplay(topic, limit, handler) {
|
|
2189
|
-
if (!this.replayBuffers || limit <= 0) return;
|
|
2190
|
-
const deliver = (buffer2) => {
|
|
2191
|
-
for (const message of buffer2.slice(-limit)) {
|
|
2192
|
-
this.invokeHandlers([handler], (h) => h({ ...message, replayed: true }));
|
|
2193
|
-
}
|
|
2194
|
-
};
|
|
2195
|
-
if (isWildcardTopic(topic)) {
|
|
2196
|
-
for (const [bufferedTopic, buffer2] of this.replayBuffers) {
|
|
2197
|
-
if (topicMatchesPattern(topic, bufferedTopic)) deliver(buffer2);
|
|
2198
|
-
}
|
|
2199
|
-
return;
|
|
2200
|
-
}
|
|
2201
|
-
const buffer = this.replayBuffers.get(topic);
|
|
2202
|
-
if (buffer) deliver(buffer);
|
|
3018
|
+
this.replayManager.record(message);
|
|
2203
3019
|
}
|
|
2204
3020
|
/**
|
|
2205
3021
|
* Propagate a status change to the cluster, trace, and all registered
|
|
@@ -2208,13 +3024,13 @@ var CrossTabDataBus = class {
|
|
|
2208
3024
|
updateStatus(status) {
|
|
2209
3025
|
const previousStatus = this.status;
|
|
2210
3026
|
this.status = status;
|
|
2211
|
-
if (previousStatus !== status) this.trace.event({ type:
|
|
3027
|
+
if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
|
|
2212
3028
|
this.cluster.setStatus(status);
|
|
2213
|
-
if (status ===
|
|
2214
|
-
if (status ===
|
|
3029
|
+
if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
|
|
3030
|
+
if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
|
|
2215
3031
|
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
2216
3032
|
}
|
|
2217
|
-
if (status ===
|
|
3033
|
+
if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
|
|
2218
3034
|
const now = this.now();
|
|
2219
3035
|
if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
|
|
2220
3036
|
this.lastRecoveryAt = now;
|
|
@@ -2222,37 +3038,67 @@ var CrossTabDataBus = class {
|
|
|
2222
3038
|
if (attempt > this.recoveryMaxAttempts) {
|
|
2223
3039
|
if (!this.recoveryExhausted) {
|
|
2224
3040
|
this.recoveryExhausted = true;
|
|
2225
|
-
this.trace.event({ type:
|
|
3041
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
|
|
2226
3042
|
}
|
|
2227
3043
|
return;
|
|
2228
3044
|
}
|
|
2229
|
-
this.trace.event({ type:
|
|
3045
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
|
|
2230
3046
|
setTimeout(() => {
|
|
2231
3047
|
if (this.stopping || !this.started || this.suspended) return;
|
|
2232
|
-
if (this.status !==
|
|
3048
|
+
if (this.status !== WORKER_STATUS.ERROR) return;
|
|
2233
3049
|
void this.reopenTransport(attempt);
|
|
2234
3050
|
}, this.recoveryCooldownMs);
|
|
2235
3051
|
}
|
|
2236
3052
|
}
|
|
2237
3053
|
this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
|
|
2238
3054
|
}
|
|
2239
|
-
reportError(error) {
|
|
2240
|
-
this.
|
|
2241
|
-
|
|
3055
|
+
reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
|
|
3056
|
+
this.lastFailure = {
|
|
3057
|
+
source,
|
|
3058
|
+
message: error instanceof Error ? error.message : String(error),
|
|
3059
|
+
at: this.now()
|
|
3060
|
+
};
|
|
3061
|
+
if (source === FAILURE_SOURCE.PERSISTENCE) {
|
|
3062
|
+
this.persistenceFailureCount += 1;
|
|
3063
|
+
this.persistenceLastFailureAt = this.lastFailure.at;
|
|
3064
|
+
this.persistenceLastErrorMessage = this.lastFailure.message;
|
|
3065
|
+
}
|
|
3066
|
+
this.trace.event({
|
|
3067
|
+
type: TRACE_EVENT_TYPE.ERROR,
|
|
3068
|
+
source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
|
|
3069
|
+
});
|
|
3070
|
+
this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
|
|
2242
3071
|
}
|
|
3072
|
+
/** Report a persistence failure to the trace and the unified failure ledger,
|
|
3073
|
+
* unless it is a {@link PersistenceRetryCancelledError} cancellation from a
|
|
3074
|
+
* lifecycle transition (teardown should stay quiet). */
|
|
2243
3075
|
reportPersistenceError(error) {
|
|
2244
3076
|
if (error instanceof PersistenceRetryCancelledError) return;
|
|
2245
|
-
this.trace.event({ type:
|
|
2246
|
-
this.reportError(error);
|
|
3077
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
|
|
3078
|
+
this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
|
|
2247
3079
|
}
|
|
2248
3080
|
traceSubscription(action, topic) {
|
|
2249
3081
|
this.trace.event({
|
|
2250
|
-
type:
|
|
3082
|
+
type: TRACE_EVENT_TYPE.SUBSCRIPTION,
|
|
2251
3083
|
action,
|
|
2252
3084
|
topic,
|
|
2253
3085
|
activeTopics: this.transportSubscribedTopics.size
|
|
2254
3086
|
});
|
|
2255
3087
|
}
|
|
3088
|
+
/** Emit the coordination trace snapshot from the current cluster state.
|
|
3089
|
+
* Called after a transport opens (start and recovery), when the role and
|
|
3090
|
+
* route picture has settled — the synchronous pre-open snapshot would see no
|
|
3091
|
+
* routes because their writes are still coalesced in the batch writer. */
|
|
3092
|
+
emitCoordinationTrace() {
|
|
3093
|
+
const snapshot = this.cluster.getSnapshot();
|
|
3094
|
+
this.trace.event({
|
|
3095
|
+
type: TRACE_EVENT_TYPE.COORDINATION,
|
|
3096
|
+
coordinated: snapshot.coordinated,
|
|
3097
|
+
activeWorkers: snapshot.workers.filter((worker) => worker.role === WORKER_ROLE.ACTIVE).length,
|
|
3098
|
+
workers: snapshot.workers.map(formatWorkerTrace),
|
|
3099
|
+
routes: snapshot.routes.map(formatRouteTrace)
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
2256
3102
|
/** Ask the transport to subscribe to a topic (idempotent). */
|
|
2257
3103
|
subscribeTransport(topic) {
|
|
2258
3104
|
if (this.transportSubscribedTopics.has(topic)) return false;
|
|
@@ -2270,17 +3116,17 @@ var CrossTabDataBus = class {
|
|
|
2270
3116
|
* are routed to `reportError` (which surfaces them to error subscribers);
|
|
2271
3117
|
* error-handler failures are logged to the console to avoid infinite
|
|
2272
3118
|
* recursion through reportError itself. */
|
|
2273
|
-
invokeHandlers(handlers, callback, label =
|
|
3119
|
+
invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
|
|
2274
3120
|
for (const handler of handlers) {
|
|
2275
3121
|
try {
|
|
2276
3122
|
callback(handler);
|
|
2277
3123
|
} catch (error) {
|
|
2278
|
-
if (label ===
|
|
3124
|
+
if (label === INVOKE_LABEL.ERROR_HANDLER) {
|
|
2279
3125
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
2280
|
-
console.warn(
|
|
3126
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
|
|
2281
3127
|
}
|
|
2282
3128
|
} else {
|
|
2283
|
-
this.reportError(error);
|
|
3129
|
+
this.reportError(error, FAILURE_SOURCE.DISPATCH);
|
|
2284
3130
|
}
|
|
2285
3131
|
}
|
|
2286
3132
|
}
|
|
@@ -2291,10 +3137,11 @@ var CrossTabDataBus = class {
|
|
|
2291
3137
|
*/
|
|
2292
3138
|
suspendTransport() {
|
|
2293
3139
|
if (this.stopping) return;
|
|
3140
|
+
this.lifecycleEpoch += 1;
|
|
2294
3141
|
this.suspended = true;
|
|
2295
3142
|
this.transportReady = false;
|
|
2296
3143
|
this.transportSubscribedTopics.clear();
|
|
2297
|
-
this.updateStatus(
|
|
3144
|
+
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
2298
3145
|
if (this.pendingStop) return;
|
|
2299
3146
|
const pending = this.startPromise ?? Promise.resolve();
|
|
2300
3147
|
const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
|
|
@@ -2328,22 +3175,26 @@ var CrossTabDataBus = class {
|
|
|
2328
3175
|
const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
|
|
2329
3176
|
this.started = true;
|
|
2330
3177
|
this.suspended = false;
|
|
2331
|
-
this.updateStatus(
|
|
3178
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
3179
|
+
const lifecycleEpoch = ++this.lifecycleEpoch;
|
|
2332
3180
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
2333
|
-
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
3181
|
+
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false, lifecycleEpoch));
|
|
2334
3182
|
this.startPromise = opening;
|
|
2335
3183
|
void opening.then(
|
|
2336
3184
|
() => {
|
|
3185
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3186
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
2337
3187
|
if (traceAttempt !== void 0) {
|
|
2338
|
-
this.trace.event({ type:
|
|
3188
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
|
|
2339
3189
|
this.recoveryAttempt = 0;
|
|
2340
3190
|
this.recoveryExhausted = false;
|
|
2341
3191
|
}
|
|
2342
|
-
if (this.startPromise === opening) this.startPromise = null;
|
|
2343
3192
|
},
|
|
2344
3193
|
() => {
|
|
3194
|
+
if (this.startPromise === opening) this.startPromise = null;
|
|
3195
|
+
if (lifecycleEpoch !== this.lifecycleEpoch) return;
|
|
2345
3196
|
if (traceAttempt !== void 0) {
|
|
2346
|
-
this.trace.event({ type:
|
|
3197
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
|
|
2347
3198
|
}
|
|
2348
3199
|
}
|
|
2349
3200
|
);
|
|
@@ -2375,6 +3226,19 @@ var CrossTabDataBus = class {
|
|
|
2375
3226
|
return operation();
|
|
2376
3227
|
}).catch((error) => this.reportError(error));
|
|
2377
3228
|
}
|
|
3229
|
+
/**
|
|
3230
|
+
* Publications started after teardown begins cannot reach any transport.
|
|
3231
|
+
* Surface that as a normal asynchronous API failure instead of letting
|
|
3232
|
+
* runTransport() return silently. Empty publishBatch() calls remain a no-op
|
|
3233
|
+
* and are filtered by the caller before this check.
|
|
3234
|
+
*/
|
|
3235
|
+
rejectPublishDuringStop(operation) {
|
|
3236
|
+
if (!this.stopping) return false;
|
|
3237
|
+
this.reportError(new Error(
|
|
3238
|
+
`CrossTabDataBus is stopping; ${operation}() was not sent. Wait for stop() to resolve, then call start() before publishing again.`
|
|
3239
|
+
));
|
|
3240
|
+
return true;
|
|
3241
|
+
}
|
|
2378
3242
|
/**
|
|
2379
3243
|
* Ensure the DataBus is started, throwing if no initialConfig was provided.
|
|
2380
3244
|
* Called automatically by subscribe/publish/ready when autoStart is true.
|
|
@@ -2401,16 +3265,14 @@ function formatRouteTrace(route) {
|
|
|
2401
3265
|
function createIndexedDbReplayPersistence(options) {
|
|
2402
3266
|
const indexedDb = globalThis.indexedDB;
|
|
2403
3267
|
if (!indexedDb) throw new Error("IndexedDB is unavailable in this environment.");
|
|
2404
|
-
const dbName = options.dbName ??
|
|
3268
|
+
const dbName = options.dbName ?? DEFAULT_STORAGE_PREFIX;
|
|
2405
3269
|
const storeName = "replay";
|
|
2406
3270
|
const maxPerTopic = options.maxPerTopic;
|
|
2407
|
-
const pruneStrategy = options.pruneStrategy ??
|
|
3271
|
+
const pruneStrategy = options.pruneStrategy ?? PRUNE_STRATEGY.COUNT;
|
|
2408
3272
|
const retentionMs = options.retentionMs;
|
|
2409
|
-
|
|
2410
|
-
if (retentionMs !== void 0
|
|
2411
|
-
|
|
2412
|
-
throw new TypeError(`maxPerTopic must be a positive safe integer, got ${String(maxPerTopic)}.`);
|
|
2413
|
-
}
|
|
3273
|
+
assertPruneStrategy(pruneStrategy);
|
|
3274
|
+
if (retentionMs !== void 0) assertPositiveFiniteNumber(retentionMs, "retentionMs");
|
|
3275
|
+
assertPositiveSafeInteger(maxPerTopic, "maxPerTopic");
|
|
2414
3276
|
let dbPromise = null;
|
|
2415
3277
|
const invalidate = (db) => {
|
|
2416
3278
|
if (dbPromise) {
|
|
@@ -2422,15 +3284,94 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
2422
3284
|
}, () => void 0);
|
|
2423
3285
|
}
|
|
2424
3286
|
};
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
3287
|
+
const pending = [];
|
|
3288
|
+
let draining = false;
|
|
3289
|
+
const drain = async () => {
|
|
3290
|
+
if (draining) return;
|
|
3291
|
+
draining = true;
|
|
3292
|
+
try {
|
|
3293
|
+
await Promise.resolve();
|
|
3294
|
+
while (pending.length > 0) {
|
|
3295
|
+
const head = pending[0].mutation;
|
|
3296
|
+
if (head.kind === "batch") {
|
|
3297
|
+
const merged = [];
|
|
3298
|
+
const entries = [];
|
|
3299
|
+
while (pending.length > 0) {
|
|
3300
|
+
const next = pending[0].mutation;
|
|
3301
|
+
if (next.kind !== "batch") break;
|
|
3302
|
+
merged.push(...next.messages);
|
|
3303
|
+
entries.push({ resolve: pending[0].resolve, reject: pending[0].reject });
|
|
3304
|
+
pending.shift();
|
|
3305
|
+
}
|
|
3306
|
+
try {
|
|
3307
|
+
await appendTransaction(merged);
|
|
3308
|
+
for (const entry of entries) entry.resolve();
|
|
3309
|
+
} catch (error) {
|
|
3310
|
+
for (const entry of entries) entry.reject(error);
|
|
3311
|
+
}
|
|
3312
|
+
} else {
|
|
3313
|
+
const entry = pending.shift();
|
|
3314
|
+
try {
|
|
3315
|
+
await head.run();
|
|
3316
|
+
entry.resolve();
|
|
3317
|
+
} catch (error) {
|
|
3318
|
+
entry.reject(error);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
} finally {
|
|
3323
|
+
draining = false;
|
|
3324
|
+
}
|
|
2430
3325
|
};
|
|
3326
|
+
const enqueue = (mutation) => new Promise((resolve, reject) => {
|
|
3327
|
+
pending.push({ mutation, resolve, reject });
|
|
3328
|
+
void drain();
|
|
3329
|
+
});
|
|
3330
|
+
const appendTransaction = (messages) => (async () => {
|
|
3331
|
+
const db = await open();
|
|
3332
|
+
return new Promise((resolve, reject) => {
|
|
3333
|
+
let transaction;
|
|
3334
|
+
try {
|
|
3335
|
+
transaction = db.transaction(storeName, "readwrite");
|
|
3336
|
+
} catch (error) {
|
|
3337
|
+
invalidate(db);
|
|
3338
|
+
reject(error);
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3341
|
+
const store = transaction.objectStore(storeName);
|
|
3342
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3343
|
+
for (const message of messages) {
|
|
3344
|
+
grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
|
|
3345
|
+
}
|
|
3346
|
+
let hasError = false;
|
|
3347
|
+
const fail = (error) => {
|
|
3348
|
+
if (hasError) return;
|
|
3349
|
+
hasError = true;
|
|
3350
|
+
invalidate(db);
|
|
3351
|
+
reject(error);
|
|
3352
|
+
};
|
|
3353
|
+
for (const [topic, topicMessages] of grouped) {
|
|
3354
|
+
const request = store.get(topic);
|
|
3355
|
+
request.onsuccess = () => {
|
|
3356
|
+
if (hasError) return;
|
|
3357
|
+
const history = pruneReplayHistory(
|
|
3358
|
+
(request.result?.messages ?? []).concat(topicMessages),
|
|
3359
|
+
{ maxPerTopic, pruneStrategy, retentionMs, now: Date.now() }
|
|
3360
|
+
);
|
|
3361
|
+
store.put({ topic, messages: history });
|
|
3362
|
+
};
|
|
3363
|
+
request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
|
|
3364
|
+
}
|
|
3365
|
+
transaction.oncomplete = () => {
|
|
3366
|
+
if (!hasError) resolve();
|
|
3367
|
+
};
|
|
3368
|
+
transaction.onerror = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
|
|
3369
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to persist replay history."));
|
|
3370
|
+
});
|
|
3371
|
+
})();
|
|
2431
3372
|
const open = () => {
|
|
2432
3373
|
if (dbPromise) return dbPromise;
|
|
2433
|
-
const
|
|
3374
|
+
const pending2 = new Promise((resolve, reject) => {
|
|
2434
3375
|
const request = indexedDb.open(dbName, 1);
|
|
2435
3376
|
request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: "topic" });
|
|
2436
3377
|
request.onsuccess = () => {
|
|
@@ -2443,179 +3384,156 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
2443
3384
|
};
|
|
2444
3385
|
request.onerror = () => reject(request.error ?? new Error("Failed to open replay database."));
|
|
2445
3386
|
});
|
|
2446
|
-
dbPromise =
|
|
2447
|
-
void
|
|
2448
|
-
if (dbPromise ===
|
|
3387
|
+
dbPromise = pending2;
|
|
3388
|
+
void pending2.catch(() => {
|
|
3389
|
+
if (dbPromise === pending2) dbPromise = null;
|
|
2449
3390
|
});
|
|
2450
|
-
return
|
|
3391
|
+
return pending2;
|
|
2451
3392
|
};
|
|
2452
3393
|
return {
|
|
2453
3394
|
async load() {
|
|
2454
3395
|
const db = await open();
|
|
2455
3396
|
return new Promise((resolve, reject) => {
|
|
3397
|
+
let transaction;
|
|
2456
3398
|
let request;
|
|
2457
3399
|
try {
|
|
2458
|
-
|
|
3400
|
+
transaction = db.transaction(storeName, "readonly");
|
|
3401
|
+
request = transaction.objectStore(storeName).getAll();
|
|
2459
3402
|
} catch (error) {
|
|
2460
3403
|
invalidate(db);
|
|
2461
3404
|
reject(error);
|
|
2462
3405
|
return;
|
|
2463
3406
|
}
|
|
2464
|
-
|
|
2465
|
-
|
|
3407
|
+
let settled = false;
|
|
3408
|
+
const fail = (error) => {
|
|
3409
|
+
if (settled) return;
|
|
3410
|
+
settled = true;
|
|
2466
3411
|
invalidate(db);
|
|
2467
|
-
reject(
|
|
3412
|
+
reject(error);
|
|
3413
|
+
};
|
|
3414
|
+
let records = [];
|
|
3415
|
+
request.onsuccess = () => {
|
|
3416
|
+
records = request.result;
|
|
3417
|
+
};
|
|
3418
|
+
request.onerror = () => fail(request.error ?? new Error("Failed to load replay history."));
|
|
3419
|
+
transaction.oncomplete = () => {
|
|
3420
|
+
if (settled) return;
|
|
3421
|
+
settled = true;
|
|
3422
|
+
resolve(records.flatMap((record) => record.messages));
|
|
2468
3423
|
};
|
|
3424
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to load replay history."));
|
|
2469
3425
|
});
|
|
2470
3426
|
},
|
|
2471
3427
|
append(message) {
|
|
2472
|
-
return
|
|
2473
|
-
const db = await open();
|
|
2474
|
-
await new Promise((resolve, reject) => {
|
|
2475
|
-
let transaction;
|
|
2476
|
-
try {
|
|
2477
|
-
transaction = db.transaction(storeName, "readwrite");
|
|
2478
|
-
} catch (error) {
|
|
2479
|
-
invalidate(db);
|
|
2480
|
-
reject(error);
|
|
2481
|
-
return;
|
|
2482
|
-
}
|
|
2483
|
-
const store = transaction.objectStore(storeName);
|
|
2484
|
-
const request = store.get(message.topic);
|
|
2485
|
-
request.onsuccess = () => {
|
|
2486
|
-
let messages = (request.result?.messages ?? []).concat(message);
|
|
2487
|
-
if (pruneStrategy !== "count" && retentionMs !== void 0) {
|
|
2488
|
-
const cutoff = Date.now() - retentionMs;
|
|
2489
|
-
messages = messages.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
|
|
2490
|
-
}
|
|
2491
|
-
if (pruneStrategy !== "age") messages = messages.slice(-maxPerTopic);
|
|
2492
|
-
store.put({ topic: message.topic, messages });
|
|
2493
|
-
};
|
|
2494
|
-
request.onerror = () => {
|
|
2495
|
-
invalidate(db);
|
|
2496
|
-
reject(request.error ?? new Error("Failed to read replay history."));
|
|
2497
|
-
};
|
|
2498
|
-
transaction.oncomplete = () => resolve();
|
|
2499
|
-
transaction.onerror = () => {
|
|
2500
|
-
invalidate(db);
|
|
2501
|
-
reject(transaction.error ?? new Error("Failed to persist replay history."));
|
|
2502
|
-
};
|
|
2503
|
-
});
|
|
2504
|
-
});
|
|
3428
|
+
return enqueue({ kind: "batch", messages: [message] });
|
|
2505
3429
|
},
|
|
2506
3430
|
appendBatch(messages) {
|
|
2507
3431
|
if (messages.length === 0) return Promise.resolve();
|
|
2508
|
-
return
|
|
2509
|
-
const db = await open();
|
|
2510
|
-
await new Promise((resolve, reject) => {
|
|
2511
|
-
let transaction;
|
|
2512
|
-
try {
|
|
2513
|
-
transaction = db.transaction(storeName, "readwrite");
|
|
2514
|
-
} catch (error) {
|
|
2515
|
-
invalidate(db);
|
|
2516
|
-
reject(error);
|
|
2517
|
-
return;
|
|
2518
|
-
}
|
|
2519
|
-
const store = transaction.objectStore(storeName);
|
|
2520
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
2521
|
-
for (const message of messages) grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
|
|
2522
|
-
for (const [topic, topicMessages] of grouped) {
|
|
2523
|
-
const request = store.get(topic);
|
|
2524
|
-
request.onsuccess = () => {
|
|
2525
|
-
let history = (request.result?.messages ?? []).concat(topicMessages);
|
|
2526
|
-
if (pruneStrategy !== "count" && retentionMs !== void 0) {
|
|
2527
|
-
const cutoff = Date.now() - retentionMs;
|
|
2528
|
-
history = history.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
|
|
2529
|
-
}
|
|
2530
|
-
if (pruneStrategy !== "age") history = history.slice(-maxPerTopic);
|
|
2531
|
-
store.put({ topic, messages: history });
|
|
2532
|
-
};
|
|
2533
|
-
request.onerror = () => {
|
|
2534
|
-
invalidate(db);
|
|
2535
|
-
reject(request.error ?? new Error("Failed to read replay history."));
|
|
2536
|
-
};
|
|
2537
|
-
}
|
|
2538
|
-
transaction.oncomplete = () => resolve();
|
|
2539
|
-
transaction.onerror = () => {
|
|
2540
|
-
invalidate(db);
|
|
2541
|
-
reject(transaction.error ?? new Error("Failed to persist replay history batch."));
|
|
2542
|
-
};
|
|
2543
|
-
});
|
|
2544
|
-
});
|
|
3432
|
+
return enqueue({ kind: "batch", messages });
|
|
2545
3433
|
},
|
|
2546
3434
|
clear() {
|
|
2547
|
-
return
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
3435
|
+
return enqueue({
|
|
3436
|
+
kind: "run",
|
|
3437
|
+
run: () => (async () => {
|
|
3438
|
+
const db = await open();
|
|
3439
|
+
return new Promise((resolve, reject) => {
|
|
3440
|
+
let transaction;
|
|
3441
|
+
try {
|
|
3442
|
+
transaction = db.transaction(storeName, "readwrite");
|
|
3443
|
+
} catch (error) {
|
|
3444
|
+
invalidate(db);
|
|
3445
|
+
reject(error);
|
|
3446
|
+
return;
|
|
3447
|
+
}
|
|
3448
|
+
let settled = false;
|
|
3449
|
+
const fail = (error) => {
|
|
3450
|
+
if (settled) return;
|
|
3451
|
+
settled = true;
|
|
3452
|
+
invalidate(db);
|
|
3453
|
+
reject(error);
|
|
3454
|
+
};
|
|
3455
|
+
transaction.objectStore(storeName).clear();
|
|
3456
|
+
transaction.oncomplete = () => {
|
|
3457
|
+
settled = true;
|
|
3458
|
+
resolve();
|
|
3459
|
+
};
|
|
3460
|
+
transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
|
|
3461
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear replay history."));
|
|
3462
|
+
});
|
|
3463
|
+
})()
|
|
2565
3464
|
});
|
|
2566
3465
|
},
|
|
2567
3466
|
clearTopic(topic) {
|
|
2568
|
-
return
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
3467
|
+
return enqueue({
|
|
3468
|
+
kind: "run",
|
|
3469
|
+
run: () => (async () => {
|
|
3470
|
+
const db = await open();
|
|
3471
|
+
return new Promise((resolve, reject) => {
|
|
3472
|
+
let transaction;
|
|
3473
|
+
try {
|
|
3474
|
+
transaction = db.transaction(storeName, "readwrite");
|
|
3475
|
+
} catch (error) {
|
|
3476
|
+
invalidate(db);
|
|
3477
|
+
reject(error);
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
let settled = false;
|
|
3481
|
+
const fail = (error) => {
|
|
3482
|
+
if (settled) return;
|
|
3483
|
+
settled = true;
|
|
3484
|
+
invalidate(db);
|
|
3485
|
+
reject(error);
|
|
3486
|
+
};
|
|
3487
|
+
transaction.objectStore(storeName).delete(topic);
|
|
3488
|
+
transaction.oncomplete = () => {
|
|
3489
|
+
settled = true;
|
|
3490
|
+
resolve();
|
|
3491
|
+
};
|
|
3492
|
+
transaction.onerror = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
|
|
3493
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to clear topic replay history."));
|
|
3494
|
+
});
|
|
3495
|
+
})()
|
|
2586
3496
|
});
|
|
2587
3497
|
},
|
|
2588
3498
|
clearBefore(timestamp) {
|
|
2589
|
-
return
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
const request = store.getAll();
|
|
2602
|
-
request.onsuccess = () => {
|
|
2603
|
-
for (const record of request.result) {
|
|
2604
|
-
const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
2605
|
-
if (messages.length === 0) store.delete(record.topic);
|
|
2606
|
-
else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
|
|
3499
|
+
return enqueue({
|
|
3500
|
+
kind: "run",
|
|
3501
|
+
run: () => (async () => {
|
|
3502
|
+
const db = await open();
|
|
3503
|
+
return new Promise((resolve, reject) => {
|
|
3504
|
+
let transaction;
|
|
3505
|
+
try {
|
|
3506
|
+
transaction = db.transaction(storeName, "readwrite");
|
|
3507
|
+
} catch (error) {
|
|
3508
|
+
invalidate(db);
|
|
3509
|
+
reject(error);
|
|
3510
|
+
return;
|
|
2607
3511
|
}
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
3512
|
+
let settled = false;
|
|
3513
|
+
const fail = (error) => {
|
|
3514
|
+
if (settled) return;
|
|
3515
|
+
settled = true;
|
|
3516
|
+
invalidate(db);
|
|
3517
|
+
reject(error);
|
|
3518
|
+
};
|
|
3519
|
+
const store = transaction.objectStore(storeName);
|
|
3520
|
+
const request = store.getAll();
|
|
3521
|
+
request.onsuccess = () => {
|
|
3522
|
+
for (const record of request.result) {
|
|
3523
|
+
const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
3524
|
+
if (messages.length === 0) store.delete(record.topic);
|
|
3525
|
+
else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
|
|
3526
|
+
}
|
|
3527
|
+
};
|
|
3528
|
+
request.onerror = () => fail(request.error ?? new Error("Failed to read replay history."));
|
|
3529
|
+
transaction.oncomplete = () => {
|
|
3530
|
+
settled = true;
|
|
3531
|
+
resolve();
|
|
3532
|
+
};
|
|
3533
|
+
transaction.onerror = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
|
|
3534
|
+
transaction.onabort = () => fail(transaction.error ?? new Error("Failed to prune replay history."));
|
|
3535
|
+
});
|
|
3536
|
+
})()
|
|
2619
3537
|
});
|
|
2620
3538
|
}
|
|
2621
3539
|
};
|
|
@@ -2649,6 +3567,7 @@ var WebSocketTransport = class {
|
|
|
2649
3567
|
constructor(connection) {
|
|
2650
3568
|
this.connection = connection;
|
|
2651
3569
|
}
|
|
3570
|
+
connection;
|
|
2652
3571
|
diagnosticsName = "websocket";
|
|
2653
3572
|
diagnosticsBackend = "native-websocket";
|
|
2654
3573
|
socket = null;
|
|
@@ -2665,22 +3584,22 @@ var WebSocketTransport = class {
|
|
|
2665
3584
|
try {
|
|
2666
3585
|
socket = factory(config.url, protocols);
|
|
2667
3586
|
} catch (error) {
|
|
2668
|
-
handlers.onStatus(
|
|
3587
|
+
handlers.onStatus(WORKER_STATUS.ERROR);
|
|
2669
3588
|
handlers.onError(error);
|
|
2670
3589
|
return;
|
|
2671
3590
|
}
|
|
2672
3591
|
socket.onopen = () => {
|
|
2673
3592
|
if (this.socket !== socket || this.handlers !== handlers) return;
|
|
2674
3593
|
for (const topic of this.subscribedTopics) {
|
|
2675
|
-
this.sendFrame({ op:
|
|
3594
|
+
this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
|
|
2676
3595
|
}
|
|
2677
|
-
handlers.onStatus(
|
|
3596
|
+
handlers.onStatus(WORKER_STATUS.CONNECTED);
|
|
2678
3597
|
};
|
|
2679
3598
|
socket.onclose = () => {
|
|
2680
|
-
if (this.socket === socket && this.handlers === handlers) handlers.onStatus(
|
|
3599
|
+
if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.DISCONNECTED);
|
|
2681
3600
|
};
|
|
2682
3601
|
socket.onerror = () => {
|
|
2683
|
-
if (this.socket === socket && this.handlers === handlers) handlers.onStatus(
|
|
3602
|
+
if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.ERROR);
|
|
2684
3603
|
};
|
|
2685
3604
|
socket.onmessage = (event) => {
|
|
2686
3605
|
if (this.socket === socket && this.handlers === handlers) void this.handleMessage(event.data);
|
|
@@ -2691,12 +3610,12 @@ var WebSocketTransport = class {
|
|
|
2691
3610
|
* not duplicate the local tracking entry. */
|
|
2692
3611
|
subscribe(topic) {
|
|
2693
3612
|
this.subscribedTopics.add(topic);
|
|
2694
|
-
this.sendFrame({ op:
|
|
3613
|
+
this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
|
|
2695
3614
|
}
|
|
2696
3615
|
/** Idempotent: unsubscribing an unknown topic is a no-op. */
|
|
2697
3616
|
unsubscribe(topic) {
|
|
2698
3617
|
this.subscribedTopics.delete(topic);
|
|
2699
|
-
this.sendFrame({ op:
|
|
3618
|
+
this.sendFrame({ op: WS_OP.UNSUBSCRIBE, topic });
|
|
2700
3619
|
}
|
|
2701
3620
|
/** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
|
|
2702
3621
|
publish(topic, data, options) {
|
|
@@ -2705,13 +3624,39 @@ var WebSocketTransport = class {
|
|
|
2705
3624
|
return;
|
|
2706
3625
|
}
|
|
2707
3626
|
this.sendFrame({
|
|
2708
|
-
op:
|
|
3627
|
+
op: WS_OP.PUBLISH,
|
|
2709
3628
|
topic,
|
|
2710
3629
|
data,
|
|
2711
3630
|
...options?.messageId === void 0 ? {} : { messageId: options.messageId },
|
|
2712
3631
|
...options?.timestamp === void 0 ? {} : { timestamp: options.timestamp }
|
|
2713
3632
|
});
|
|
2714
3633
|
}
|
|
3634
|
+
/** Publish many items for one topic as a single wire frame. One-item
|
|
3635
|
+
* batches delegate to `publish` so the legacy single-publication frame
|
|
3636
|
+
* shape (including binary framing) is preserved. */
|
|
3637
|
+
publishBatch(topic, items) {
|
|
3638
|
+
if (items.length === 0) return;
|
|
3639
|
+
if (items.length === 1) {
|
|
3640
|
+
const single = items[0];
|
|
3641
|
+
return this.publish(topic, single.data, {
|
|
3642
|
+
...single.messageId === void 0 ? {} : { messageId: single.messageId },
|
|
3643
|
+
...single.timestamp === void 0 ? {} : { timestamp: single.timestamp }
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
if (this.socket?.readyState !== WS_OPEN) {
|
|
3647
|
+
this.handlers?.onError(new Error('WebSocket is not open; dropped "publishBatch" frame.'));
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
this.socket.send(JSON.stringify({
|
|
3651
|
+
op: WS_OP.PUBLISH_BATCH,
|
|
3652
|
+
topic,
|
|
3653
|
+
items: items.map((item) => ({
|
|
3654
|
+
data: item.data instanceof ArrayBuffer ? Array.from(new Uint8Array(item.data)) : item.data,
|
|
3655
|
+
...item.messageId === void 0 ? {} : { messageId: item.messageId },
|
|
3656
|
+
...item.timestamp === void 0 ? {} : { timestamp: item.timestamp }
|
|
3657
|
+
}))
|
|
3658
|
+
}));
|
|
3659
|
+
}
|
|
2715
3660
|
/** Close the socket and drop all state. Safe to call multiple times. */
|
|
2716
3661
|
stop() {
|
|
2717
3662
|
const socket = this.socket;
|
|
@@ -2733,7 +3678,7 @@ var WebSocketTransport = class {
|
|
|
2733
3678
|
sendBinaryFrame(topic, data, messageId, timestamp) {
|
|
2734
3679
|
if (messageId !== void 0 || timestamp !== void 0) {
|
|
2735
3680
|
this.sendFrame({
|
|
2736
|
-
op:
|
|
3681
|
+
op: WS_OP.PUBLISH,
|
|
2737
3682
|
topic,
|
|
2738
3683
|
data: Array.from(new Uint8Array(data)),
|
|
2739
3684
|
...messageId === void 0 ? {} : { messageId },
|
|
@@ -2813,9 +3758,9 @@ function createWebSocketDataBus(options) {
|
|
|
2813
3758
|
function selectWorkerBackend(mode, availability = {}) {
|
|
2814
3759
|
const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
|
|
2815
3760
|
const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
|
|
2816
|
-
if (mode ===
|
|
2817
|
-
return hasShared ?
|
|
3761
|
+
if (mode === WORKER_MODE.SHARED || mode === WORKER_MODE.AUTO) {
|
|
3762
|
+
return hasShared ? WORKER_BACKEND.SHARED : hasDedicated ? WORKER_BACKEND.DEDICATED : WORKER_BACKEND.LOCAL;
|
|
2818
3763
|
}
|
|
2819
|
-
return hasDedicated ?
|
|
3764
|
+
return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
|
|
2820
3765
|
}
|
|
2821
3766
|
//# sourceMappingURL=index.cjs.map
|