cross-tab-worker-databus 0.20.71 → 0.20.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -2
- package/README.md +8 -0
- package/README.zh.md +4 -0
- package/dist/centrifuge-protocol.d.ts +59 -26
- package/dist/centrifuge-protocol.d.ts.map +1 -1
- package/dist/centrifuge-session.d.ts +9 -0
- package/dist/centrifuge-session.d.ts.map +1 -1
- package/dist/centrifuge.d.ts +12 -2
- package/dist/centrifuge.d.ts.map +1 -1
- package/dist/centrifuge.js +168 -88
- package/dist/centrifuge.js.map +3 -3
- package/dist/centrifuge.shared.worker.js +146 -38
- package/dist/centrifuge.shared.worker.js.map +3 -3
- package/dist/centrifuge.worker.js +140 -33
- package/dist/centrifuge.worker.js.map +3 -3
- package/dist/{chunk-D2SIT473.js → chunk-PW63EWIK.js} +1090 -494
- package/dist/chunk-PW63EWIK.js.map +7 -0
- package/dist/chunk-TZ7ZP7YD.js +175 -0
- package/dist/chunk-TZ7ZP7YD.js.map +7 -0
- package/dist/cjs/centrifuge.cjs +1403 -637
- package/dist/cjs/centrifuge.cjs.map +4 -4
- package/dist/cjs/hooks.cjs +37 -2
- package/dist/cjs/hooks.cjs.map +3 -3
- package/dist/cjs/index.cjs +1453 -730
- package/dist/cjs/index.cjs.map +4 -4
- package/dist/cjs/vue.cjs +45 -2
- package/dist/cjs/vue.cjs.map +3 -3
- package/dist/core/cluster.d.ts +40 -4
- package/dist/core/cluster.d.ts.map +1 -1
- package/dist/core/data-bus.d.ts +93 -69
- package/dist/core/data-bus.d.ts.map +1 -1
- package/dist/core/dedup-manager.d.ts +98 -0
- package/dist/core/dedup-manager.d.ts.map +1 -0
- package/dist/core/environment.d.ts +43 -3
- package/dist/core/environment.d.ts.map +1 -1
- package/dist/core/replay-manager.d.ts +128 -0
- package/dist/core/replay-manager.d.ts.map +1 -0
- package/dist/core/replay-persistence.d.ts +2 -1
- package/dist/core/replay-persistence.d.ts.map +1 -1
- package/dist/core/routing.d.ts +22 -3
- package/dist/core/routing.d.ts.map +1 -1
- package/dist/core/storage-batch.d.ts +0 -4
- package/dist/core/storage-batch.d.ts.map +1 -1
- package/dist/core/trace.d.ts +55 -15
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/types.d.ts +58 -9
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/hooks.d.ts +12 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +28 -2
- package/dist/hooks.js.map +2 -2
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +228 -162
- package/dist/index.js.map +3 -3
- package/dist/utils/constants.d.ts +170 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/error-utils.d.ts +28 -0
- package/dist/utils/error-utils.d.ts.map +1 -0
- package/dist/utils/metadata.d.ts +16 -0
- package/dist/utils/metadata.d.ts.map +1 -0
- package/dist/utils/storage-utils.d.ts +24 -0
- package/dist/utils/storage-utils.d.ts.map +1 -0
- package/dist/utils/validation.d.ts +76 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/vue.d.ts +13 -1
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +36 -2
- package/dist/vue.js.map +2 -2
- package/dist/websocket.d.ts +6 -1
- package/dist/websocket.d.ts.map +1 -1
- package/dist/worker-mode.d.ts +6 -2
- package/dist/worker-mode.d.ts.map +1 -1
- package/docs/README.md +1 -0
- package/docs/api.md +122 -2
- package/docs/architecture.md +80 -0
- package/docs/benchmarks.md +24 -0
- package/docs/capabilities.md +24 -3
- package/docs/configuration.md +58 -0
- package/docs/getting-started.md +35 -0
- package/docs/release-checklist.md +28 -5
- package/docs/roadmap.md +74 -5
- package/docs/zh/README.md +2 -1
- package/docs/zh/api.md +121 -2
- package/docs/zh/architecture.md +49 -0
- package/docs/zh/benchmarks.md +24 -0
- package/docs/zh/capabilities.md +21 -3
- package/docs/zh/configuration.md +53 -0
- package/docs/zh/getting-started.md +35 -0
- package/docs/zh/release-checklist.md +29 -6
- package/docs/zh/roadmap.md +86 -5
- package/package.json +39 -16
- package/dist/chunk-D2SIT473.js.map +0 -7
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,215 +1855,605 @@ function roundMs(value) {
|
|
|
1411
1855
|
return Math.round(value * 10) / 10;
|
|
1412
1856
|
}
|
|
1413
1857
|
|
|
1414
|
-
// src/core/
|
|
1415
|
-
var PUBLICATION_EVENT = "DATABUS_PUBLICATION";
|
|
1416
|
-
var SDK_VERSION = "0.20.69";
|
|
1417
|
-
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
1858
|
+
// src/core/replay-manager.ts
|
|
1418
1859
|
var PersistenceRetryCancelledError = class extends Error {
|
|
1419
1860
|
constructor() {
|
|
1420
1861
|
super("Persistence retry cancelled by lifecycle transition.");
|
|
1421
1862
|
this.name = "PersistenceRetryCancelledError";
|
|
1422
1863
|
}
|
|
1423
1864
|
};
|
|
1424
|
-
var
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1865
|
+
var MAX_RETRY_DELAY_MS2 = 1600;
|
|
1866
|
+
var ReplayManager = class {
|
|
1867
|
+
constructor(deps) {
|
|
1868
|
+
this.deps = deps;
|
|
1869
|
+
this.buffers = deps.enabled ? /* @__PURE__ */ new Map() : null;
|
|
1870
|
+
this.maxPerTopic = deps.maxPerTopic;
|
|
1871
|
+
this.persistence = deps.persistence ?? null;
|
|
1872
|
+
this.retentionMs = deps.retentionMs;
|
|
1873
|
+
this.pruneStrategy = deps.pruneStrategy;
|
|
1874
|
+
this.retentionSweepMs = deps.retentionSweepMs;
|
|
1875
|
+
this.persistenceRetryMaxAttempts = deps.persistenceRetryMaxAttempts;
|
|
1876
|
+
this.persistenceRetryBackoffMs = deps.persistenceRetryBackoffMs;
|
|
1877
|
+
this.now = deps.now;
|
|
1878
|
+
this.trace = deps.trace;
|
|
1879
|
+
this.onPersistenceError = deps.onPersistenceError;
|
|
1880
|
+
this.onDispatchError = deps.onDispatchError;
|
|
1881
|
+
this.hydration = this.hydrate();
|
|
1882
|
+
}
|
|
1883
|
+
deps;
|
|
1884
|
+
buffers;
|
|
1885
|
+
maxPerTopic;
|
|
1886
|
+
persistence;
|
|
1887
|
+
retentionMs;
|
|
1888
|
+
pruneStrategy;
|
|
1889
|
+
retentionSweepMs;
|
|
1442
1890
|
persistenceRetryMaxAttempts;
|
|
1443
1891
|
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
1892
|
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
|
-
pendingStop = null;
|
|
1503
|
-
// Minimum interval in ms between automatic recovery attempts.
|
|
1504
|
-
recoveryCooldownMs;
|
|
1505
|
-
recoveryMaxAttempts;
|
|
1506
|
-
constructor(options) {
|
|
1507
|
-
const replay = options.replay;
|
|
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
|
-
);
|
|
1893
|
+
trace;
|
|
1894
|
+
onPersistenceError;
|
|
1895
|
+
onDispatchError;
|
|
1896
|
+
/** Bumped on suspend/stop so in-flight persistence retries are cancelled. */
|
|
1897
|
+
retryGeneration = 0;
|
|
1898
|
+
pendingReplayPersistence = [];
|
|
1899
|
+
persistenceFlushScheduled = false;
|
|
1900
|
+
hydration;
|
|
1901
|
+
/** Coalesced retention cleanup: the newest cutoff wins while one is running. */
|
|
1902
|
+
retentionCleanup = null;
|
|
1903
|
+
retentionCutoff = null;
|
|
1904
|
+
retentionTimer = null;
|
|
1905
|
+
/** True when replay buffering is enabled. */
|
|
1906
|
+
get enabled() {
|
|
1907
|
+
return this.buffers !== null;
|
|
1908
|
+
}
|
|
1909
|
+
/** Append a dispatched publication to the topic's replay ring buffer.
|
|
1910
|
+
* No-op when replay is disabled. */
|
|
1911
|
+
record(message) {
|
|
1912
|
+
if (!this.buffers) return;
|
|
1913
|
+
let buffer = this.buffers.get(message.topic);
|
|
1914
|
+
if (!buffer) {
|
|
1915
|
+
buffer = [];
|
|
1916
|
+
this.buffers.set(message.topic, buffer);
|
|
1917
|
+
}
|
|
1918
|
+
buffer.push(message);
|
|
1919
|
+
const ageBounded = this.pruneStrategy !== PRUNE_STRATEGY.COUNT && this.retentionMs !== void 0;
|
|
1920
|
+
if (this.pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) {
|
|
1921
|
+
while (buffer.length > this.maxPerTopic) buffer.shift();
|
|
1922
|
+
}
|
|
1923
|
+
if (ageBounded) {
|
|
1924
|
+
const cutoff = this.now() - this.retentionMs;
|
|
1925
|
+
while (buffer.length > 0) {
|
|
1926
|
+
const first = buffer[0];
|
|
1927
|
+
if (!first || first.timestamp === void 0 || first.timestamp >= cutoff) break;
|
|
1928
|
+
buffer.shift();
|
|
1514
1929
|
}
|
|
1515
1930
|
}
|
|
1516
|
-
this.
|
|
1517
|
-
this.
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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.");
|
|
1931
|
+
if (!this.persistence) return;
|
|
1932
|
+
if (this.persistence.appendBatch) {
|
|
1933
|
+
this.pendingReplayPersistence.push(message);
|
|
1934
|
+
this.schedulePersistenceFlush();
|
|
1935
|
+
} else {
|
|
1936
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.append(message)).catch((error) => this.onPersistenceError(error));
|
|
1524
1937
|
}
|
|
1525
|
-
this.
|
|
1526
|
-
|
|
1527
|
-
throw new TypeError("replay.retentionSweepMs must be a positive finite number.");
|
|
1938
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
1939
|
+
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
1528
1940
|
}
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1941
|
+
}
|
|
1942
|
+
/** Deliver buffered history to a newly-registered handler. For an exact
|
|
1943
|
+
* topic this is that topic's ring; for a wildcard subscription every
|
|
1944
|
+
* buffered topic matching the pattern contributes (in buffer insertion
|
|
1945
|
+
* order). Replay deliveries are marked `replayed: true` and are not counted
|
|
1946
|
+
* into trace metrics.
|
|
1947
|
+
*
|
|
1948
|
+
* When a durable persistence backend is present, delivery waits for the
|
|
1949
|
+
* hydration load to settle first; `isHandlerActive` is then consulted so a
|
|
1950
|
+
* handler that unsubscribed during the async load does not receive history.
|
|
1951
|
+
*/
|
|
1952
|
+
deliverReplay(topic, replayOption, handler, isHandlerActive) {
|
|
1953
|
+
if (!this.buffers) return;
|
|
1954
|
+
const limit = typeof replayOption === "number" ? Math.min(Math.floor(replayOption), this.maxPerTopic) : this.maxPerTopic;
|
|
1955
|
+
if (this.persistence) {
|
|
1956
|
+
void this.hydration.then(() => {
|
|
1957
|
+
if (isHandlerActive?.() ?? true) this.deliver(topic, limit, handler);
|
|
1958
|
+
});
|
|
1959
|
+
return;
|
|
1533
1960
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1961
|
+
this.deliver(topic, limit, handler);
|
|
1962
|
+
}
|
|
1963
|
+
/** Clean up a topic that lost its last local handler: drop the ring buffer,
|
|
1964
|
+
* filter queued batch flushes (so an in-flight append cannot undo the
|
|
1965
|
+
* clearTopic), and prune durable history. */
|
|
1966
|
+
onTopicUnsubscribed(topic) {
|
|
1967
|
+
if (!this.buffers) return;
|
|
1968
|
+
this.buffers.delete(topic);
|
|
1969
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
1970
|
+
if (this.persistence?.clearTopic) {
|
|
1971
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic)).catch((error) => this.onPersistenceError(error));
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
/** Clear all in-memory replay buffers and, when supported, durable history.
|
|
1975
|
+
* Reports persistence failures and rethrows, mirroring the public API
|
|
1976
|
+
* contract that callers can observe a failed clear. */
|
|
1977
|
+
async clearAll() {
|
|
1978
|
+
if (!this.buffers) return;
|
|
1979
|
+
this.buffers.clear();
|
|
1980
|
+
this.pendingReplayPersistence = [];
|
|
1981
|
+
if (this.persistence?.clear) {
|
|
1982
|
+
try {
|
|
1983
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR, () => this.persistence.clear());
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
this.onPersistenceError(error);
|
|
1986
|
+
throw error;
|
|
1987
|
+
}
|
|
1536
1988
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
if (!
|
|
1541
|
-
|
|
1989
|
+
}
|
|
1990
|
+
/** Clear replay history for one exact topic, including durable storage. */
|
|
1991
|
+
async clearTopic(topic) {
|
|
1992
|
+
if (!this.buffers) return;
|
|
1993
|
+
this.buffers.delete(topic);
|
|
1994
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter((message) => message.topic !== topic);
|
|
1995
|
+
if (this.persistence?.clearTopic) {
|
|
1996
|
+
try {
|
|
1997
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_TOPIC, () => this.persistence.clearTopic(topic));
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
this.onPersistenceError(error);
|
|
2000
|
+
throw error;
|
|
2001
|
+
}
|
|
1542
2002
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
2003
|
+
}
|
|
2004
|
+
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
2005
|
+
async clearBefore(timestamp) {
|
|
2006
|
+
if (!Number.isFinite(timestamp)) throw new TypeError("timestamp must be finite.");
|
|
2007
|
+
if (this.buffers) {
|
|
2008
|
+
for (const [topic, messages] of this.buffers) {
|
|
2009
|
+
const kept = messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
2010
|
+
if (kept.length) this.buffers.set(topic, kept);
|
|
2011
|
+
else this.buffers.delete(topic);
|
|
2012
|
+
}
|
|
1545
2013
|
}
|
|
1546
|
-
this.
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
this.
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
this.dedupWindowStartedAt = this.now();
|
|
1557
|
-
this.dedupSweepMs = dedup?.sweepMs;
|
|
1558
|
-
this.dedupEnabled = dedup !== void 0;
|
|
1559
|
-
if (!Number.isSafeInteger(this.dedupMaxEntries) || this.dedupMaxEntries <= 0) {
|
|
1560
|
-
throw new TypeError("dedup.maxEntries must be a positive safe integer.");
|
|
1561
|
-
}
|
|
1562
|
-
if (!Number.isFinite(this.dedupTtlMs) || this.dedupTtlMs <= 0) {
|
|
1563
|
-
throw new TypeError("dedup.ttlMs must be a positive finite number.");
|
|
1564
|
-
}
|
|
1565
|
-
if (this.dedupSweepMs !== void 0 && (!Number.isFinite(this.dedupSweepMs) || this.dedupSweepMs <= 0)) {
|
|
1566
|
-
throw new TypeError("dedup.sweepMs must be a positive finite number.");
|
|
2014
|
+
this.pendingReplayPersistence = this.pendingReplayPersistence.filter(
|
|
2015
|
+
(message) => message.timestamp === void 0 || message.timestamp >= timestamp
|
|
2016
|
+
);
|
|
2017
|
+
if (this.persistence?.clearBefore) {
|
|
2018
|
+
try {
|
|
2019
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(timestamp));
|
|
2020
|
+
} catch (error) {
|
|
2021
|
+
this.onPersistenceError(error);
|
|
2022
|
+
throw error;
|
|
2023
|
+
}
|
|
1567
2024
|
}
|
|
1568
|
-
|
|
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
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
2025
|
+
}
|
|
2026
|
+
/** Start the periodic retention sweep. No-op when no durable retention
|
|
2027
|
+
* config makes it necessary. */
|
|
2028
|
+
start() {
|
|
2029
|
+
if (this.retentionTimer || !this.retentionMs || !this.retentionSweepMs || !this.persistence?.clearBefore) return;
|
|
2030
|
+
this.retentionTimer = setInterval(() => {
|
|
2031
|
+
this.scheduleRetentionCleanup(this.now() - this.retentionMs);
|
|
2032
|
+
}, this.retentionSweepMs);
|
|
2033
|
+
}
|
|
2034
|
+
/** Stop the periodic retention sweep. */
|
|
2035
|
+
stop() {
|
|
2036
|
+
if (this.retentionTimer) clearInterval(this.retentionTimer);
|
|
2037
|
+
this.retentionTimer = null;
|
|
2038
|
+
}
|
|
2039
|
+
/** Suspend the manager: cancel in-flight persistence retries (so a hidden tab
|
|
2040
|
+
* or stopped bus does not keep hammering the store) and stop the sweep. */
|
|
2041
|
+
suspend() {
|
|
2042
|
+
this.retryGeneration += 1;
|
|
2043
|
+
this.stop();
|
|
2044
|
+
}
|
|
2045
|
+
/** Drop all in-memory buffers (used on full teardown). */
|
|
2046
|
+
resetBuffers() {
|
|
2047
|
+
this.buffers?.clear();
|
|
2048
|
+
}
|
|
2049
|
+
/** Buffer occupancy for diagnostics. `bytes` is an approximate in-memory
|
|
2050
|
+
* payload footprint (same heuristic as adaptive load weighting), computed on
|
|
2051
|
+
* demand so the hot append path never pays for it. */
|
|
2052
|
+
getStats() {
|
|
2053
|
+
let messages = 0;
|
|
2054
|
+
let bytes = 0;
|
|
2055
|
+
if (this.buffers) {
|
|
2056
|
+
for (const buffer of this.buffers.values()) {
|
|
2057
|
+
messages += buffer.length;
|
|
2058
|
+
for (const message of buffer) bytes += approximatePayloadBytes(message.data);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
return { enabled: this.enabled, topics: this.buffers?.size ?? 0, messages, bytes };
|
|
2062
|
+
}
|
|
2063
|
+
/** Deliver history from one topic's ring to a handler, isolating a throwing
|
|
2064
|
+
* handler so the remaining buffers are still delivered. */
|
|
2065
|
+
deliver(topic, limit, handler) {
|
|
2066
|
+
if (!this.buffers || limit <= 0) return;
|
|
2067
|
+
const deliverBuffer = (buffer2) => {
|
|
2068
|
+
for (const message of buffer2.slice(-limit)) {
|
|
2069
|
+
try {
|
|
2070
|
+
handler({ ...message, replayed: true });
|
|
2071
|
+
} catch (error) {
|
|
2072
|
+
this.onDispatchError(error);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
if (isWildcardTopic(topic)) {
|
|
2077
|
+
for (const [bufferedTopic, buffer2] of this.buffers) {
|
|
2078
|
+
if (topicMatchesPattern(topic, bufferedTopic)) deliverBuffer(buffer2);
|
|
2079
|
+
}
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
const buffer = this.buffers.get(topic);
|
|
2083
|
+
if (buffer) deliverBuffer(buffer);
|
|
2084
|
+
}
|
|
2085
|
+
/** Coalesce queued persistence appends into a single microtask batch so a
|
|
2086
|
+
* burst of publications does not issue one IndexedDB transaction each.
|
|
2087
|
+
* Only reachable when the backend advertises `appendBatch` (the sole queuer,
|
|
2088
|
+
* `record()`, guards on it), so the batched path is unconditional here. */
|
|
2089
|
+
schedulePersistenceFlush() {
|
|
2090
|
+
if (this.persistenceFlushScheduled) return;
|
|
2091
|
+
this.persistenceFlushScheduled = true;
|
|
2092
|
+
queueMicrotask(() => {
|
|
2093
|
+
this.persistenceFlushScheduled = false;
|
|
2094
|
+
const batch = this.pendingReplayPersistence.splice(0);
|
|
2095
|
+
if (batch.length === 0 || !this.persistence) return;
|
|
2096
|
+
void this.withPersistenceRetry(PERSISTENCE_OPERATION.APPEND, () => this.persistence.appendBatch(batch)).catch((error) => this.onPersistenceError(error));
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
/** Load durable history into the in-memory rings once at startup, pruning
|
|
2100
|
+
* entries past the retention window first. Failures are reported but do not
|
|
2101
|
+
* block startup — the bus runs with whatever survived. */
|
|
2102
|
+
async hydrate() {
|
|
2103
|
+
if (!this.buffers || !this.persistence) {
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
try {
|
|
2107
|
+
if (this.retentionMs !== void 0 && this.persistence.clearBefore) {
|
|
2108
|
+
await this.withPersistenceRetry(PERSISTENCE_OPERATION.CLEAR_BEFORE, () => this.persistence.clearBefore(this.now() - this.retentionMs));
|
|
2109
|
+
}
|
|
2110
|
+
for (const message of await this.withPersistenceRetry(PERSISTENCE_OPERATION.LOAD, () => this.persistence.load())) {
|
|
2111
|
+
let buffer = this.buffers.get(message.topic);
|
|
2112
|
+
if (!buffer) {
|
|
2113
|
+
buffer = [];
|
|
2114
|
+
this.buffers.set(message.topic, buffer);
|
|
2115
|
+
}
|
|
2116
|
+
buffer.push(message);
|
|
2117
|
+
if (buffer.length > this.maxPerTopic) buffer.shift();
|
|
2118
|
+
}
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
this.onPersistenceError(error);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
/** Coalesce retention cleanup: the newest cutoff wins while one pass runs,
|
|
2124
|
+
* so a burst of publications issues at most one clearBefore transaction. */
|
|
2125
|
+
scheduleRetentionCleanup(cutoff) {
|
|
2126
|
+
if (!this.persistence?.clearBefore) return;
|
|
2127
|
+
if (this.retentionCutoff === null || cutoff > this.retentionCutoff) {
|
|
2128
|
+
this.retentionCutoff = cutoff;
|
|
2129
|
+
}
|
|
2130
|
+
if (this.retentionCleanup) return;
|
|
2131
|
+
this.retentionCleanup = (async () => {
|
|
2132
|
+
while (this.retentionCutoff !== null) {
|
|
2133
|
+
const nextCutoff = this.retentionCutoff;
|
|
2134
|
+
this.retentionCutoff = null;
|
|
2135
|
+
try {
|
|
2136
|
+
await this.persistence.clearBefore(nextCutoff);
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
this.onPersistenceError(error);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
})().finally(() => {
|
|
2142
|
+
this.retentionCleanup = null;
|
|
2143
|
+
if (this.retentionCutoff !== null) {
|
|
2144
|
+
this.scheduleRetentionCleanup(this.retentionCutoff);
|
|
2145
|
+
}
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
/** Run a persistence operation with exponential backoff on transient failure.
|
|
2149
|
+
* Bumped `retryGeneration` (suspend/stop) cancels the loop early; a
|
|
2150
|
+
* structurally failing operation throws after `persistenceRetryMaxAttempts`,
|
|
2151
|
+
* leaving the ring buffer intact so the bus keeps working. */
|
|
2152
|
+
async withPersistenceRetry(persistenceOperation, operation) {
|
|
2153
|
+
const generation = this.retryGeneration;
|
|
2154
|
+
let attempt = 0;
|
|
2155
|
+
let delay = this.persistenceRetryBackoffMs;
|
|
2156
|
+
while (true) {
|
|
2157
|
+
attempt += 1;
|
|
2158
|
+
try {
|
|
2159
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2160
|
+
return await operation();
|
|
2161
|
+
} catch (error) {
|
|
2162
|
+
if (error instanceof PersistenceRetryCancelledError || generation !== this.retryGeneration) {
|
|
2163
|
+
throw new PersistenceRetryCancelledError();
|
|
2164
|
+
}
|
|
2165
|
+
if (attempt >= this.persistenceRetryMaxAttempts) throw error;
|
|
2166
|
+
this.trace.event({
|
|
2167
|
+
type: TRACE_EVENT_TYPE.RELIABILITY,
|
|
2168
|
+
operation: RELIABILITY_OPERATION.PERSISTENCE_RETRY,
|
|
2169
|
+
persistenceOperation,
|
|
2170
|
+
attempt
|
|
2171
|
+
});
|
|
2172
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2173
|
+
if (generation !== this.retryGeneration) throw new PersistenceRetryCancelledError();
|
|
2174
|
+
delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS2);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
// src/core/dedup-manager.ts
|
|
2181
|
+
var ADAPTIVE_WINDOW_MS = 5e3;
|
|
2182
|
+
var QUIET_RATE_PER_MS = 0.01;
|
|
2183
|
+
var DedupManager = class {
|
|
2184
|
+
enabled;
|
|
2185
|
+
maxEntries;
|
|
2186
|
+
ttlMs;
|
|
2187
|
+
adaptiveBounds;
|
|
2188
|
+
sweepMs;
|
|
2189
|
+
now;
|
|
2190
|
+
trace;
|
|
2191
|
+
seenMessageIds = /* @__PURE__ */ new Map();
|
|
2192
|
+
sweepTimer = null;
|
|
2193
|
+
windowStartedAt = 0;
|
|
2194
|
+
windowAccepted = 0;
|
|
2195
|
+
suppressed = 0;
|
|
2196
|
+
accepted = 0;
|
|
2197
|
+
constructor(options) {
|
|
2198
|
+
this.enabled = options.enabled;
|
|
2199
|
+
this.maxEntries = options.maxEntries;
|
|
2200
|
+
this.ttlMs = options.ttlMs;
|
|
2201
|
+
this.adaptiveBounds = options.adaptiveBounds;
|
|
2202
|
+
this.sweepMs = options.sweepMs;
|
|
2203
|
+
this.now = options.now;
|
|
2204
|
+
this.trace = options.trace;
|
|
2205
|
+
this.windowStartedAt = this.now();
|
|
2206
|
+
}
|
|
2207
|
+
/** True when a publication carrying `messageId` was already seen. Records the
|
|
2208
|
+
* ID and updates counters/trace on acceptance. Disabled or ID-less messages
|
|
2209
|
+
* always pass through. */
|
|
2210
|
+
isDuplicate(messageId, topic) {
|
|
2211
|
+
if (!this.enabled || !messageId) return false;
|
|
2212
|
+
const now = this.now();
|
|
2213
|
+
const ttlMs = this.currentTtl();
|
|
2214
|
+
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2215
|
+
if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
|
|
2216
|
+
}
|
|
2217
|
+
if (this.seenMessageIds.has(messageId)) {
|
|
2218
|
+
this.suppressed += 1;
|
|
2219
|
+
this.trace.event({
|
|
2220
|
+
type: TRACE_EVENT_TYPE.RELIABILITY,
|
|
2221
|
+
operation: RELIABILITY_OPERATION.DEDUP_SUPPRESSED,
|
|
2222
|
+
topic
|
|
2223
|
+
});
|
|
2224
|
+
this.trace.recordDedupSuppressed();
|
|
2225
|
+
return true;
|
|
2226
|
+
}
|
|
2227
|
+
this.seenMessageIds.set(messageId, now);
|
|
2228
|
+
this.accepted += 1;
|
|
2229
|
+
this.windowAccepted += 1;
|
|
2230
|
+
this.trace.recordDedupAccepted();
|
|
2231
|
+
while (this.seenMessageIds.size > this.maxEntries) {
|
|
2232
|
+
const oldest = this.seenMessageIds.keys().next().value;
|
|
2233
|
+
if (oldest === void 0) break;
|
|
2234
|
+
this.seenMessageIds.delete(oldest);
|
|
2235
|
+
}
|
|
2236
|
+
return false;
|
|
2237
|
+
}
|
|
2238
|
+
/** Start the periodic expiry sweep. No-op when disabled or no sweepMs was
|
|
2239
|
+
* configured (the hot-path opportunistic expiry still bounds the map). */
|
|
2240
|
+
start() {
|
|
2241
|
+
if (this.sweepTimer || !this.enabled || !this.sweepMs) return;
|
|
2242
|
+
this.sweepTimer = setInterval(() => this.pruneExpired(), this.sweepMs);
|
|
2243
|
+
}
|
|
2244
|
+
/** Stop the periodic expiry sweep. */
|
|
2245
|
+
stop() {
|
|
2246
|
+
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
2247
|
+
this.sweepTimer = null;
|
|
2248
|
+
}
|
|
2249
|
+
/** Return bounded deduplication counters for diagnostics and health checks. */
|
|
2250
|
+
getStats() {
|
|
2251
|
+
return {
|
|
2252
|
+
enabled: this.enabled,
|
|
2253
|
+
tracked: this.seenMessageIds.size,
|
|
2254
|
+
suppressed: this.suppressed,
|
|
2255
|
+
accepted: this.accepted,
|
|
2256
|
+
...this.adaptiveBounds ? { ttlMs: this.currentTtl() } : {}
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
/** Drop all remembered IDs and reset dedup counters. */
|
|
2260
|
+
reset() {
|
|
2261
|
+
this.seenMessageIds.clear();
|
|
2262
|
+
this.suppressed = 0;
|
|
2263
|
+
this.accepted = 0;
|
|
2264
|
+
this.windowStartedAt = this.now();
|
|
2265
|
+
this.windowAccepted = 0;
|
|
2266
|
+
}
|
|
2267
|
+
/** Remove IDs whose timestamp predates the effective TTL cutoff. */
|
|
2268
|
+
pruneExpired() {
|
|
2269
|
+
const cutoff = this.now() - this.currentTtl();
|
|
2270
|
+
for (const [id, timestamp] of this.seenMessageIds) {
|
|
2271
|
+
if (timestamp < cutoff) this.seenMessageIds.delete(id);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
/** Effective TTL: the fixed `ttlMs` unless adaptive bounds are configured, in
|
|
2275
|
+
* which case a higher recent message rate shortens the window (dedup only
|
|
2276
|
+
* needs to live long enough to bridge duplicate bursts). The window resets
|
|
2277
|
+
* every ADAPTIVE_WINDOW_MS. */
|
|
2278
|
+
currentTtl() {
|
|
2279
|
+
if (!this.adaptiveBounds) return this.ttlMs;
|
|
2280
|
+
const now = this.now();
|
|
2281
|
+
const elapsed = now - this.windowStartedAt;
|
|
2282
|
+
if (elapsed >= ADAPTIVE_WINDOW_MS) {
|
|
2283
|
+
this.windowStartedAt = now;
|
|
2284
|
+
this.windowAccepted = 0;
|
|
2285
|
+
return this.adaptiveBounds.maxMs;
|
|
2286
|
+
}
|
|
2287
|
+
const rate = this.windowAccepted / Math.max(1, elapsed);
|
|
2288
|
+
const factor = Math.min(1, rate / QUIET_RATE_PER_MS);
|
|
2289
|
+
return this.adaptiveBounds.maxMs - (this.adaptiveBounds.maxMs - this.adaptiveBounds.minMs) * factor;
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
|
|
2293
|
+
// src/core/version.ts
|
|
2294
|
+
var SDK_VERSION = true ? "0.20.85" : "";
|
|
2295
|
+
|
|
2296
|
+
// src/core/data-bus.ts
|
|
2297
|
+
var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
|
|
2298
|
+
var CrossTabDataBus = class {
|
|
2299
|
+
transport;
|
|
2300
|
+
cluster;
|
|
2301
|
+
// Map of topic → set of local subscribers.
|
|
2302
|
+
topicHandlers = /* @__PURE__ */ new Map();
|
|
2303
|
+
// Topics for which the transport has been asked to subscribe (used to avoid
|
|
2304
|
+
// duplicate subscribe calls during reconnection).
|
|
2305
|
+
transportSubscribedTopics = /* @__PURE__ */ new Set();
|
|
2306
|
+
statusHandlers = /* @__PURE__ */ new Set();
|
|
2307
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
2308
|
+
replayManager;
|
|
2309
|
+
initialConfig;
|
|
2310
|
+
hasInitialConfig;
|
|
2311
|
+
trace;
|
|
2312
|
+
dedupManager;
|
|
2313
|
+
now;
|
|
2314
|
+
activeConfig;
|
|
2315
|
+
status = WORKER_STATUS.DISCONNECTED;
|
|
2316
|
+
started = false;
|
|
2317
|
+
stopping = false;
|
|
2318
|
+
transportReady = false;
|
|
2319
|
+
// Last transport failure, retained so ready() can surface it to callers who
|
|
2320
|
+
// never awaited start() directly. Cleared on the next successful start.
|
|
2321
|
+
lastError = null;
|
|
2322
|
+
lastErrorAt = null;
|
|
2323
|
+
// Unified failure ledger for the health summary: the most recent failure of
|
|
2324
|
+
// any source (transport, persistence, dispatch) since the last explicit start.
|
|
2325
|
+
lastFailure = null;
|
|
2326
|
+
persistenceFailureCount = 0;
|
|
2327
|
+
persistenceLastFailureAt = null;
|
|
2328
|
+
persistenceLastErrorMessage = null;
|
|
2329
|
+
// Gate that serialises start/stop/suspend/resume — only one lifecycle
|
|
2330
|
+
// transition at a time. Resets to null once the operation settles.
|
|
2331
|
+
startPromise = null;
|
|
2332
|
+
// Timestamp of the last automatic transport recovery attempt.
|
|
2333
|
+
// Used to avoid a tight retry loop when the transport fails repeatedly.
|
|
2334
|
+
lastRecoveryAt = 0;
|
|
2335
|
+
// Monotonic attempt number within one runtime recovery sequence; reset once
|
|
2336
|
+
// a transport reopen succeeds so traces can correlate repeated failures.
|
|
2337
|
+
recoveryAttempt = 0;
|
|
2338
|
+
recoveryExhausted = false;
|
|
2339
|
+
/** Monotonic generation incremented on every successful transport open.
|
|
2340
|
+
* Stays in lockstep with `lastSuccessAt` so callers can detect that the
|
|
2341
|
+
* transport has been reopened even if the timestamp window is short. */
|
|
2342
|
+
recoveryGeneration = 0;
|
|
2343
|
+
/** Timestamp of the most recent successful transport open. Null until the
|
|
2344
|
+
* transport has reached the `ready` state at least once. */
|
|
2345
|
+
lastSuccessAt = null;
|
|
2346
|
+
// True while the tab is hidden so an in-flight transport start does not mark
|
|
2347
|
+
// the transport ready after suspendTransport() has stopped it.
|
|
2348
|
+
suspended = false;
|
|
2349
|
+
// Single gate for async transport.stop() cleanup, shared by failed opens and
|
|
2350
|
+
// page-hide suspension. Kept separate from startPromise so ready() still
|
|
2351
|
+
// surfaces a failure while later opens and automatic recovery wait for the
|
|
2352
|
+
// stop to settle.
|
|
2353
|
+
pendingStop = null;
|
|
2354
|
+
// Minimum interval in ms between automatic recovery attempts.
|
|
2355
|
+
recoveryCooldownMs;
|
|
2356
|
+
recoveryMaxAttempts;
|
|
2357
|
+
constructor(options) {
|
|
2358
|
+
const replay = options.replay;
|
|
2359
|
+
assertReplayOptions(replay);
|
|
2360
|
+
const { autoStart, initialConfig, trace, transport, dedup, recovery, ...clusterOptions } = options;
|
|
2361
|
+
assertRecoveryOptions(recovery);
|
|
2362
|
+
this.recoveryCooldownMs = recovery?.cooldownMs ?? 1e3;
|
|
2363
|
+
this.recoveryMaxAttempts = recovery?.maxAttempts ?? Number.POSITIVE_INFINITY;
|
|
2364
|
+
this.now = dedup?.now ?? Date.now;
|
|
2365
|
+
this.transport = transport;
|
|
2366
|
+
this.initialConfig = initialConfig;
|
|
2367
|
+
this.hasInitialConfig = "initialConfig" in options;
|
|
2368
|
+
this.trace = new DataBusTraceReporter(trace);
|
|
2369
|
+
this.replayManager = new ReplayManager({
|
|
2370
|
+
enabled: replay !== void 0,
|
|
2371
|
+
maxPerTopic: replay?.maxPerTopic ?? DEFAULT_REPLAY_MAX_PER_TOPIC,
|
|
2372
|
+
persistence: replay?.persistence ?? null,
|
|
2373
|
+
retentionMs: replay?.retentionMs,
|
|
2374
|
+
pruneStrategy: replay?.pruneStrategy ?? PRUNE_STRATEGY.COUNT,
|
|
2375
|
+
retentionSweepMs: replay?.retentionSweepMs,
|
|
2376
|
+
persistenceRetryMaxAttempts: replay?.persistenceRetry?.maxAttempts ?? 1,
|
|
2377
|
+
persistenceRetryBackoffMs: replay?.persistenceRetry?.backoffMs ?? 50,
|
|
2378
|
+
now: this.now,
|
|
2379
|
+
trace: this.trace,
|
|
2380
|
+
onPersistenceError: (error) => this.reportPersistenceError(error),
|
|
2381
|
+
onDispatchError: (error) => this.reportError(error, FAILURE_SOURCE.DISPATCH)
|
|
2382
|
+
});
|
|
2383
|
+
assertDedupOptions(dedup);
|
|
2384
|
+
this.dedupManager = new DedupManager({
|
|
2385
|
+
enabled: dedup !== void 0,
|
|
2386
|
+
maxEntries: dedup?.maxEntries ?? 1e3,
|
|
2387
|
+
ttlMs: dedup?.ttlMs ?? 6e4,
|
|
2388
|
+
adaptiveBounds: dedup?.adaptiveTtl,
|
|
2389
|
+
sweepMs: dedup?.sweepMs,
|
|
2390
|
+
now: this.now,
|
|
2391
|
+
trace: this.trace
|
|
2392
|
+
});
|
|
2393
|
+
this.cluster = new WorkerClusterRuntime({
|
|
2394
|
+
...clusterOptions,
|
|
2395
|
+
handlers: {
|
|
2396
|
+
// The cluster calls `onControl` when it receives a SUBSCRIBE/UNSUBSCRIBE/PUBLISH
|
|
2397
|
+
// control message — meaning the owning Worker has delegated the action to us.
|
|
2398
|
+
onControl: (action, topic, data, messageId, timestamp) => {
|
|
2399
|
+
switch (action) {
|
|
2400
|
+
case CONTROL_ACTION.SUBSCRIBE:
|
|
2401
|
+
if (this.subscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.SUBSCRIBE, topic);
|
|
2402
|
+
break;
|
|
2403
|
+
case CONTROL_ACTION.UNSUBSCRIBE:
|
|
2404
|
+
if (this.unsubscribeTransport(topic)) this.traceSubscription(SUBSCRIPTION_ACTION.UNSUBSCRIBE, topic);
|
|
2405
|
+
break;
|
|
2406
|
+
case CONTROL_ACTION.PUBLISH:
|
|
2407
|
+
this.runTransport(() => this.transport.publish(topic, data, publicationMetadata(messageId, timestamp)));
|
|
2408
|
+
break;
|
|
2409
|
+
default:
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
2412
|
+
},
|
|
2413
|
+
// Batched variant of the PUBLISH action (CONTROL frames carrying
|
|
2414
|
+
// multiple items, and the local publishBatch fast path). Uses the
|
|
2415
|
+
// transport's one-frame publishBatch when available, preserving
|
|
2416
|
+
// per-item metadata; otherwise falls back to per-item publishes.
|
|
2417
|
+
onPublishBatch: (topic, items) => {
|
|
2418
|
+
if (typeof this.transport.publishBatch === "function") {
|
|
2419
|
+
this.runTransport(() => this.transport.publishBatch(topic, items));
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
for (const item of items) {
|
|
2423
|
+
this.runTransport(() => this.transport.publish(
|
|
2424
|
+
topic,
|
|
2425
|
+
item.data,
|
|
2426
|
+
publicationMetadata(item.messageId, item.timestamp)
|
|
2427
|
+
));
|
|
2428
|
+
}
|
|
2429
|
+
},
|
|
2430
|
+
// The cluster calls `onEvent` when a publication broadcast arrives from
|
|
2431
|
+
// another tab. Dispatch locally if we have subscribers. The payload is
|
|
2432
|
+
// typed `unknown` at the cluster boundary (the cluster is transport-
|
|
2433
|
+
// agnostic); here we narrow it to DataBusMessage — the sender is our
|
|
2434
|
+
// own broadcastEvent call, which always posts a DataBusMessage.
|
|
2435
|
+
onEvent: (eventType, payload, _sourceWorkerId, originTabId) => {
|
|
2436
|
+
if (eventType !== PUBLICATION_EVENT) return;
|
|
2437
|
+
const incoming = payload;
|
|
2438
|
+
const message = incoming.originTabId !== void 0 ? incoming : originTabId !== void 0 ? { ...incoming, originTabId } : incoming;
|
|
2439
|
+
if (this.cluster.hasLocalSubscriber(message.topic)) this.dispatch(message);
|
|
2440
|
+
},
|
|
2441
|
+
onSuspend: () => {
|
|
2442
|
+
if (!this.stopping) this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.SUSPEND });
|
|
2443
|
+
this.trace.pause();
|
|
2444
|
+
this.replayManager.suspend();
|
|
2445
|
+
this.stopDedupSweep();
|
|
1612
2446
|
this.suspendTransport();
|
|
1613
2447
|
},
|
|
1614
2448
|
onResume: () => {
|
|
1615
|
-
this.trace.event({ type:
|
|
2449
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.RESUME });
|
|
1616
2450
|
this.trace.start();
|
|
1617
2451
|
this.startDedupSweep();
|
|
1618
|
-
this.
|
|
2452
|
+
this.replayManager.start();
|
|
1619
2453
|
this.resumeTransport();
|
|
1620
2454
|
},
|
|
1621
2455
|
onDiagnostic: (event) => {
|
|
1622
|
-
this.trace.event({ type:
|
|
2456
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, ...event });
|
|
1623
2457
|
}
|
|
1624
2458
|
}
|
|
1625
2459
|
});
|
|
@@ -1641,27 +2475,24 @@ var CrossTabDataBus = class {
|
|
|
1641
2475
|
this.suspended = false;
|
|
1642
2476
|
this.activeConfig = config;
|
|
1643
2477
|
this.lastError = null;
|
|
1644
|
-
this.
|
|
2478
|
+
this.lastFailure = null;
|
|
2479
|
+
this.persistenceFailureCount = 0;
|
|
2480
|
+
this.persistenceLastFailureAt = null;
|
|
2481
|
+
this.persistenceLastErrorMessage = null;
|
|
2482
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.START });
|
|
1645
2483
|
this.trace.start();
|
|
1646
2484
|
this.startDedupSweep();
|
|
1647
|
-
this.
|
|
1648
|
-
this.updateStatus(
|
|
2485
|
+
this.replayManager.start();
|
|
2486
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
1649
2487
|
this.cluster.start();
|
|
1650
2488
|
const opening = this.openTransport(config, this.pendingStop ?? Promise.resolve(), true);
|
|
1651
2489
|
this.startPromise = opening;
|
|
1652
2490
|
for (const topic of this.topicHandlers.keys()) {
|
|
1653
2491
|
this.cluster.subscribe(topic);
|
|
1654
2492
|
}
|
|
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
2493
|
void opening.then(
|
|
1664
2494
|
() => {
|
|
2495
|
+
this.emitCoordinationTrace();
|
|
1665
2496
|
if (this.startPromise === opening) this.startPromise = null;
|
|
1666
2497
|
},
|
|
1667
2498
|
() => {
|
|
@@ -1688,7 +2519,7 @@ var CrossTabDataBus = class {
|
|
|
1688
2519
|
onError: (error) => this.reportError(error)
|
|
1689
2520
|
})
|
|
1690
2521
|
).then(() => {
|
|
1691
|
-
if (this.status ===
|
|
2522
|
+
if (this.status === WORKER_STATUS.ERROR) {
|
|
1692
2523
|
throw new Error("Transport failed during startup.");
|
|
1693
2524
|
}
|
|
1694
2525
|
if (!this.suspended && !this.stopping) {
|
|
@@ -1702,7 +2533,7 @@ var CrossTabDataBus = class {
|
|
|
1702
2533
|
if (!this.pendingStop) {
|
|
1703
2534
|
this.pendingStop = this.createStopPromise();
|
|
1704
2535
|
}
|
|
1705
|
-
this.updateStatus(
|
|
2536
|
+
this.updateStatus(WORKER_STATUS.ERROR);
|
|
1706
2537
|
this.reportError(error);
|
|
1707
2538
|
this.lastError = error;
|
|
1708
2539
|
this.lastErrorAt = this.now();
|
|
@@ -1747,17 +2578,12 @@ var CrossTabDataBus = class {
|
|
|
1747
2578
|
this.topicHandlers.set(topic, handlers);
|
|
1748
2579
|
if (wasUnused) this.cluster.subscribe(topic);
|
|
1749
2580
|
if (options?.replay) {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2581
|
+
this.replayManager.deliverReplay(
|
|
2582
|
+
topic,
|
|
2583
|
+
options.replay,
|
|
2584
|
+
handler,
|
|
2585
|
+
() => Boolean(this.topicHandlers.get(topic)?.has(handler))
|
|
1753
2586
|
);
|
|
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
2587
|
}
|
|
1762
2588
|
return () => this.unsubscribe(topic, handler);
|
|
1763
2589
|
}
|
|
@@ -1772,70 +2598,28 @@ var CrossTabDataBus = class {
|
|
|
1772
2598
|
else handlers.clear();
|
|
1773
2599
|
if (handlers.size > 0) return;
|
|
1774
2600
|
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
|
-
}
|
|
2601
|
+
this.replayManager.onTopicUnsubscribed(topic);
|
|
1779
2602
|
this.cluster.unsubscribe(topic);
|
|
1780
2603
|
}
|
|
1781
2604
|
/** Clear all in-memory replay buffers and, when supported, durable history. */
|
|
1782
2605
|
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
|
-
}
|
|
2606
|
+
await this.replayManager.clearAll();
|
|
1792
2607
|
}
|
|
1793
2608
|
/** Clear replay history for one exact topic, including durable storage. */
|
|
1794
2609
|
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
|
-
}
|
|
2610
|
+
await this.replayManager.clearTopic(topic);
|
|
1804
2611
|
}
|
|
1805
2612
|
/** Remove replay entries older than an epoch-millisecond cutoff. */
|
|
1806
2613
|
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
|
-
}
|
|
2614
|
+
await this.replayManager.clearBefore(timestamp);
|
|
1823
2615
|
}
|
|
1824
2616
|
/** Return bounded deduplication counters for diagnostics and health checks. */
|
|
1825
2617
|
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
|
-
};
|
|
2618
|
+
return this.dedupManager.getStats();
|
|
1833
2619
|
}
|
|
1834
2620
|
/** Drop all remembered IDs and reset dedup counters. */
|
|
1835
2621
|
resetDedup() {
|
|
1836
|
-
this.
|
|
1837
|
-
this.dedupSuppressed = 0;
|
|
1838
|
-
this.dedupAccepted = 0;
|
|
2622
|
+
this.dedupManager.reset();
|
|
1839
2623
|
}
|
|
1840
2624
|
/** Publish a message to `topic`. The owning Worker delivers it to the transport. */
|
|
1841
2625
|
publish(topic, data, options) {
|
|
@@ -1863,8 +2647,7 @@ var CrossTabDataBus = class {
|
|
|
1863
2647
|
}
|
|
1864
2648
|
const mapped = items.map((item) => ({
|
|
1865
2649
|
data: item.data,
|
|
1866
|
-
...item.options?.messageId
|
|
1867
|
-
...item.options?.timestamp !== void 0 ? { timestamp: item.options.timestamp } : {}
|
|
2650
|
+
...publicationMetadata(item.options?.messageId, item.options?.timestamp)
|
|
1868
2651
|
}));
|
|
1869
2652
|
if (!this.cluster.publishBatch(topic, mapped)) {
|
|
1870
2653
|
this.reportError(
|
|
@@ -1909,6 +2692,41 @@ var CrossTabDataBus = class {
|
|
|
1909
2692
|
lastSuccessAt: this.lastSuccessAt
|
|
1910
2693
|
};
|
|
1911
2694
|
}
|
|
2695
|
+
/** Bounded failure counters for the replay persistence backend. */
|
|
2696
|
+
getPersistenceStats() {
|
|
2697
|
+
return {
|
|
2698
|
+
failures: this.persistenceFailureCount,
|
|
2699
|
+
lastFailureAt: this.persistenceLastFailureAt,
|
|
2700
|
+
lastErrorMessage: this.persistenceLastErrorMessage
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
/** Compact health verdict for dashboards, readiness probes, and support
|
|
2704
|
+
* bundles. Answers "is the bus usable right now" first, then attaches the
|
|
2705
|
+
* unified failure ledger and recovery context that explains the verdict. */
|
|
2706
|
+
getHealthSummary() {
|
|
2707
|
+
const transport = this.transport;
|
|
2708
|
+
const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
|
|
2709
|
+
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;
|
|
2710
|
+
return {
|
|
2711
|
+
healthy: state === HEALTH_STATE.HEALTHY,
|
|
2712
|
+
state,
|
|
2713
|
+
status: this.status,
|
|
2714
|
+
sdkVersion: SDK_VERSION,
|
|
2715
|
+
started: this.started,
|
|
2716
|
+
suspended: this.suspended,
|
|
2717
|
+
transport: {
|
|
2718
|
+
name: transport.diagnosticsName ?? transport.constructor.name,
|
|
2719
|
+
backend: transport.diagnosticsBackend ?? null,
|
|
2720
|
+
ready: this.transportReady,
|
|
2721
|
+
status: this.status
|
|
2722
|
+
},
|
|
2723
|
+
recovery: this.getRecoveryStats(),
|
|
2724
|
+
lastFailure: this.lastFailure,
|
|
2725
|
+
persistence: this.getPersistenceStats(),
|
|
2726
|
+
metrics: this.trace.getMetrics(),
|
|
2727
|
+
trace: this.trace.getSinkState()
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
1912
2730
|
/** Snapshot of the cluster state (workers, routes, assignments).
|
|
1913
2731
|
* For diagnostics only — the returned object is a shallow copy but
|
|
1914
2732
|
* nested arrays are snapshots at call time. */
|
|
@@ -1917,8 +2735,7 @@ var CrossTabDataBus = class {
|
|
|
1917
2735
|
}
|
|
1918
2736
|
/** Return a single health snapshot combining lifecycle, recovery, dedup, replay, and cluster state. */
|
|
1919
2737
|
getDiagnostics() {
|
|
1920
|
-
|
|
1921
|
-
if (this.replayBuffers) for (const buffer of this.replayBuffers.values()) messages += buffer.length;
|
|
2738
|
+
const replay = this.replayManager.getStats();
|
|
1922
2739
|
const cluster = this.cluster.getSnapshot();
|
|
1923
2740
|
const unknownMessages = this.cluster.getUnknownMessageStats();
|
|
1924
2741
|
const transport = this.transport;
|
|
@@ -1929,12 +2746,26 @@ var CrossTabDataBus = class {
|
|
|
1929
2746
|
transportReady: this.transportReady,
|
|
1930
2747
|
recovery: this.getRecoveryStats(),
|
|
1931
2748
|
dedup: this.getDedupStats(),
|
|
1932
|
-
replay: { enabled:
|
|
2749
|
+
replay: { enabled: replay.enabled, topics: replay.topics, messages: replay.messages, bytes: replay.bytes },
|
|
2750
|
+
persistence: this.getPersistenceStats(),
|
|
1933
2751
|
protocol: { version: cluster.protocolVersion, unknownMessages: unknownMessages.count, lastUnknownMessageType: unknownMessages.lastType, peers: cluster.peerProtocolVersions },
|
|
1934
|
-
transport: {
|
|
1935
|
-
|
|
2752
|
+
transport: {
|
|
2753
|
+
name: transport.diagnosticsName ?? transport.constructor.name,
|
|
2754
|
+
backend: transport.diagnosticsBackend ?? null,
|
|
2755
|
+
status: this.status,
|
|
2756
|
+
suspended: this.suspended
|
|
2757
|
+
},
|
|
2758
|
+
cluster,
|
|
2759
|
+
metrics: this.trace.getMetrics(),
|
|
2760
|
+
trace: this.trace.getSinkState()
|
|
1936
2761
|
};
|
|
1937
2762
|
}
|
|
2763
|
+
/** Synchronous snapshot of the current trace metrics window (throughput,
|
|
2764
|
+
* dispatch latency, dedup outcomes), without flushing or resetting it.
|
|
2765
|
+
* Returns null when trace metrics are inactive (disabled or events-only). */
|
|
2766
|
+
getMetrics() {
|
|
2767
|
+
return this.trace.getMetrics();
|
|
2768
|
+
}
|
|
1938
2769
|
/**
|
|
1939
2770
|
* Gracefully stop the DataBus: unsubscribe all topics, stop the cluster,
|
|
1940
2771
|
* and close the transport. Idempotent.
|
|
@@ -1942,13 +2773,12 @@ var CrossTabDataBus = class {
|
|
|
1942
2773
|
async stop() {
|
|
1943
2774
|
if (!this.started) return;
|
|
1944
2775
|
this.stopping = true;
|
|
1945
|
-
this.
|
|
1946
|
-
this.trace.event({ type:
|
|
2776
|
+
this.replayManager.suspend();
|
|
2777
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
|
|
1947
2778
|
this.trace.stop();
|
|
1948
2779
|
this.stopDedupSweep();
|
|
1949
|
-
this.stopReplayRetentionSweep();
|
|
1950
2780
|
this.topicHandlers.clear();
|
|
1951
|
-
this.
|
|
2781
|
+
this.replayManager.resetBuffers();
|
|
1952
2782
|
this.cluster.stop();
|
|
1953
2783
|
try {
|
|
1954
2784
|
await this.startPromise?.catch(() => void 0);
|
|
@@ -1969,7 +2799,7 @@ var CrossTabDataBus = class {
|
|
|
1969
2799
|
this.activeConfig = void 0;
|
|
1970
2800
|
this.recoveryAttempt = 0;
|
|
1971
2801
|
this.recoveryExhausted = false;
|
|
1972
|
-
this.updateStatus(
|
|
2802
|
+
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
1973
2803
|
}
|
|
1974
2804
|
}
|
|
1975
2805
|
/**
|
|
@@ -1978,7 +2808,7 @@ var CrossTabDataBus = class {
|
|
|
1978
2808
|
* and dispatches locally.
|
|
1979
2809
|
*/
|
|
1980
2810
|
handleTransportMessage(message) {
|
|
1981
|
-
if (this.isDuplicate(message)) return;
|
|
2811
|
+
if (this.dedupManager.isDuplicate(message.messageId ?? "", message.topic)) return;
|
|
1982
2812
|
this.trace.recordReceived(message.topic);
|
|
1983
2813
|
if (!this.cluster.isAssigned(message.topic)) {
|
|
1984
2814
|
this.trace.recordDiscarded(message.topic);
|
|
@@ -1992,55 +2822,13 @@ var CrossTabDataBus = class {
|
|
|
1992
2822
|
}
|
|
1993
2823
|
this.trace.recordDiscarded(message.topic);
|
|
1994
2824
|
}
|
|
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
|
-
}
|
|
2825
|
+
/** Start enqueuing the dedup expiry sweep (delegated to {@link DedupManager}). */
|
|
2018
2826
|
startDedupSweep() {
|
|
2019
|
-
|
|
2020
|
-
this.dedupSweepTimer = setInterval(() => this.pruneExpiredDedup(), this.dedupSweepMs);
|
|
2827
|
+
this.dedupManager.start();
|
|
2021
2828
|
}
|
|
2829
|
+
/** Stop enqueuing the dedup expiry sweep. */
|
|
2022
2830
|
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;
|
|
2831
|
+
this.dedupManager.stop();
|
|
2044
2832
|
}
|
|
2045
2833
|
/** Deliver a message to every local handler registered for its topic,
|
|
2046
2834
|
* plus every handler registered with a wildcard subscription that matches
|
|
@@ -2053,153 +2841,7 @@ var CrossTabDataBus = class {
|
|
|
2053
2841
|
this.invokeHandlers(handlers, (handler) => handler(message));
|
|
2054
2842
|
}
|
|
2055
2843
|
}
|
|
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);
|
|
2844
|
+
this.replayManager.record(message);
|
|
2203
2845
|
}
|
|
2204
2846
|
/**
|
|
2205
2847
|
* Propagate a status change to the cluster, trace, and all registered
|
|
@@ -2208,13 +2850,13 @@ var CrossTabDataBus = class {
|
|
|
2208
2850
|
updateStatus(status) {
|
|
2209
2851
|
const previousStatus = this.status;
|
|
2210
2852
|
this.status = status;
|
|
2211
|
-
if (previousStatus !== status) this.trace.event({ type:
|
|
2853
|
+
if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
|
|
2212
2854
|
this.cluster.setStatus(status);
|
|
2213
|
-
if (status ===
|
|
2214
|
-
if (status ===
|
|
2855
|
+
if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
|
|
2856
|
+
if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
|
|
2215
2857
|
for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
|
|
2216
2858
|
}
|
|
2217
|
-
if (status ===
|
|
2859
|
+
if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
|
|
2218
2860
|
const now = this.now();
|
|
2219
2861
|
if (now - this.lastRecoveryAt >= this.recoveryCooldownMs) {
|
|
2220
2862
|
this.lastRecoveryAt = now;
|
|
@@ -2222,37 +2864,67 @@ var CrossTabDataBus = class {
|
|
|
2222
2864
|
if (attempt > this.recoveryMaxAttempts) {
|
|
2223
2865
|
if (!this.recoveryExhausted) {
|
|
2224
2866
|
this.recoveryExhausted = true;
|
|
2225
|
-
this.trace.event({ type:
|
|
2867
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
|
|
2226
2868
|
}
|
|
2227
2869
|
return;
|
|
2228
2870
|
}
|
|
2229
|
-
this.trace.event({ type:
|
|
2871
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
|
|
2230
2872
|
setTimeout(() => {
|
|
2231
2873
|
if (this.stopping || !this.started || this.suspended) return;
|
|
2232
|
-
if (this.status !==
|
|
2874
|
+
if (this.status !== WORKER_STATUS.ERROR) return;
|
|
2233
2875
|
void this.reopenTransport(attempt);
|
|
2234
2876
|
}, this.recoveryCooldownMs);
|
|
2235
2877
|
}
|
|
2236
2878
|
}
|
|
2237
2879
|
this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
|
|
2238
2880
|
}
|
|
2239
|
-
reportError(error) {
|
|
2240
|
-
this.
|
|
2241
|
-
|
|
2881
|
+
reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
|
|
2882
|
+
this.lastFailure = {
|
|
2883
|
+
source,
|
|
2884
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2885
|
+
at: this.now()
|
|
2886
|
+
};
|
|
2887
|
+
if (source === FAILURE_SOURCE.PERSISTENCE) {
|
|
2888
|
+
this.persistenceFailureCount += 1;
|
|
2889
|
+
this.persistenceLastFailureAt = this.lastFailure.at;
|
|
2890
|
+
this.persistenceLastErrorMessage = this.lastFailure.message;
|
|
2891
|
+
}
|
|
2892
|
+
this.trace.event({
|
|
2893
|
+
type: TRACE_EVENT_TYPE.ERROR,
|
|
2894
|
+
source: source === FAILURE_SOURCE.TRANSPORT ? TRACE_ERROR_SOURCE.TRANSPORT : TRACE_ERROR_SOURCE.OPERATION
|
|
2895
|
+
});
|
|
2896
|
+
this.invokeHandlers(this.errorHandlers, (handler) => handler(error), INVOKE_LABEL.ERROR_HANDLER);
|
|
2242
2897
|
}
|
|
2898
|
+
/** Report a persistence failure to the trace and the unified failure ledger,
|
|
2899
|
+
* unless it is a {@link PersistenceRetryCancelledError} cancellation from a
|
|
2900
|
+
* lifecycle transition (teardown should stay quiet). */
|
|
2243
2901
|
reportPersistenceError(error) {
|
|
2244
2902
|
if (error instanceof PersistenceRetryCancelledError) return;
|
|
2245
|
-
this.trace.event({ type:
|
|
2246
|
-
this.reportError(error);
|
|
2903
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.PERSISTENCE_CLEANUP });
|
|
2904
|
+
this.reportError(error, FAILURE_SOURCE.PERSISTENCE);
|
|
2247
2905
|
}
|
|
2248
2906
|
traceSubscription(action, topic) {
|
|
2249
2907
|
this.trace.event({
|
|
2250
|
-
type:
|
|
2908
|
+
type: TRACE_EVENT_TYPE.SUBSCRIPTION,
|
|
2251
2909
|
action,
|
|
2252
2910
|
topic,
|
|
2253
2911
|
activeTopics: this.transportSubscribedTopics.size
|
|
2254
2912
|
});
|
|
2255
2913
|
}
|
|
2914
|
+
/** Emit the coordination trace snapshot from the current cluster state.
|
|
2915
|
+
* Called after a transport opens (start and recovery), when the role and
|
|
2916
|
+
* route picture has settled — the synchronous pre-open snapshot would see no
|
|
2917
|
+
* routes because their writes are still coalesced in the batch writer. */
|
|
2918
|
+
emitCoordinationTrace() {
|
|
2919
|
+
const snapshot = this.cluster.getSnapshot();
|
|
2920
|
+
this.trace.event({
|
|
2921
|
+
type: TRACE_EVENT_TYPE.COORDINATION,
|
|
2922
|
+
coordinated: snapshot.coordinated,
|
|
2923
|
+
activeWorkers: snapshot.workers.filter((worker) => worker.role === WORKER_ROLE.ACTIVE).length,
|
|
2924
|
+
workers: snapshot.workers.map(formatWorkerTrace),
|
|
2925
|
+
routes: snapshot.routes.map(formatRouteTrace)
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2256
2928
|
/** Ask the transport to subscribe to a topic (idempotent). */
|
|
2257
2929
|
subscribeTransport(topic) {
|
|
2258
2930
|
if (this.transportSubscribedTopics.has(topic)) return false;
|
|
@@ -2270,17 +2942,17 @@ var CrossTabDataBus = class {
|
|
|
2270
2942
|
* are routed to `reportError` (which surfaces them to error subscribers);
|
|
2271
2943
|
* error-handler failures are logged to the console to avoid infinite
|
|
2272
2944
|
* recursion through reportError itself. */
|
|
2273
|
-
invokeHandlers(handlers, callback, label =
|
|
2945
|
+
invokeHandlers(handlers, callback, label = INVOKE_LABEL.DISPATCH) {
|
|
2274
2946
|
for (const handler of handlers) {
|
|
2275
2947
|
try {
|
|
2276
2948
|
callback(handler);
|
|
2277
2949
|
} catch (error) {
|
|
2278
|
-
if (label ===
|
|
2950
|
+
if (label === INVOKE_LABEL.ERROR_HANDLER) {
|
|
2279
2951
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
2280
|
-
console.warn(
|
|
2952
|
+
console.warn(`[${DEFAULT_STORAGE_PREFIX}] error handler threw:`, error);
|
|
2281
2953
|
}
|
|
2282
2954
|
} else {
|
|
2283
|
-
this.reportError(error);
|
|
2955
|
+
this.reportError(error, FAILURE_SOURCE.DISPATCH);
|
|
2284
2956
|
}
|
|
2285
2957
|
}
|
|
2286
2958
|
}
|
|
@@ -2294,7 +2966,7 @@ var CrossTabDataBus = class {
|
|
|
2294
2966
|
this.suspended = true;
|
|
2295
2967
|
this.transportReady = false;
|
|
2296
2968
|
this.transportSubscribedTopics.clear();
|
|
2297
|
-
this.updateStatus(
|
|
2969
|
+
this.updateStatus(WORKER_STATUS.DISCONNECTED);
|
|
2298
2970
|
if (this.pendingStop) return;
|
|
2299
2971
|
const pending = this.startPromise ?? Promise.resolve();
|
|
2300
2972
|
const stopping = pending.catch(() => void 0).then(() => this.transport.stop()).catch((error) => this.reportError(error));
|
|
@@ -2328,14 +3000,14 @@ var CrossTabDataBus = class {
|
|
|
2328
3000
|
const traceAttempt = recoveryAttempt ?? (this.recoveryAttempt > 0 ? this.recoveryAttempt : void 0);
|
|
2329
3001
|
this.started = true;
|
|
2330
3002
|
this.suspended = false;
|
|
2331
|
-
this.updateStatus(
|
|
3003
|
+
this.updateStatus(WORKER_STATUS.CONNECTING);
|
|
2332
3004
|
const pending = this.startPromise ?? this.pendingStop ?? Promise.resolve();
|
|
2333
3005
|
const opening = pending.catch(() => void 0).then(() => this.openTransport(config, Promise.resolve(), false));
|
|
2334
3006
|
this.startPromise = opening;
|
|
2335
3007
|
void opening.then(
|
|
2336
3008
|
() => {
|
|
2337
3009
|
if (traceAttempt !== void 0) {
|
|
2338
|
-
this.trace.event({ type:
|
|
3010
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.SUCCEEDED });
|
|
2339
3011
|
this.recoveryAttempt = 0;
|
|
2340
3012
|
this.recoveryExhausted = false;
|
|
2341
3013
|
}
|
|
@@ -2343,7 +3015,7 @@ var CrossTabDataBus = class {
|
|
|
2343
3015
|
},
|
|
2344
3016
|
() => {
|
|
2345
3017
|
if (traceAttempt !== void 0) {
|
|
2346
|
-
this.trace.event({ type:
|
|
3018
|
+
this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: traceAttempt, outcome: RECOVERY_OUTCOME.FAILED });
|
|
2347
3019
|
}
|
|
2348
3020
|
}
|
|
2349
3021
|
);
|
|
@@ -2401,16 +3073,14 @@ function formatRouteTrace(route) {
|
|
|
2401
3073
|
function createIndexedDbReplayPersistence(options) {
|
|
2402
3074
|
const indexedDb = globalThis.indexedDB;
|
|
2403
3075
|
if (!indexedDb) throw new Error("IndexedDB is unavailable in this environment.");
|
|
2404
|
-
const dbName = options.dbName ??
|
|
3076
|
+
const dbName = options.dbName ?? DEFAULT_STORAGE_PREFIX;
|
|
2405
3077
|
const storeName = "replay";
|
|
2406
3078
|
const maxPerTopic = options.maxPerTopic;
|
|
2407
|
-
const pruneStrategy = options.pruneStrategy ??
|
|
3079
|
+
const pruneStrategy = options.pruneStrategy ?? PRUNE_STRATEGY.COUNT;
|
|
2408
3080
|
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
|
-
}
|
|
3081
|
+
assertPruneStrategy(pruneStrategy);
|
|
3082
|
+
if (retentionMs !== void 0) assertPositiveFiniteNumber(retentionMs, "retentionMs");
|
|
3083
|
+
assertPositiveSafeInteger(maxPerTopic, "maxPerTopic");
|
|
2414
3084
|
let dbPromise = null;
|
|
2415
3085
|
const invalidate = (db) => {
|
|
2416
3086
|
if (dbPromise) {
|
|
@@ -2422,15 +3092,100 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
2422
3092
|
}, () => void 0);
|
|
2423
3093
|
}
|
|
2424
3094
|
};
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
3095
|
+
const pending = [];
|
|
3096
|
+
let draining = false;
|
|
3097
|
+
const drain = async () => {
|
|
3098
|
+
if (draining) return;
|
|
3099
|
+
draining = true;
|
|
3100
|
+
try {
|
|
3101
|
+
await Promise.resolve();
|
|
3102
|
+
while (pending.length > 0) {
|
|
3103
|
+
const head = pending[0].mutation;
|
|
3104
|
+
if (head.kind === "batch") {
|
|
3105
|
+
const merged = [];
|
|
3106
|
+
const entries = [];
|
|
3107
|
+
while (pending.length > 0) {
|
|
3108
|
+
const next = pending[0].mutation;
|
|
3109
|
+
if (next.kind !== "batch") break;
|
|
3110
|
+
merged.push(...next.messages);
|
|
3111
|
+
entries.push({ resolve: pending[0].resolve, reject: pending[0].reject });
|
|
3112
|
+
pending.shift();
|
|
3113
|
+
}
|
|
3114
|
+
try {
|
|
3115
|
+
await appendTransaction(merged);
|
|
3116
|
+
for (const entry of entries) entry.resolve();
|
|
3117
|
+
} catch (error) {
|
|
3118
|
+
for (const entry of entries) entry.reject(error);
|
|
3119
|
+
}
|
|
3120
|
+
} else {
|
|
3121
|
+
const entry = pending.shift();
|
|
3122
|
+
try {
|
|
3123
|
+
await head.run();
|
|
3124
|
+
entry.resolve();
|
|
3125
|
+
} catch (error) {
|
|
3126
|
+
entry.reject(error);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
} finally {
|
|
3131
|
+
draining = false;
|
|
3132
|
+
}
|
|
2430
3133
|
};
|
|
3134
|
+
const enqueue = (mutation) => new Promise((resolve, reject) => {
|
|
3135
|
+
pending.push({ mutation, resolve, reject });
|
|
3136
|
+
void drain();
|
|
3137
|
+
});
|
|
3138
|
+
const appendTransaction = (messages) => (async () => {
|
|
3139
|
+
const db = await open();
|
|
3140
|
+
return new Promise((resolve, reject) => {
|
|
3141
|
+
let transaction;
|
|
3142
|
+
try {
|
|
3143
|
+
transaction = db.transaction(storeName, "readwrite");
|
|
3144
|
+
} catch (error) {
|
|
3145
|
+
invalidate(db);
|
|
3146
|
+
reject(error);
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
const store = transaction.objectStore(storeName);
|
|
3150
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3151
|
+
for (const message of messages) {
|
|
3152
|
+
grouped.set(message.topic, [...grouped.get(message.topic) ?? [], message]);
|
|
3153
|
+
}
|
|
3154
|
+
let hasError = false;
|
|
3155
|
+
for (const [topic, topicMessages] of grouped) {
|
|
3156
|
+
const request = store.get(topic);
|
|
3157
|
+
request.onsuccess = () => {
|
|
3158
|
+
if (hasError) return;
|
|
3159
|
+
let history = (request.result?.messages ?? []).concat(topicMessages);
|
|
3160
|
+
const ageBounded = pruneStrategy !== PRUNE_STRATEGY.COUNT && retentionMs !== void 0;
|
|
3161
|
+
if (ageBounded) {
|
|
3162
|
+
const cutoff = Date.now() - retentionMs;
|
|
3163
|
+
history = history.filter((item) => item.timestamp === void 0 || item.timestamp >= cutoff);
|
|
3164
|
+
}
|
|
3165
|
+
if (pruneStrategy !== PRUNE_STRATEGY.AGE || !ageBounded) history = history.slice(-maxPerTopic);
|
|
3166
|
+
store.put({ topic, messages: history });
|
|
3167
|
+
};
|
|
3168
|
+
request.onerror = () => {
|
|
3169
|
+
if (hasError) return;
|
|
3170
|
+
hasError = true;
|
|
3171
|
+
invalidate(db);
|
|
3172
|
+
reject(request.error ?? new Error("Failed to read replay history."));
|
|
3173
|
+
};
|
|
3174
|
+
}
|
|
3175
|
+
transaction.oncomplete = () => {
|
|
3176
|
+
if (!hasError) resolve();
|
|
3177
|
+
};
|
|
3178
|
+
transaction.onerror = () => {
|
|
3179
|
+
if (hasError) return;
|
|
3180
|
+
hasError = true;
|
|
3181
|
+
invalidate(db);
|
|
3182
|
+
reject(transaction.error ?? new Error("Failed to persist replay history."));
|
|
3183
|
+
};
|
|
3184
|
+
});
|
|
3185
|
+
})();
|
|
2431
3186
|
const open = () => {
|
|
2432
3187
|
if (dbPromise) return dbPromise;
|
|
2433
|
-
const
|
|
3188
|
+
const pending2 = new Promise((resolve, reject) => {
|
|
2434
3189
|
const request = indexedDb.open(dbName, 1);
|
|
2435
3190
|
request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: "topic" });
|
|
2436
3191
|
request.onsuccess = () => {
|
|
@@ -2443,11 +3198,11 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
2443
3198
|
};
|
|
2444
3199
|
request.onerror = () => reject(request.error ?? new Error("Failed to open replay database."));
|
|
2445
3200
|
});
|
|
2446
|
-
dbPromise =
|
|
2447
|
-
void
|
|
2448
|
-
if (dbPromise ===
|
|
3201
|
+
dbPromise = pending2;
|
|
3202
|
+
void pending2.catch(() => {
|
|
3203
|
+
if (dbPromise === pending2) dbPromise = null;
|
|
2449
3204
|
});
|
|
2450
|
-
return
|
|
3205
|
+
return pending2;
|
|
2451
3206
|
};
|
|
2452
3207
|
return {
|
|
2453
3208
|
async load() {
|
|
@@ -2469,153 +3224,94 @@ function createIndexedDbReplayPersistence(options) {
|
|
|
2469
3224
|
});
|
|
2470
3225
|
},
|
|
2471
3226
|
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
|
-
});
|
|
3227
|
+
return enqueue({ kind: "batch", messages: [message] });
|
|
2505
3228
|
},
|
|
2506
3229
|
appendBatch(messages) {
|
|
2507
3230
|
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
|
-
});
|
|
3231
|
+
return enqueue({ kind: "batch", messages });
|
|
2545
3232
|
},
|
|
2546
3233
|
clear() {
|
|
2547
|
-
return
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
3234
|
+
return enqueue({
|
|
3235
|
+
kind: "run",
|
|
3236
|
+
run: () => (async () => {
|
|
3237
|
+
const db = await open();
|
|
3238
|
+
return new Promise((resolve, reject) => {
|
|
3239
|
+
let transaction;
|
|
3240
|
+
try {
|
|
3241
|
+
transaction = db.transaction(storeName, "readwrite");
|
|
3242
|
+
} catch (error) {
|
|
3243
|
+
invalidate(db);
|
|
3244
|
+
reject(error);
|
|
3245
|
+
return;
|
|
3246
|
+
}
|
|
3247
|
+
transaction.objectStore(storeName).clear();
|
|
3248
|
+
transaction.oncomplete = () => resolve();
|
|
3249
|
+
transaction.onerror = () => {
|
|
3250
|
+
invalidate(db);
|
|
3251
|
+
reject(transaction.error ?? new Error("Failed to clear replay history."));
|
|
3252
|
+
};
|
|
3253
|
+
});
|
|
3254
|
+
})()
|
|
2565
3255
|
});
|
|
2566
3256
|
},
|
|
2567
3257
|
clearTopic(topic) {
|
|
2568
|
-
return
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
3258
|
+
return enqueue({
|
|
3259
|
+
kind: "run",
|
|
3260
|
+
run: () => (async () => {
|
|
3261
|
+
const db = await open();
|
|
3262
|
+
return new Promise((resolve, reject) => {
|
|
3263
|
+
let transaction;
|
|
3264
|
+
try {
|
|
3265
|
+
transaction = db.transaction(storeName, "readwrite");
|
|
3266
|
+
} catch (error) {
|
|
3267
|
+
invalidate(db);
|
|
3268
|
+
reject(error);
|
|
3269
|
+
return;
|
|
3270
|
+
}
|
|
3271
|
+
transaction.objectStore(storeName).delete(topic);
|
|
3272
|
+
transaction.oncomplete = () => resolve();
|
|
3273
|
+
transaction.onerror = () => {
|
|
3274
|
+
invalidate(db);
|
|
3275
|
+
reject(transaction.error ?? new Error("Failed to clear topic replay history."));
|
|
3276
|
+
};
|
|
3277
|
+
});
|
|
3278
|
+
})()
|
|
2586
3279
|
});
|
|
2587
3280
|
},
|
|
2588
3281
|
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 });
|
|
3282
|
+
return enqueue({
|
|
3283
|
+
kind: "run",
|
|
3284
|
+
run: () => (async () => {
|
|
3285
|
+
const db = await open();
|
|
3286
|
+
return new Promise((resolve, reject) => {
|
|
3287
|
+
let transaction;
|
|
3288
|
+
try {
|
|
3289
|
+
transaction = db.transaction(storeName, "readwrite");
|
|
3290
|
+
} catch (error) {
|
|
3291
|
+
invalidate(db);
|
|
3292
|
+
reject(error);
|
|
3293
|
+
return;
|
|
2607
3294
|
}
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
3295
|
+
const store = transaction.objectStore(storeName);
|
|
3296
|
+
const request = store.getAll();
|
|
3297
|
+
request.onsuccess = () => {
|
|
3298
|
+
for (const record of request.result) {
|
|
3299
|
+
const messages = record.messages.filter((message) => message.timestamp === void 0 || message.timestamp >= timestamp);
|
|
3300
|
+
if (messages.length === 0) store.delete(record.topic);
|
|
3301
|
+
else if (messages.length !== record.messages.length) store.put({ topic: record.topic, messages });
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
request.onerror = () => {
|
|
3305
|
+
invalidate(db);
|
|
3306
|
+
reject(request.error ?? new Error("Failed to read replay history."));
|
|
3307
|
+
};
|
|
3308
|
+
transaction.oncomplete = () => resolve();
|
|
3309
|
+
transaction.onerror = () => {
|
|
3310
|
+
invalidate(db);
|
|
3311
|
+
reject(transaction.error ?? new Error("Failed to prune replay history."));
|
|
3312
|
+
};
|
|
3313
|
+
});
|
|
3314
|
+
})()
|
|
2619
3315
|
});
|
|
2620
3316
|
}
|
|
2621
3317
|
};
|
|
@@ -2649,6 +3345,7 @@ var WebSocketTransport = class {
|
|
|
2649
3345
|
constructor(connection) {
|
|
2650
3346
|
this.connection = connection;
|
|
2651
3347
|
}
|
|
3348
|
+
connection;
|
|
2652
3349
|
diagnosticsName = "websocket";
|
|
2653
3350
|
diagnosticsBackend = "native-websocket";
|
|
2654
3351
|
socket = null;
|
|
@@ -2665,22 +3362,22 @@ var WebSocketTransport = class {
|
|
|
2665
3362
|
try {
|
|
2666
3363
|
socket = factory(config.url, protocols);
|
|
2667
3364
|
} catch (error) {
|
|
2668
|
-
handlers.onStatus(
|
|
3365
|
+
handlers.onStatus(WORKER_STATUS.ERROR);
|
|
2669
3366
|
handlers.onError(error);
|
|
2670
3367
|
return;
|
|
2671
3368
|
}
|
|
2672
3369
|
socket.onopen = () => {
|
|
2673
3370
|
if (this.socket !== socket || this.handlers !== handlers) return;
|
|
2674
3371
|
for (const topic of this.subscribedTopics) {
|
|
2675
|
-
this.sendFrame({ op:
|
|
3372
|
+
this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
|
|
2676
3373
|
}
|
|
2677
|
-
handlers.onStatus(
|
|
3374
|
+
handlers.onStatus(WORKER_STATUS.CONNECTED);
|
|
2678
3375
|
};
|
|
2679
3376
|
socket.onclose = () => {
|
|
2680
|
-
if (this.socket === socket && this.handlers === handlers) handlers.onStatus(
|
|
3377
|
+
if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.DISCONNECTED);
|
|
2681
3378
|
};
|
|
2682
3379
|
socket.onerror = () => {
|
|
2683
|
-
if (this.socket === socket && this.handlers === handlers) handlers.onStatus(
|
|
3380
|
+
if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.ERROR);
|
|
2684
3381
|
};
|
|
2685
3382
|
socket.onmessage = (event) => {
|
|
2686
3383
|
if (this.socket === socket && this.handlers === handlers) void this.handleMessage(event.data);
|
|
@@ -2691,12 +3388,12 @@ var WebSocketTransport = class {
|
|
|
2691
3388
|
* not duplicate the local tracking entry. */
|
|
2692
3389
|
subscribe(topic) {
|
|
2693
3390
|
this.subscribedTopics.add(topic);
|
|
2694
|
-
this.sendFrame({ op:
|
|
3391
|
+
this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
|
|
2695
3392
|
}
|
|
2696
3393
|
/** Idempotent: unsubscribing an unknown topic is a no-op. */
|
|
2697
3394
|
unsubscribe(topic) {
|
|
2698
3395
|
this.subscribedTopics.delete(topic);
|
|
2699
|
-
this.sendFrame({ op:
|
|
3396
|
+
this.sendFrame({ op: WS_OP.UNSUBSCRIBE, topic });
|
|
2700
3397
|
}
|
|
2701
3398
|
/** Publish `data` to `topic` as a JSON frame. Requires an open socket. */
|
|
2702
3399
|
publish(topic, data, options) {
|
|
@@ -2705,13 +3402,39 @@ var WebSocketTransport = class {
|
|
|
2705
3402
|
return;
|
|
2706
3403
|
}
|
|
2707
3404
|
this.sendFrame({
|
|
2708
|
-
op:
|
|
3405
|
+
op: WS_OP.PUBLISH,
|
|
2709
3406
|
topic,
|
|
2710
3407
|
data,
|
|
2711
3408
|
...options?.messageId === void 0 ? {} : { messageId: options.messageId },
|
|
2712
3409
|
...options?.timestamp === void 0 ? {} : { timestamp: options.timestamp }
|
|
2713
3410
|
});
|
|
2714
3411
|
}
|
|
3412
|
+
/** Publish many items for one topic as a single wire frame. One-item
|
|
3413
|
+
* batches delegate to `publish` so the legacy single-publication frame
|
|
3414
|
+
* shape (including binary framing) is preserved. */
|
|
3415
|
+
publishBatch(topic, items) {
|
|
3416
|
+
if (items.length === 0) return;
|
|
3417
|
+
if (items.length === 1) {
|
|
3418
|
+
const single = items[0];
|
|
3419
|
+
return this.publish(topic, single.data, {
|
|
3420
|
+
...single.messageId === void 0 ? {} : { messageId: single.messageId },
|
|
3421
|
+
...single.timestamp === void 0 ? {} : { timestamp: single.timestamp }
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
3424
|
+
if (this.socket?.readyState !== WS_OPEN) {
|
|
3425
|
+
this.handlers?.onError(new Error('WebSocket is not open; dropped "publishBatch" frame.'));
|
|
3426
|
+
return;
|
|
3427
|
+
}
|
|
3428
|
+
this.socket.send(JSON.stringify({
|
|
3429
|
+
op: WS_OP.PUBLISH_BATCH,
|
|
3430
|
+
topic,
|
|
3431
|
+
items: items.map((item) => ({
|
|
3432
|
+
data: item.data instanceof ArrayBuffer ? Array.from(new Uint8Array(item.data)) : item.data,
|
|
3433
|
+
...item.messageId === void 0 ? {} : { messageId: item.messageId },
|
|
3434
|
+
...item.timestamp === void 0 ? {} : { timestamp: item.timestamp }
|
|
3435
|
+
}))
|
|
3436
|
+
}));
|
|
3437
|
+
}
|
|
2715
3438
|
/** Close the socket and drop all state. Safe to call multiple times. */
|
|
2716
3439
|
stop() {
|
|
2717
3440
|
const socket = this.socket;
|
|
@@ -2733,7 +3456,7 @@ var WebSocketTransport = class {
|
|
|
2733
3456
|
sendBinaryFrame(topic, data, messageId, timestamp) {
|
|
2734
3457
|
if (messageId !== void 0 || timestamp !== void 0) {
|
|
2735
3458
|
this.sendFrame({
|
|
2736
|
-
op:
|
|
3459
|
+
op: WS_OP.PUBLISH,
|
|
2737
3460
|
topic,
|
|
2738
3461
|
data: Array.from(new Uint8Array(data)),
|
|
2739
3462
|
...messageId === void 0 ? {} : { messageId },
|
|
@@ -2813,9 +3536,9 @@ function createWebSocketDataBus(options) {
|
|
|
2813
3536
|
function selectWorkerBackend(mode, availability = {}) {
|
|
2814
3537
|
const hasDedicated = availability.worker ?? typeof Worker !== "undefined";
|
|
2815
3538
|
const hasShared = availability.sharedWorker ?? typeof SharedWorker !== "undefined";
|
|
2816
|
-
if (mode ===
|
|
2817
|
-
return hasShared ?
|
|
3539
|
+
if (mode === WORKER_MODE.SHARED || mode === WORKER_MODE.AUTO) {
|
|
3540
|
+
return hasShared ? WORKER_BACKEND.SHARED : hasDedicated ? WORKER_BACKEND.DEDICATED : WORKER_BACKEND.LOCAL;
|
|
2818
3541
|
}
|
|
2819
|
-
return hasDedicated ?
|
|
3542
|
+
return hasDedicated ? WORKER_BACKEND.DEDICATED : hasShared ? WORKER_BACKEND.SHARED : WORKER_BACKEND.LOCAL;
|
|
2820
3543
|
}
|
|
2821
3544
|
//# sourceMappingURL=index.cjs.map
|