@peerbit/pubsub 4.1.4 → 5.0.0

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.
Files changed (46) hide show
  1. package/README.md +23 -20
  2. package/dist/benchmark/fanout-tree-sim-lib.d.ts +201 -0
  3. package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +1 -0
  4. package/dist/benchmark/fanout-tree-sim-lib.js +1225 -0
  5. package/dist/benchmark/fanout-tree-sim-lib.js.map +1 -0
  6. package/dist/benchmark/fanout-tree-sim.d.ts +11 -0
  7. package/dist/benchmark/fanout-tree-sim.d.ts.map +1 -0
  8. package/dist/benchmark/fanout-tree-sim.js +521 -0
  9. package/dist/benchmark/fanout-tree-sim.js.map +1 -0
  10. package/dist/benchmark/index.d.ts +6 -0
  11. package/dist/benchmark/index.d.ts.map +1 -1
  12. package/dist/benchmark/index.js +38 -80
  13. package/dist/benchmark/index.js.map +1 -1
  14. package/dist/benchmark/pubsub-topic-sim-lib.d.ts +82 -0
  15. package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +1 -0
  16. package/dist/benchmark/pubsub-topic-sim-lib.js +625 -0
  17. package/dist/benchmark/pubsub-topic-sim-lib.js.map +1 -0
  18. package/dist/benchmark/pubsub-topic-sim.d.ts +9 -0
  19. package/dist/benchmark/pubsub-topic-sim.d.ts.map +1 -0
  20. package/dist/benchmark/pubsub-topic-sim.js +116 -0
  21. package/dist/benchmark/pubsub-topic-sim.js.map +1 -0
  22. package/dist/benchmark/sim/bench-utils.d.ts +25 -0
  23. package/dist/benchmark/sim/bench-utils.d.ts.map +1 -0
  24. package/dist/benchmark/sim/bench-utils.js +141 -0
  25. package/dist/benchmark/sim/bench-utils.js.map +1 -0
  26. package/dist/src/fanout-channel.d.ts +62 -0
  27. package/dist/src/fanout-channel.d.ts.map +1 -0
  28. package/dist/src/fanout-channel.js +114 -0
  29. package/dist/src/fanout-channel.js.map +1 -0
  30. package/dist/src/fanout-tree.d.ts +551 -0
  31. package/dist/src/fanout-tree.d.ts.map +1 -0
  32. package/dist/src/fanout-tree.js +4980 -0
  33. package/dist/src/fanout-tree.js.map +1 -0
  34. package/dist/src/index.d.ts +168 -39
  35. package/dist/src/index.d.ts.map +1 -1
  36. package/dist/src/index.js +1416 -457
  37. package/dist/src/index.js.map +1 -1
  38. package/dist/src/topic-root-control-plane.d.ts +43 -0
  39. package/dist/src/topic-root-control-plane.d.ts.map +1 -0
  40. package/dist/src/topic-root-control-plane.js +120 -0
  41. package/dist/src/topic-root-control-plane.js.map +1 -0
  42. package/package.json +8 -7
  43. package/src/fanout-channel.ts +150 -0
  44. package/src/fanout-tree.ts +6346 -0
  45. package/src/index.ts +1693 -591
  46. package/src/topic-root-control-plane.ts +160 -0
@@ -0,0 +1,625 @@
1
+ import { PreHash, SignatureWithKey } from "@peerbit/crypto";
2
+ import { AcknowledgeAnyWhere } from "@peerbit/stream-interface";
3
+ import { delay } from "@peerbit/time";
4
+ import { FanoutTree, TopicControlPlane, TopicRootControlPlane, } from "../src/index.js";
5
+ import { InMemoryConnectionManager, InMemoryNetwork, } from "@peerbit/libp2p-test-utils/inmemory-libp2p.js";
6
+ import { BENCH_ID_PREFIX, buildRandomGraph, int, isBenchId, mulberry32, quantile, readU32BE, runWithConcurrency, waitForProtocolStreams, writeU32BE, } from "./sim/bench-utils.js";
7
+ const pickDistinct = (rng, n, k, exclude) => {
8
+ const max = Math.max(0, Math.min(n - 1, Math.floor(k)));
9
+ if (max === 0)
10
+ return [];
11
+ if (max >= n - 1) {
12
+ const out = [];
13
+ for (let i = 0; i < n; i++)
14
+ if (i !== exclude)
15
+ out.push(i);
16
+ return out;
17
+ }
18
+ const pool = [];
19
+ for (let i = 0; i < n; i++) {
20
+ if (i === exclude)
21
+ continue;
22
+ pool.push(i);
23
+ }
24
+ // Partial Fisher–Yates shuffle (only first k positions).
25
+ for (let i = 0; i < max; i++) {
26
+ const j = i + int(rng, pool.length - i);
27
+ const tmp = pool[i];
28
+ pool[i] = pool[j];
29
+ pool[j] = tmp;
30
+ }
31
+ pool.length = max;
32
+ return pool;
33
+ };
34
+ class SimFanoutTree extends FanoutTree {
35
+ constructor(c, opts) {
36
+ super(c, opts);
37
+ // Fast/mock signing: keep signer identity semantics but skip crypto work.
38
+ this.sign = async () => new SignatureWithKey({
39
+ signature: new Uint8Array([0]),
40
+ publicKey: this.publicKey,
41
+ prehash: PreHash.NONE,
42
+ });
43
+ }
44
+ async verifyAndProcess(message) {
45
+ // Skip expensive crypto verify for large sims, but keep session handling behavior
46
+ // consistent with the real implementation.
47
+ const from = message.header.signatures.publicKeys[0];
48
+ if (!this.peers.has(from.hashcode())) {
49
+ this.updateSession(from, Number(message.header.session));
50
+ }
51
+ return true;
52
+ }
53
+ }
54
+ class SimTopicControlPlane extends TopicControlPlane {
55
+ recordModeToLen;
56
+ constructor(c, opts, recordModeToLen, extra) {
57
+ if (!extra?.fanout) {
58
+ throw new Error("SimTopicControlPlane requires a FanoutTree instance");
59
+ }
60
+ super(c, {
61
+ canRelayMessage: true,
62
+ subscriptionDebounceDelay: opts.subscriptionDebounceDelayMs,
63
+ connectionManager: {
64
+ dialer: opts.dialer ? { retryDelay: 1_000 } : false,
65
+ pruner: opts.pruner
66
+ ? {
67
+ interval: opts.prunerIntervalMs,
68
+ maxBuffer: opts.prunerMaxBufferBytes,
69
+ }
70
+ : false,
71
+ maxConnections: Number.MAX_SAFE_INTEGER,
72
+ minConnections: 0,
73
+ },
74
+ ...extra,
75
+ });
76
+ this.recordModeToLen = recordModeToLen;
77
+ // Fast/mock signing: keep signer identity semantics but skip crypto work.
78
+ this.sign = async () => new SignatureWithKey({
79
+ signature: new Uint8Array([0]),
80
+ publicKey: this.publicKey,
81
+ prehash: PreHash.NONE,
82
+ });
83
+ }
84
+ async verifyAndProcess(message) {
85
+ // Skip expensive crypto verify for large sims, but keep session handling
86
+ // behavior consistent with the real implementation.
87
+ const from = message.header.signatures.publicKeys[0];
88
+ if (!this.peers.has(from.hashcode())) {
89
+ this.updateSession(from, Number(message.header.session));
90
+ }
91
+ return true;
92
+ }
93
+ async publishMessage(from, message, to, relayed, signal) {
94
+ if (this.recordModeToLen && message?.id instanceof Uint8Array && isBenchId(message.id)) {
95
+ const mode = message?.header?.mode;
96
+ const toLen = Array.isArray(mode?.to) ? mode.to.length : undefined;
97
+ if (toLen != null)
98
+ this.recordModeToLen(toLen);
99
+ }
100
+ return super.publishMessage(from, message, to, relayed, signal);
101
+ }
102
+ }
103
+ const clampInt = (value, min, max) => Math.max(min, Math.min(max, Math.floor(value)));
104
+ export const resolvePubsubTopicSimParams = (input) => {
105
+ const nodes = clampInt(Number(input.nodes ?? 2000), 1, 1_000_000_000);
106
+ const degree = clampInt(Number(input.degree ?? 6), 0, Math.max(0, nodes - 1));
107
+ const writerIndex = clampInt(Number(input.writerIndex ?? 0), 0, nodes - 1);
108
+ const fanoutTopic = Boolean(input.fanoutTopic ?? false);
109
+ const fanoutRootIndex = clampInt(Number(input.fanoutRootIndex ?? writerIndex), 0, nodes - 1);
110
+ const subscribers = clampInt(Number(input.subscribers ?? nodes - 1), 0, nodes - 1);
111
+ return {
112
+ nodes,
113
+ degree,
114
+ writerIndex,
115
+ fanoutTopic,
116
+ fanoutRootIndex,
117
+ subscribers,
118
+ messages: clampInt(Number(input.messages ?? 200), 0, 1_000_000_000),
119
+ msgSize: clampInt(Number(input.msgSize ?? 1024), 0, 1_000_000_000),
120
+ intervalMs: clampInt(Number(input.intervalMs ?? 0), 0, 1_000_000_000),
121
+ silent: Boolean(input.silent ?? false),
122
+ redundancy: clampInt(Number(input.redundancy ?? 2), 0, 1_000_000_000),
123
+ seed: clampInt(Number(input.seed ?? 1), 0, 0xffff_ffff),
124
+ topic: String(input.topic ?? "concert"),
125
+ subscribeModel: (input.subscribeModel ?? "real"),
126
+ subscriptionDebounceDelayMs: clampInt(Number(input.subscriptionDebounceDelayMs ?? 0), 0, 1_000_000_000),
127
+ warmupMs: clampInt(Number(input.warmupMs ?? 0), 0, 1_000_000_000),
128
+ warmupMessages: clampInt(Number(input.warmupMessages ?? 0), 0, 1_000_000_000),
129
+ settleMs: clampInt(Number(input.settleMs ?? 200), 0, 1_000_000_000),
130
+ timeoutMs: clampInt(Number(input.timeoutMs ?? 300_000), 0, 1_000_000_000),
131
+ dialConcurrency: clampInt(Number(input.dialConcurrency ?? 256), 1, 1_000_000_000),
132
+ dialer: Boolean(input.dialer ?? false),
133
+ pruner: Boolean(input.pruner ?? false),
134
+ prunerIntervalMs: clampInt(Number(input.prunerIntervalMs ?? 50), 1, 1_000_000_000),
135
+ prunerMaxBufferBytes: clampInt(Number(input.prunerMaxBufferBytes ?? 64 * 1024), 0, 1_000_000_000),
136
+ dialDelayMs: clampInt(Number(input.dialDelayMs ?? 0), 0, 1_000_000_000),
137
+ streamRxDelayMs: clampInt(Number(input.streamRxDelayMs ?? 0), 0, 1_000_000_000),
138
+ streamHighWaterMarkBytes: clampInt(Number(input.streamHighWaterMarkBytes ?? 256 * 1024), 0, 1_000_000_000),
139
+ dropDataFrameRate: Math.max(0, Math.min(1, Number(input.dropDataFrameRate ?? 0))),
140
+ maxLatencySamples: clampInt(Number(input.maxLatencySamples ?? 1_000_000), 0, 1_000_000_000),
141
+ churnEveryMs: clampInt(Number(input.churnEveryMs ?? 0), 0, 1_000_000_000),
142
+ churnDownMs: clampInt(Number(input.churnDownMs ?? 0), 0, 1_000_000_000),
143
+ churnFraction: Math.max(0, Math.min(1, Number(input.churnFraction ?? 0))),
144
+ churnRedialIntervalMs: clampInt(Number(input.churnRedialIntervalMs ?? 50), 0, 1_000_000_000),
145
+ churnRedialConcurrency: clampInt(Number(input.churnRedialConcurrency ?? 128), 1, 1_000_000_000),
146
+ };
147
+ };
148
+ export const formatPubsubTopicSimResult = (r) => {
149
+ const p = r.params;
150
+ const lines = [];
151
+ lines.push("pubsub pubsub-topic-sim results");
152
+ lines.push(`- nodes: ${p.nodes}, degree: ${p.degree}`);
153
+ lines.push(`- writerIndex: ${p.writerIndex}, subscribers: ${r.subscriberCount}, topic: ${p.topic}, silent: ${p.silent ? "on" : "off"}`);
154
+ lines.push(`- fanoutTopic: ${p.fanoutTopic ? "on" : "off"} (rootIndex=${p.fanoutRootIndex})`);
155
+ lines.push(`- messages: ${p.messages}, msgSize: ${p.msgSize}B, intervalMs: ${p.intervalMs}, redundancy: ${p.redundancy}`);
156
+ lines.push(`- dialer: ${p.dialer ? "on" : "off"}, pruner: ${p.pruner ? "on" : "off"} (interval=${p.prunerIntervalMs}ms, maxBuffer=${p.prunerMaxBufferBytes}B)`);
157
+ lines.push(`- transport: dialDelay=${p.dialDelayMs}ms, rxDelay=${p.streamRxDelayMs}ms, hwm=${p.streamHighWaterMarkBytes}B`);
158
+ lines.push(`- subscribe: model=${p.subscribeModel}, requested=${r.subscriberCount}, writerKnown=${r.writerKnown}, time=${r.subscribeMs}ms`);
159
+ lines.push(`- churn: everyMs=${p.churnEveryMs} downMs=${p.churnDownMs} fraction=${p.churnFraction} events=${r.churnEvents} peers=${r.churnedPeersTotal}`);
160
+ lines.push(`- publish: time=${r.publishMs}ms, expected=${r.expected}, observedUnique=${r.deliveredUnique} (${r.deliveredPct.toFixed(2)}%), observedOnline=${r.expectedOnline} (${r.deliveredOnlinePct.toFixed(2)}%), duplicates=${r.duplicates}, publishErrors=${r.publishErrors}`);
161
+ if (r.latencySamples > 0) {
162
+ lines.push(`- latency ms (sample n=${r.latencySamples}): p50=${r.latencyP50.toFixed(1)}, p95=${r.latencyP95.toFixed(1)}, p99=${r.latencyP99.toFixed(1)}, max=${r.latencyMax.toFixed(1)}`);
163
+ }
164
+ else {
165
+ lines.push(`- latency ms: (no samples)`);
166
+ }
167
+ lines.push(`- mode.to length (writer bench msgs): avg=${r.modeToLenAvg.toFixed(1)}, max=${r.modeToLenMax}, samples=${r.modeToLenSamples}`);
168
+ lines.push(`- connections: now=${r.connectionsNow} (avg neighbours/node=${r.avgNeighbours.toFixed(2)})`);
169
+ lines.push(`- routes: avg/node=${r.avgRoutes.toFixed(2)}`);
170
+ lines.push(`- queuedBytes: avg/node=${r.avgQueuedBytes.toFixed(0)}, max/node=${r.maxQueuedBytes.toFixed(0)}`);
171
+ lines.push(`- frames: total=${r.framesSent} (data=${r.dataFramesSent}, ack=${r.ackFramesSent}, goodbye=${r.goodbyeFramesSent}, other=${r.otherFramesSent})`);
172
+ lines.push(`- drops: frames=${r.framesDropped} (data=${r.dataFramesDropped}), bytes=${r.bytesDropped}`);
173
+ lines.push(`- bytes sent (framed): ${r.bytesSent}`);
174
+ lines.push(`- memory: rss=${r.memoryRssMiB}MiB heapUsed=${r.memoryHeapUsedMiB}MiB heapTotal=${r.memoryHeapTotalMiB}MiB`);
175
+ return lines.join("\n");
176
+ };
177
+ export const runPubsubTopicSim = async (input) => {
178
+ const params = resolvePubsubTopicSimParams(input);
179
+ const timeoutMs = Math.max(0, params.timeoutMs);
180
+ const timeoutController = new AbortController();
181
+ const timeoutSignal = timeoutController.signal;
182
+ let timeout;
183
+ if (timeoutMs > 0) {
184
+ timeout = setTimeout(() => {
185
+ timeoutController.abort(new Error(`pubsub-topic-sim timed out after ${timeoutMs}ms (override with --timeoutMs)`));
186
+ }, timeoutMs);
187
+ }
188
+ const rng = mulberry32(params.seed);
189
+ const nodes = params.nodes;
190
+ const subscriberCount = clampInt(params.subscribers, 0, Math.max(0, nodes - 1));
191
+ let churnEvents = 0;
192
+ let churnedPeersTotal = 0;
193
+ let publishErrors = 0;
194
+ let modeToLenCount = 0;
195
+ let modeToLenSum = 0;
196
+ let modeToLenMax = 0;
197
+ const recordModeToLen = (len) => {
198
+ modeToLenCount += 1;
199
+ modeToLenSum += len;
200
+ if (len > modeToLenMax)
201
+ modeToLenMax = len;
202
+ };
203
+ const graph = buildRandomGraph(params.nodes, params.degree, rng);
204
+ const network = new InMemoryNetwork({
205
+ streamRxDelayMs: params.streamRxDelayMs,
206
+ streamHighWaterMarkBytes: params.streamHighWaterMarkBytes,
207
+ dialDelayMs: params.dialDelayMs,
208
+ dropDataFrameRate: params.dropDataFrameRate,
209
+ dropSeed: params.seed,
210
+ });
211
+ const topicRootControlPlane = new TopicRootControlPlane();
212
+ // Use a small stable router set for shard roots so topic delivery remains
213
+ // reliable under subscriber churn (roots must stay online).
214
+ const routerCount = Math.min(4, Math.max(1, params.nodes));
215
+ const routerIndices = new Set();
216
+ routerIndices.add(params.writerIndex);
217
+ for (let i = 0; routerIndices.size < routerCount && i < params.nodes; i++) {
218
+ const idx = (params.writerIndex + 1 + i) % params.nodes;
219
+ routerIndices.add(idx);
220
+ }
221
+ const peers = [];
222
+ try {
223
+ const basePort = 40_000;
224
+ for (let i = 0; i < params.nodes; i++) {
225
+ const isRouter = routerIndices.has(i);
226
+ const port = basePort + i;
227
+ const { runtime } = InMemoryNetwork.createPeer({ index: i, port, network });
228
+ runtime.connectionManager = new InMemoryConnectionManager(network, runtime);
229
+ network.registerPeer(runtime, port);
230
+ const components = {
231
+ peerId: runtime.peerId,
232
+ privateKey: runtime.privateKey,
233
+ addressManager: runtime.addressManager,
234
+ registrar: runtime.registrar,
235
+ connectionManager: runtime.connectionManager,
236
+ peerStore: runtime.peerStore,
237
+ events: runtime.events,
238
+ };
239
+ const record = i === params.writerIndex ? recordModeToLen : undefined;
240
+ const fanout = new SimFanoutTree(components, {
241
+ connectionManager: false,
242
+ topicRootControlPlane,
243
+ });
244
+ peers.push({
245
+ peerId: runtime.peerId,
246
+ fanout,
247
+ sub: new SimTopicControlPlane(components, {
248
+ dialer: params.dialer,
249
+ pruner: params.pruner,
250
+ prunerIntervalMs: params.prunerIntervalMs,
251
+ prunerMaxBufferBytes: params.prunerMaxBufferBytes,
252
+ subscriptionDebounceDelayMs: params.subscriptionDebounceDelayMs,
253
+ }, record, {
254
+ fanout,
255
+ topicRootControlPlane,
256
+ hostShards: isRouter,
257
+ // Make the shard overlay robust under subscriber churn by ensuring
258
+ // only stable "router" nodes can act as relays.
259
+ fanoutRootChannel: { maxChildren: 64 },
260
+ fanoutNodeChannel: { maxChildren: isRouter ? 24 : 0 },
261
+ }),
262
+ });
263
+ }
264
+ // TopicControlPlane.start() will start the fanout underlay as needed.
265
+ // Configure shard roots deterministically across the whole sim before starting.
266
+ topicRootControlPlane.setTopicRootCandidates([...routerIndices].map((i) => peers[i].sub.publicKeyHash));
267
+ // Ensure all nodes can discover stable router peers when joining shard overlays.
268
+ // Without bootstraps, the join loop falls back to already-connected neighbors,
269
+ // which is insufficient when most nodes are configured as leaf-only.
270
+ const routerBootstraps = [...routerIndices].map((i) => peers[i].sub.components.addressManager.getAddresses()[0]);
271
+ for (const p of peers) {
272
+ p.fanout.setBootstraps(routerBootstraps);
273
+ }
274
+ await Promise.all(peers.map((p) => p.sub.start()));
275
+ // Establish initial graph via dials (bounded concurrency).
276
+ const addrs = peers.map((p) => p.sub.components.addressManager.getAddresses()[0]);
277
+ const dialTasks = [];
278
+ for (let a = 0; a < graph.length; a++) {
279
+ for (const b of graph[a]) {
280
+ if (b <= a)
281
+ continue;
282
+ const addrB = addrs[b];
283
+ dialTasks.push(async () => {
284
+ await peers[a].sub.components.connectionManager.openConnection(addrB);
285
+ });
286
+ }
287
+ }
288
+ await runWithConcurrency(dialTasks, params.dialConcurrency);
289
+ await waitForProtocolStreams(peers.map((p) => p.sub));
290
+ await waitForProtocolStreams(peers.map((p) => p.fanout));
291
+ const writer = peers[params.writerIndex].sub;
292
+ const subscriberIndices = pickDistinct(rng, params.nodes, subscriberCount, params.writerIndex);
293
+ // Subscribe
294
+ const subscribeStart = Date.now();
295
+ await Promise.all(subscriberIndices.map(async (idx) => {
296
+ await peers[idx].sub.subscribe(params.topic);
297
+ }));
298
+ const subscribeDone = Date.now();
299
+ if (params.warmupMs > 0) {
300
+ await delay(params.warmupMs, { signal: timeoutSignal });
301
+ }
302
+ if (params.warmupMessages > 0) {
303
+ // Route warmup: SilentDelivery is economical but does not learn routes.
304
+ // Prime routing using acknowledged anywhere-probes (no explicit `to=[all]`).
305
+ const subscriberHashes = subscriberIndices.map((i) => peers[i].sub.publicKeyHash);
306
+ // Use redundancy=1 so every ACK we learn is distance=0 ("closest path"),
307
+ // which enables SilentDelivery routing (origin otherwise falls back to flooding).
308
+ const probeRedundancy = 1;
309
+ for (let i = 0; i < params.warmupMessages; i++) {
310
+ if (timeoutSignal.aborted) {
311
+ throw timeoutSignal.reason ?? new Error("pubsub-topic-sim aborted");
312
+ }
313
+ const msg = await writer.createMessage(undefined, {
314
+ mode: new AcknowledgeAnyWhere({ redundancy: probeRedundancy }),
315
+ });
316
+ await writer.publishMessage(writer.publicKey, msg, undefined, undefined, timeoutSignal);
317
+ // Yield so route updates/acks can be processed between probes.
318
+ await delay(0, { signal: timeoutSignal });
319
+ }
320
+ if (subscriberHashes.length > 0) {
321
+ await writer.waitFor(subscriberHashes, {
322
+ settle: "all",
323
+ timeout: 30_000,
324
+ signal: timeoutSignal,
325
+ });
326
+ }
327
+ }
328
+ // Delivery + latency tracking (unique deliveries per subscriber/seq).
329
+ const sendTimes = new Float64Array(params.messages);
330
+ const receivedFlags = subscriberIndices.map(() => new Uint8Array(params.messages));
331
+ let expectedOnline = 0;
332
+ let deliveredUnique = 0;
333
+ let duplicates = 0;
334
+ let deliveredSamplesSeen = 0;
335
+ const samples = [];
336
+ const maxSamples = Math.max(0, params.maxLatencySamples);
337
+ const recordLatency = (ms) => {
338
+ deliveredSamplesSeen += 1;
339
+ if (samples.length < maxSamples) {
340
+ samples.push(ms);
341
+ return;
342
+ }
343
+ if (maxSamples === 0)
344
+ return;
345
+ const j = int(rng, deliveredSamplesSeen);
346
+ if (j < maxSamples)
347
+ samples[j] = ms;
348
+ };
349
+ for (let s = 0; s < subscriberIndices.length; s++) {
350
+ const idx = subscriberIndices[s];
351
+ const node = peers[idx].sub;
352
+ node.addEventListener("data", (ev) => {
353
+ const msg = ev?.detail?.message;
354
+ const id = msg?.id;
355
+ if (!(id instanceof Uint8Array) || !isBenchId(id))
356
+ return;
357
+ const seq = readU32BE(id, 4);
358
+ if (seq >= params.messages)
359
+ return;
360
+ const t0 = sendTimes[seq];
361
+ if (!t0)
362
+ return;
363
+ const flags = receivedFlags[s];
364
+ if (flags[seq] === 1) {
365
+ duplicates += 1;
366
+ return;
367
+ }
368
+ flags[seq] = 1;
369
+ deliveredUnique += 1;
370
+ recordLatency(Date.now() - t0);
371
+ });
372
+ }
373
+ // Churn + redial (keep the underlay graph roughly stable).
374
+ const churnController = new AbortController();
375
+ const churnSignal = churnController.signal;
376
+ const redialController = new AbortController();
377
+ const redialSignal = redialController.signal;
378
+ const redialPending = new Map(); // idx -> nextAttemptAt
379
+ const scheduleRedial = (idx, at) => {
380
+ const prev = redialPending.get(idx);
381
+ if (prev == null || at < prev)
382
+ redialPending.set(idx, at);
383
+ };
384
+ const churnLoop = async () => {
385
+ const everyMs = Math.max(0, Math.floor(params.churnEveryMs));
386
+ const downMs = Math.max(0, Math.floor(params.churnDownMs));
387
+ const fraction = Math.max(0, Math.min(1, Number(params.churnFraction)));
388
+ if (everyMs <= 0 || downMs <= 0 || fraction <= 0)
389
+ return;
390
+ if (subscriberIndices.length === 0)
391
+ return;
392
+ for (;;) {
393
+ if (churnSignal.aborted)
394
+ return;
395
+ await delay(everyMs, { signal: churnSignal });
396
+ if (churnSignal.aborted)
397
+ return;
398
+ if (timeoutSignal.aborted)
399
+ return;
400
+ const target = Math.min(subscriberIndices.length, Math.max(1, Math.floor(subscriberIndices.length * fraction)));
401
+ const chosen = new Set();
402
+ const maxAttempts = Math.max(10, target * 20);
403
+ for (let tries = 0; chosen.size < target && tries < maxAttempts; tries++) {
404
+ const idx = subscriberIndices[int(rng, subscriberIndices.length)];
405
+ if (routerIndices.has(idx))
406
+ continue;
407
+ const peer = peers[idx];
408
+ if (network.isPeerOffline(peer.peerId))
409
+ continue;
410
+ chosen.add(idx);
411
+ }
412
+ if (chosen.size === 0)
413
+ continue;
414
+ churnEvents += 1;
415
+ churnedPeersTotal += chosen.size;
416
+ const now = Date.now();
417
+ await Promise.all([...chosen].map(async (idx) => {
418
+ const peer = peers[idx];
419
+ network.setPeerOffline(peer.peerId, downMs, now);
420
+ await network.disconnectPeer(peer.peerId);
421
+ scheduleRedial(idx, now + downMs + 1);
422
+ }));
423
+ }
424
+ };
425
+ const redialLoop = async () => {
426
+ const tickMs = Math.max(0, Math.floor(params.churnRedialIntervalMs));
427
+ if (tickMs <= 0)
428
+ return;
429
+ const concurrency = Math.max(1, Math.floor(params.churnRedialConcurrency));
430
+ for (;;) {
431
+ if (redialSignal.aborted)
432
+ return;
433
+ await delay(tickMs, { signal: redialSignal });
434
+ if (redialSignal.aborted)
435
+ return;
436
+ if (timeoutSignal.aborted)
437
+ return;
438
+ const now = Date.now();
439
+ const ready = [];
440
+ for (const [idx, at] of redialPending) {
441
+ if (at <= now)
442
+ ready.push(idx);
443
+ }
444
+ if (ready.length === 0)
445
+ continue;
446
+ const tasks = [];
447
+ for (const idx of ready) {
448
+ const peer = peers[idx];
449
+ if (network.isPeerOffline(peer.peerId, now)) {
450
+ scheduleRedial(idx, now + tickMs);
451
+ continue;
452
+ }
453
+ const cm = peer.sub.components.connectionManager;
454
+ const neighbors = graph[idx] ?? [];
455
+ let missing = 0;
456
+ for (const nb of neighbors) {
457
+ const other = peers[nb];
458
+ if (network.isPeerOffline(other.peerId, now))
459
+ continue;
460
+ const open = cm.getConnections(other.peerId).some((c) => c.status === "open");
461
+ if (open)
462
+ continue;
463
+ missing += 1;
464
+ const addrB = addrs[nb];
465
+ tasks.push(async () => {
466
+ try {
467
+ await cm.openConnection(addrB);
468
+ }
469
+ catch {
470
+ // ignored (offline or temporary no address)
471
+ }
472
+ });
473
+ }
474
+ if (missing > 0) {
475
+ // Retry a little later until we have a stable degree again.
476
+ scheduleRedial(idx, now + tickMs * 4);
477
+ }
478
+ else {
479
+ redialPending.delete(idx);
480
+ }
481
+ }
482
+ if (tasks.length > 0) {
483
+ await runWithConcurrency(tasks, concurrency);
484
+ }
485
+ }
486
+ };
487
+ const churnPromise = churnLoop().catch(() => { });
488
+ const redialPromise = redialLoop().catch(() => { });
489
+ // Publish
490
+ const payload = new Uint8Array(Math.max(0, params.msgSize));
491
+ const publishStart = Date.now();
492
+ try {
493
+ for (let i = 0; i < params.messages; i++) {
494
+ if (timeoutSignal.aborted) {
495
+ throw timeoutSignal.reason ?? new Error("pubsub-topic-sim aborted");
496
+ }
497
+ const id = new Uint8Array(32);
498
+ id.set(BENCH_ID_PREFIX, 0);
499
+ writeU32BE(id, 4, i);
500
+ const now = Date.now();
501
+ sendTimes[i] = now;
502
+ if (params.churnEveryMs > 0 && subscriberIndices.length > 0) {
503
+ let online = 0;
504
+ for (const idx of subscriberIndices) {
505
+ if (!network.isPeerOffline(peers[idx].peerId, now))
506
+ online += 1;
507
+ }
508
+ expectedOnline += online;
509
+ }
510
+ else {
511
+ expectedOnline += subscriberIndices.length;
512
+ }
513
+ try {
514
+ await writer.publish(payload, {
515
+ id,
516
+ topics: [params.topic],
517
+ signal: timeoutSignal,
518
+ });
519
+ }
520
+ catch {
521
+ publishErrors += 1;
522
+ }
523
+ if (params.intervalMs > 0) {
524
+ await delay(params.intervalMs, { signal: timeoutSignal });
525
+ }
526
+ }
527
+ }
528
+ finally {
529
+ churnController.abort();
530
+ redialController.abort();
531
+ await churnPromise;
532
+ await redialPromise;
533
+ churnSignal.clear?.();
534
+ redialSignal.clear?.();
535
+ }
536
+ // Give in-flight messages time to arrive
537
+ if (params.settleMs > 0) {
538
+ await delay(params.settleMs, { signal: timeoutSignal });
539
+ }
540
+ const publishDone = Date.now();
541
+ // Aggregate stats
542
+ const expected = subscriberIndices.length * params.messages;
543
+ samples.sort((a, b) => a - b);
544
+ let peerEdges = 0;
545
+ let neighbourSum = 0;
546
+ let routeSum = 0;
547
+ let queuedSum = 0;
548
+ let queuedMax = 0;
549
+ for (const p of peers) {
550
+ peerEdges += p.sub.components.connectionManager.getConnections().length;
551
+ neighbourSum += p.sub.peers.size;
552
+ routeSum += p.sub.routes.count();
553
+ const queued = p.sub.getQueuedBytes();
554
+ queuedSum += queued;
555
+ if (queued > queuedMax)
556
+ queuedMax = queued;
557
+ }
558
+ const m = network.metrics;
559
+ const connectionsNow = peerEdges / 2;
560
+ const avgNeighbours = neighbourSum / peers.length;
561
+ const avgRoutes = routeSum / peers.length;
562
+ const avgQueuedBytes = queuedSum / peers.length;
563
+ const mem = process.memoryUsage();
564
+ const deliveredPct = expected === 0 ? 100 : (100 * deliveredUnique) / expected;
565
+ const deliveredOnlinePct = expectedOnline === 0 ? 100 : (100 * deliveredUnique) / expectedOnline;
566
+ return {
567
+ params,
568
+ subscriberCount: subscriberIndices.length,
569
+ writerKnown: writer.getSubscribers(params.topic)?.length ?? 0,
570
+ subscribeMs: subscribeDone - subscribeStart,
571
+ warmupMs: params.warmupMs,
572
+ publishMs: publishDone - publishStart,
573
+ expected,
574
+ expectedOnline,
575
+ deliveredUnique,
576
+ deliveredPct,
577
+ deliveredOnlinePct,
578
+ duplicates,
579
+ publishErrors,
580
+ latencySamples: samples.length,
581
+ latencyP50: quantile(samples, 0.5),
582
+ latencyP95: quantile(samples, 0.95),
583
+ latencyP99: quantile(samples, 0.99),
584
+ latencyMax: samples.length ? samples[samples.length - 1] : NaN,
585
+ modeToLenAvg: modeToLenCount ? modeToLenSum / modeToLenCount : 0,
586
+ modeToLenMax,
587
+ modeToLenSamples: modeToLenCount,
588
+ churnEvents,
589
+ churnedPeersTotal,
590
+ connectionsNow,
591
+ avgNeighbours,
592
+ avgRoutes,
593
+ avgQueuedBytes,
594
+ maxQueuedBytes: queuedMax,
595
+ framesSent: m.framesSent,
596
+ dataFramesSent: m.dataFramesSent,
597
+ ackFramesSent: m.ackFramesSent,
598
+ goodbyeFramesSent: m.goodbyeFramesSent,
599
+ otherFramesSent: m.otherFramesSent,
600
+ framesDropped: m.framesDropped,
601
+ dataFramesDropped: m.dataFramesDropped,
602
+ bytesSent: m.bytesSent,
603
+ bytesDropped: m.bytesDropped,
604
+ memoryRssMiB: Math.round(mem.rss / 1024 / 1024),
605
+ memoryHeapUsedMiB: Math.round(mem.heapUsed / 1024 / 1024),
606
+ memoryHeapTotalMiB: Math.round(mem.heapTotal / 1024 / 1024),
607
+ };
608
+ }
609
+ catch (e) {
610
+ // `delay(..., { signal })` rejects with a generic AbortError (no message),
611
+ // so surface the actual timeout reason if we aborted due to `timeoutMs`.
612
+ if (timeoutSignal.aborted) {
613
+ throw timeoutSignal.reason ?? e;
614
+ }
615
+ throw e;
616
+ }
617
+ finally {
618
+ if (timeout)
619
+ clearTimeout(timeout);
620
+ // Stop pubsub first (some scenarios share routing/session state with fanout).
621
+ await Promise.all(peers.map((p) => p.sub.stop().catch(() => { })));
622
+ await Promise.all(peers.map((p) => p.fanout?.stop().catch(() => { })));
623
+ }
624
+ };
625
+ //# sourceMappingURL=pubsub-topic-sim-lib.js.map