@peerbit/pubsub 4.1.4-2ed894b → 4.1.4-42e98ce

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 +1413 -454
  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 +10 -9
  43. package/src/fanout-channel.ts +150 -0
  44. package/src/fanout-tree.ts +6346 -0
  45. package/src/index.ts +1682 -588
  46. package/src/topic-root-control-plane.ts +160 -0
@@ -0,0 +1,1225 @@
1
+ import { PreHash, SignatureWithKey } from "@peerbit/crypto";
2
+ import { delay } from "@peerbit/time";
3
+ import { anySignal } from "any-signal";
4
+ import { monitorEventLoopDelay } from "node:perf_hooks";
5
+ import { FanoutTree } from "../src/index.js";
6
+ import { InMemoryNetwork, InMemorySession, } from "@peerbit/libp2p-test-utils/inmemory-libp2p.js";
7
+ import { int, mulberry32, quantile, runWithConcurrency } from "./sim/bench-utils.js";
8
+ class SimFanoutTree extends FanoutTree {
9
+ constructor(c, opts) {
10
+ super(c, opts);
11
+ // Fast/mock signing: keep signer identity semantics but skip crypto work.
12
+ this.sign = async () => new SignatureWithKey({
13
+ signature: new Uint8Array([0]),
14
+ publicKey: this.publicKey,
15
+ prehash: PreHash.NONE,
16
+ });
17
+ }
18
+ async verifyAndProcess(message) {
19
+ // Skip expensive crypto verify for large sims, but keep session handling behavior
20
+ // consistent with the real implementation.
21
+ const from = message.header.signatures.publicKeys[0];
22
+ if (!this.peers.has(from.hashcode())) {
23
+ this.updateSession(from, Number(message.header.session));
24
+ }
25
+ return true;
26
+ }
27
+ }
28
+ const pickDistinct = (rng, n, k, exclude) => {
29
+ if (k <= 0)
30
+ return [];
31
+ const out = new Set();
32
+ while (out.size < k) {
33
+ const candidate = int(rng, n);
34
+ if (exclude.has(candidate))
35
+ continue;
36
+ out.add(candidate);
37
+ }
38
+ return [...out];
39
+ };
40
+ const parseSimPeerIndex = (peerId) => {
41
+ const s = String(peerId?.toString?.() ?? "");
42
+ const m = s.match(/sim-(\d+)/);
43
+ if (!m)
44
+ return 0;
45
+ const n = Number(m[1]);
46
+ return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
47
+ };
48
+ export const resolveFanoutTreeSimParams = (input) => {
49
+ const nodes = Number(input.nodes ?? 2000);
50
+ const bootstraps = Number(input.bootstraps ?? 1);
51
+ const subscribersDefault = Math.max(0, nodes - 1 - bootstraps);
52
+ const msgRate = Number(input.msgRate ?? 30);
53
+ const intervalMsRaw = Number(input.intervalMs ?? 0);
54
+ const intervalMs = intervalMsRaw > 0
55
+ ? intervalMsRaw
56
+ : msgRate > 0
57
+ ? Math.floor(1000 / msgRate)
58
+ : 0;
59
+ return {
60
+ nodes,
61
+ rootIndex: Number(input.rootIndex ?? 0),
62
+ bootstraps,
63
+ bootstrapMaxPeers: Number(input.bootstrapMaxPeers ?? 0),
64
+ subscribers: Number(input.subscribers ?? subscribersDefault),
65
+ relayFraction: Number(input.relayFraction ?? 0.25),
66
+ messages: Number(input.messages ?? 200),
67
+ msgRate,
68
+ msgSize: Number(input.msgSize ?? 1024),
69
+ intervalMs,
70
+ settleMs: Number(input.settleMs ?? 2_000),
71
+ deadlineMs: Number(input.deadlineMs ?? 0),
72
+ maxDataAgeMs: Number(input.maxDataAgeMs ?? 0),
73
+ timeoutMs: Number(input.timeoutMs ?? 300_000),
74
+ seed: Number(input.seed ?? 1),
75
+ topic: String(input.topic ?? "concert"),
76
+ rootUploadLimitBps: Number(input.rootUploadLimitBps ?? 20_000_000),
77
+ rootMaxChildren: Number(input.rootMaxChildren ?? 64),
78
+ relayUploadLimitBps: Number(input.relayUploadLimitBps ?? 10_000_000),
79
+ relayMaxChildren: Number(input.relayMaxChildren ?? 32),
80
+ allowKick: Boolean(input.allowKick ?? false),
81
+ bidPerByte: Number(input.bidPerByte ?? 0),
82
+ bidPerByteRelay: Number(input.bidPerByteRelay ?? input.bidPerByte ?? 0),
83
+ bidPerByteLeaf: Number(input.bidPerByteLeaf ?? input.bidPerByte ?? 0),
84
+ repair: Boolean(input.repair ?? true),
85
+ repairWindowMessages: Number(input.repairWindowMessages ?? 1024),
86
+ repairMaxBackfillMessages: Number(input.repairMaxBackfillMessages ?? -1),
87
+ repairIntervalMs: Number(input.repairIntervalMs ?? 200),
88
+ repairMaxPerReq: Number(input.repairMaxPerReq ?? 64),
89
+ neighborRepair: Boolean(input.neighborRepair ?? false),
90
+ neighborRepairPeers: Number(input.neighborRepairPeers ?? 2),
91
+ neighborMeshPeers: Number(input.neighborMeshPeers ?? -1),
92
+ neighborAnnounceIntervalMs: Number(input.neighborAnnounceIntervalMs ?? -1),
93
+ neighborMeshRefreshIntervalMs: Number(input.neighborMeshRefreshIntervalMs ?? -1),
94
+ neighborHaveTtlMs: Number(input.neighborHaveTtlMs ?? -1),
95
+ neighborRepairBudgetBps: Number(input.neighborRepairBudgetBps ?? -1),
96
+ neighborRepairBurstMs: Number(input.neighborRepairBurstMs ?? -1),
97
+ streamRxDelayMs: Number(input.streamRxDelayMs ?? 0),
98
+ streamHighWaterMarkBytes: Number(input.streamHighWaterMarkBytes ?? 256 * 1024),
99
+ dialDelayMs: Number(input.dialDelayMs ?? 0),
100
+ joinConcurrency: Number(input.joinConcurrency ?? 256),
101
+ joinReqTimeoutMs: Number(input.joinReqTimeoutMs ?? -1),
102
+ candidateShuffleTopK: Number(input.candidateShuffleTopK ?? -1),
103
+ candidateScoringMode: input.candidateScoringMode === "ranked-strict" ||
104
+ input.candidateScoringMode === "weighted" ||
105
+ input.candidateScoringMode === "ranked-shuffle"
106
+ ? input.candidateScoringMode
107
+ : "ranked-shuffle",
108
+ bootstrapEnsureIntervalMs: Number(input.bootstrapEnsureIntervalMs ?? -1),
109
+ trackerQueryIntervalMs: Number(input.trackerQueryIntervalMs ?? -1),
110
+ joinAttemptsPerRound: Number(input.joinAttemptsPerRound ?? -1),
111
+ candidateCooldownMs: Number(input.candidateCooldownMs ?? -1),
112
+ joinPhases: Boolean(input.joinPhases ?? false),
113
+ joinPhaseSettleMs: Number(input.joinPhaseSettleMs ?? 2_000),
114
+ maxLatencySamples: Number(input.maxLatencySamples ?? 1_000_000),
115
+ profile: Boolean(input.profile ?? false),
116
+ progress: Boolean(input.progress ?? false),
117
+ progressEveryMs: Number(input.progressEveryMs ?? 5_000),
118
+ dropDataFrameRate: Number(input.dropDataFrameRate ?? 0),
119
+ churnEveryMs: Number(input.churnEveryMs ?? 0),
120
+ churnDownMs: Number(input.churnDownMs ?? 0),
121
+ churnFraction: Number(input.churnFraction ?? 0),
122
+ assertMinJoinedPct: Number(input.assertMinJoinedPct ?? 0),
123
+ assertMinDeliveryPct: Number(input.assertMinDeliveryPct ?? 0),
124
+ assertMinDeadlineDeliveryPct: Number(input.assertMinDeadlineDeliveryPct ?? 0),
125
+ assertMaxUploadFracPct: Number(input.assertMaxUploadFracPct ?? 0),
126
+ assertMaxOverheadFactor: Number(input.assertMaxOverheadFactor ?? 0),
127
+ assertMaxControlBpp: Number(input.assertMaxControlBpp ?? 0),
128
+ assertMaxTrackerBpp: Number(input.assertMaxTrackerBpp ?? 0),
129
+ assertMaxRepairBpp: Number(input.assertMaxRepairBpp ?? 0),
130
+ assertAttachP95Ms: Number(input.assertAttachP95Ms ?? 0),
131
+ assertMaxTreeLevelP95: Number(input.assertMaxTreeLevelP95 ?? 0),
132
+ assertMaxFormationScore: Number(input.assertMaxFormationScore ?? 0),
133
+ assertMaxOrphans: Number(input.assertMaxOrphans ?? 0),
134
+ assertRecoveryP95Ms: Number(input.assertRecoveryP95Ms ?? 0),
135
+ assertMaxReparentsPerMin: Number(input.assertMaxReparentsPerMin ?? 0),
136
+ assertMaxOrphanArea: Number(input.assertMaxOrphanArea ?? 0),
137
+ };
138
+ };
139
+ export const formatFanoutTreeSimResult = (r) => {
140
+ const p = r.params;
141
+ return [
142
+ "fanout-tree-sim",
143
+ `nodes=${p.nodes} bootstraps=${r.bootstrapCount} bootstrapMaxPeers=${p.bootstrapMaxPeers} subscribers=${r.subscriberCount} relays=${r.relayCount}`,
144
+ `joined=${r.joinedCount}/${r.subscriberCount} (${r.joinedPct.toFixed(2)}%)`,
145
+ `join: ${(r.joinMs / 1000).toFixed(3)}s`,
146
+ `attachMs samples=${r.attachSamples} p50=${r.attachP50.toFixed(1)} p95=${r.attachP95.toFixed(1)} p99=${r.attachP99.toFixed(1)} max=${r.attachMax.toFixed(1)}`,
147
+ `formationPaths: underlayEdges=${r.formationUnderlayEdges} dist(p95/max)=${r.formationUnderlayDistP95.toFixed(1)}/${r.formationUnderlayDistMax.toFixed(1)} stretch(p95/max)=${r.formationStretchP95.toFixed(2)}/${r.formationStretchMax.toFixed(2)} score=${r.formationScore.toFixed(2)}`,
148
+ `formationTree: maxLevel=${r.formationTreeMaxLevel} p95Level=${r.formationTreeLevelP95.toFixed(1)} avgLevel=${r.formationTreeLevelAvg.toFixed(2)} orphans=${r.formationTreeOrphans} rootChildren=${r.formationTreeRootChildren} children(p95/max)=${r.formationTreeChildrenP95.toFixed(1)}/${r.formationTreeChildrenMax}`,
149
+ `publish: ${(r.publishMs / 1000).toFixed(3)}s intervalMs=${p.intervalMs}`,
150
+ `churn: everyMs=${p.churnEveryMs} downMs=${p.churnDownMs} fraction=${p.churnFraction} events=${r.churnEvents} peers=${r.churnedPeersTotal}`,
151
+ ...(r.maintSamples > 0
152
+ ? [
153
+ `maintenance: maxOrphans=${r.maintMaxOrphans} orphanArea=${r.maintOrphanArea.toFixed(1)}s recoveryMs p50=${r.maintRecoveryP50Ms.toFixed(1)} p95=${r.maintRecoveryP95Ms.toFixed(1)} reparentsPerMin=${r.maintReparentsPerMin.toFixed(2)} flapMax=${r.maintMaxReparentsPerPeer} driftP95(level/children)=${r.maintLevelP95DriftMax.toFixed(1)}/${r.maintChildrenP95DriftMax.toFixed(1)}`,
154
+ ]
155
+ : []),
156
+ `delivered=${r.delivered}/${r.expected} (${r.deliveredPct.toFixed(2)}%) dup=${r.duplicates}`,
157
+ p.deadlineMs > 0
158
+ ? `deadline=${p.deadlineMs}ms${p.maxDataAgeMs > 0 ? ` maxAgeMs=${p.maxDataAgeMs}` : ""} delivered=${r.deliveredWithinDeadline}/${r.expected} (${r.deliveredWithinDeadlinePct.toFixed(2)}%)`
159
+ : `deadline=off${p.maxDataAgeMs > 0 ? ` maxAgeMs=${p.maxDataAgeMs}` : ""}`,
160
+ `latencyMs p50=${r.latencyP50.toFixed(1)} p95=${r.latencyP95.toFixed(1)} p99=${r.latencyP99.toFixed(1)} max=${r.latencyMax.toFixed(1)}`,
161
+ `drops: forward total=${r.droppedForwardsTotal} max=${r.droppedForwardsMax} node=${r.droppedForwardsMaxNode ?? "-"} stale total=${r.staleForwardsDroppedTotal} max=${r.staleForwardsDroppedMax} node=${r.staleForwardsDroppedMaxNode ?? "-"} write total=${r.dataWriteDropsTotal} max=${r.dataWriteDropsMax} node=${r.dataWriteDropsMaxNode ?? "-"}`,
162
+ `reparent: disconnect=${r.reparentDisconnectTotal} stale=${r.reparentStaleTotal} kicked=${r.reparentKickedTotal}`,
163
+ `tree: maxLevel=${r.treeMaxLevel} p95Level=${r.treeLevelP95.toFixed(1)} avgLevel=${r.treeLevelAvg.toFixed(2)} orphans=${r.treeOrphans} rootChildren=${r.treeRootChildren} children(p95/max)=${r.treeChildrenP95.toFixed(1)}/${r.treeChildrenMax}`,
164
+ `upload: max=${r.maxUploadBps} B/s (${r.maxUploadFracPct.toFixed(1)}% of cap) node=${r.maxUploadNode ?? "-"}`,
165
+ `stream: queuedBytes total=${r.streamQueuedBytesTotal} max=${r.streamQueuedBytesMax} p95=${r.streamQueuedBytesP95.toFixed(0)} node=${r.streamQueuedBytesMaxNode ?? "-"} lanes=${r.streamQueuedBytesByLane.join(",")}`,
166
+ `overhead: dataFactor=${r.overheadFactorData.toFixed(3)} (sentPayloadBytes / ideal)`,
167
+ `economics: earningsTotal=${r.earningsTotal} relayCount=${r.earningsRelayCount} p50=${r.earningsRelayP50} p95=${r.earningsRelayP95} max=${r.earningsRelayMax}`,
168
+ `protocol: controlBytesSent=${r.protocolControlBytesSent} (join=${r.protocolControlBytesSentJoin} tracker=${r.protocolControlBytesSentTracker} repair=${r.protocolControlBytesSentRepair}) bpp=${r.controlBpp.toFixed(4)} (tracker=${r.trackerBpp.toFixed(4)} repair=${r.repairBpp.toFixed(4)}) dataPayloadBytesSent=${r.protocolDataPayloadBytesSent} fetchReqSent=${r.protocolFetchReqSent} ihaveSent=${r.protocolIHaveSent} trackerFeedbackSent=${r.protocolTrackerFeedbackSent} holeFills=${r.protocolHoleFillsFromNeighbor} routeCache(h/m/x/e)=${r.protocolRouteCacheHits}/${r.protocolRouteCacheMisses}/${r.protocolRouteCacheExpirations}/${r.protocolRouteCacheEvictions} routeProxy(q/t/f)=${r.protocolRouteProxyQueries}/${r.protocolRouteProxyTimeouts}/${r.protocolRouteProxyFanout}`,
169
+ `network: dials=${r.network.dials} connsOpened=${r.network.connectionsOpened} streamsOpened=${r.network.streamsOpened} framesSent=${r.network.framesSent} bytesSent=${r.network.bytesSent} framesDropped=${r.network.framesDropped} bytesDropped=${r.network.bytesDropped}`,
170
+ ...(r.profile
171
+ ? [
172
+ `profile: cpuUserMs=${r.profile.cpuUserMs.toFixed(1)} cpuSystemMs=${r.profile.cpuSystemMs.toFixed(1)} rssMb=${r.profile.rssMb.toFixed(1)} heapUsedMb=${r.profile.heapUsedMb.toFixed(1)} eldP95Ms=${r.profile.eventLoopDelayP95Ms.toFixed(2)} eldMaxMs=${r.profile.eventLoopDelayMaxMs.toFixed(2)}`,
173
+ ]
174
+ : []),
175
+ ].join("\n");
176
+ };
177
+ export const runFanoutTreeSim = async (input) => {
178
+ const params = resolveFanoutTreeSimParams(input);
179
+ const timeoutMs = Math.max(0, Math.floor(params.timeoutMs));
180
+ const rng = mulberry32(params.seed);
181
+ const profileEnabled = params.profile === true;
182
+ let profileCpuStart;
183
+ let profileEld;
184
+ if (profileEnabled) {
185
+ profileCpuStart = process.cpuUsage();
186
+ try {
187
+ profileEld = monitorEventLoopDelay({ resolution: 20 });
188
+ profileEld.enable();
189
+ }
190
+ catch {
191
+ // ignore
192
+ }
193
+ }
194
+ const rootIndex = Math.max(0, Math.min(params.nodes - 1, params.rootIndex));
195
+ const bootstrapCount = Math.max(0, Math.min(params.nodes - 1, Math.floor(params.bootstraps)));
196
+ const bootstrapIndices = Array.from({ length: bootstrapCount }, (_, i) => {
197
+ const idx = i + 1;
198
+ return idx >= params.nodes ? 0 : idx;
199
+ }).filter((i) => i !== rootIndex);
200
+ const exclude = new Set([rootIndex, ...bootstrapIndices]);
201
+ const subscriberCount = Math.max(0, Math.min(params.nodes - exclude.size, Math.floor(params.subscribers)));
202
+ const subscriberIndices = pickDistinct(rng, params.nodes, subscriberCount, exclude);
203
+ // Ensure we have at least one relay when scale > root fanout.
204
+ const wantsRelays = subscriberCount > Math.max(0, params.rootMaxChildren);
205
+ let relayTarget = Math.floor(subscriberCount * Math.max(0, Math.min(1, params.relayFraction)));
206
+ if (wantsRelays && relayTarget === 0)
207
+ relayTarget = Math.min(1, subscriberCount);
208
+ const relaySet = new Set();
209
+ const shuffledSubscribers = [...subscriberIndices].sort(() => rng() - 0.5);
210
+ for (const idx of shuffledSubscribers) {
211
+ if (relaySet.size >= relayTarget)
212
+ break;
213
+ relaySet.add(idx);
214
+ }
215
+ const network = new InMemoryNetwork({
216
+ streamRxDelayMs: params.streamRxDelayMs,
217
+ streamHighWaterMarkBytes: params.streamHighWaterMarkBytes,
218
+ dialDelayMs: params.dialDelayMs,
219
+ dropDataFrameRate: params.dropDataFrameRate,
220
+ dropSeed: params.seed,
221
+ });
222
+ const bootstrapIndexSet = new Set(bootstrapIndices);
223
+ const maxConnectionsFor = (index) => {
224
+ if (index === rootIndex)
225
+ return 256;
226
+ if (bootstrapIndexSet.has(index))
227
+ return 128;
228
+ if (relaySet.has(index))
229
+ return 64;
230
+ return 16;
231
+ };
232
+ const seenCacheMaxFor = (index) => {
233
+ if (index === rootIndex)
234
+ return 200_000;
235
+ if (bootstrapIndexSet.has(index))
236
+ return 100_000;
237
+ if (relaySet.has(index))
238
+ return 50_000;
239
+ return 20_000;
240
+ };
241
+ const seenCacheTtlMsFor = (index) => {
242
+ if (index === rootIndex || bootstrapIndexSet.has(index))
243
+ return 120_000;
244
+ return 60_000;
245
+ };
246
+ const session = await InMemorySession.disconnected(params.nodes, {
247
+ network,
248
+ basePort: 30_000,
249
+ services: {
250
+ fanout: (c) => {
251
+ const index = parseSimPeerIndex(c?.peerId);
252
+ return new SimFanoutTree(c, {
253
+ // Keep sims bounded: limit per-node connections to roughly the fanout degree,
254
+ // so large networks can run in a single process without OOM.
255
+ connectionManager: {
256
+ minConnections: 0,
257
+ maxConnections: maxConnectionsFor(index),
258
+ dialer: false,
259
+ pruner: { interval: 1_000 },
260
+ },
261
+ seenCacheMax: seenCacheMaxFor(index),
262
+ seenCacheTtlMs: seenCacheTtlMsFor(index),
263
+ random: mulberry32((params.seed >>> 0) ^ index),
264
+ });
265
+ },
266
+ },
267
+ });
268
+ let timer;
269
+ const timeoutController = new AbortController();
270
+ const timeoutSignal = timeoutController.signal;
271
+ try {
272
+ if (timeoutMs > 0) {
273
+ timer = setTimeout(() => {
274
+ timeoutController.abort(new Error(`fanout-tree-sim timed out after ${timeoutMs}ms (override with --timeoutMs)`));
275
+ }, timeoutMs);
276
+ }
277
+ const run = async () => {
278
+ const bootstrapAddrs = bootstrapIndices.flatMap((i) => session.peers[i].getMultiaddrs());
279
+ if (bootstrapAddrs.length === 0) {
280
+ throw new Error("No bootstrap addrs; pass --bootstraps >= 1");
281
+ }
282
+ for (const p of session.peers) {
283
+ p.services.fanout.setBootstraps(bootstrapAddrs);
284
+ }
285
+ const root = session.peers[rootIndex].services.fanout;
286
+ const rootId = root.publicKeyHash;
287
+ // Root opens channel and starts announcing capacity to trackers immediately.
288
+ root.openChannel(params.topic, rootId, {
289
+ role: "root",
290
+ msgRate: params.msgRate,
291
+ msgSize: params.msgSize,
292
+ ...(params.maxDataAgeMs > 0 ? { maxDataAgeMs: params.maxDataAgeMs } : {}),
293
+ uploadLimitBps: params.rootUploadLimitBps,
294
+ maxChildren: params.rootMaxChildren,
295
+ bidPerByte: params.bidPerByte,
296
+ allowKick: params.allowKick,
297
+ repair: params.repair,
298
+ repairWindowMessages: params.repairWindowMessages,
299
+ ...(params.repairMaxBackfillMessages >= 0
300
+ ? { repairMaxBackfillMessages: params.repairMaxBackfillMessages }
301
+ : {}),
302
+ repairIntervalMs: params.repairIntervalMs,
303
+ repairMaxPerReq: params.repairMaxPerReq,
304
+ neighborRepair: params.neighborRepair,
305
+ neighborRepairPeers: params.neighborRepairPeers,
306
+ ...(params.neighborMeshPeers >= 0
307
+ ? { neighborMeshPeers: params.neighborMeshPeers }
308
+ : {}),
309
+ ...(params.neighborAnnounceIntervalMs >= 0
310
+ ? { neighborAnnounceIntervalMs: params.neighborAnnounceIntervalMs }
311
+ : {}),
312
+ ...(params.neighborMeshRefreshIntervalMs >= 0
313
+ ? { neighborMeshRefreshIntervalMs: params.neighborMeshRefreshIntervalMs }
314
+ : {}),
315
+ ...(params.neighborHaveTtlMs >= 0
316
+ ? { neighborHaveTtlMs: params.neighborHaveTtlMs }
317
+ : {}),
318
+ ...(params.neighborRepairBudgetBps >= 0
319
+ ? { neighborRepairBudgetBps: params.neighborRepairBudgetBps }
320
+ : {}),
321
+ ...(params.neighborRepairBurstMs >= 0
322
+ ? { neighborRepairBurstMs: params.neighborRepairBurstMs }
323
+ : {}),
324
+ });
325
+ // Join subscribers (bounded concurrency).
326
+ const joinStart = Date.now();
327
+ const joined = new Array(subscriberIndices.length).fill(false);
328
+ const attachDurationsByPos = new Array(subscriberIndices.length).fill(-1);
329
+ let joinCompleted = 0;
330
+ let joinOk = 0;
331
+ const progressEveryMs = Math.max(250, Math.floor(params.progressEveryMs || 5_000));
332
+ let joinProgressTimer;
333
+ if (params.progress) {
334
+ console.log(`[fanout-tree-sim] phase=join subscribers=${subscriberIndices.length} relays=${relaySet.size} joinConcurrency=${params.joinConcurrency}`);
335
+ joinProgressTimer = setInterval(() => {
336
+ const mu = process.memoryUsage();
337
+ const rssMb = mu.rss / (1024 * 1024);
338
+ const heapUsedMb = mu.heapUsed / (1024 * 1024);
339
+ const openConns = Math.max(0, network.metrics.connectionsOpened - network.metrics.connectionsClosed);
340
+ console.log(`[fanout-tree-sim] join progress ok=${joinOk}/${subscriberIndices.length} done=${joinCompleted}/${subscriberIndices.length} openConns=${openConns} dials=${network.metrics.dials} streamsOpened=${network.metrics.streamsOpened} rssMb=${rssMb.toFixed(1)} heapUsedMb=${heapUsedMb.toFixed(1)}`);
341
+ }, progressEveryMs);
342
+ joinProgressTimer.unref?.();
343
+ }
344
+ const joinOne = async (idx) => {
345
+ const node = session.peers[idx].services.fanout;
346
+ const isRelay = relaySet.has(idx);
347
+ try {
348
+ await node.joinChannel(params.topic, rootId, {
349
+ msgRate: params.msgRate,
350
+ msgSize: params.msgSize,
351
+ ...(params.maxDataAgeMs > 0 ? { maxDataAgeMs: params.maxDataAgeMs } : {}),
352
+ uploadLimitBps: isRelay ? params.relayUploadLimitBps : 0,
353
+ maxChildren: isRelay ? params.relayMaxChildren : 0,
354
+ bidPerByte: isRelay ? params.bidPerByteRelay : params.bidPerByteLeaf,
355
+ allowKick: params.allowKick,
356
+ repair: params.repair,
357
+ repairWindowMessages: params.repairWindowMessages,
358
+ ...(params.repairMaxBackfillMessages >= 0
359
+ ? { repairMaxBackfillMessages: params.repairMaxBackfillMessages }
360
+ : {}),
361
+ repairIntervalMs: params.repairIntervalMs,
362
+ repairMaxPerReq: params.repairMaxPerReq,
363
+ neighborRepair: params.neighborRepair,
364
+ neighborRepairPeers: params.neighborRepairPeers,
365
+ ...(params.neighborMeshPeers >= 0
366
+ ? { neighborMeshPeers: params.neighborMeshPeers }
367
+ : {}),
368
+ ...(params.neighborAnnounceIntervalMs >= 0
369
+ ? { neighborAnnounceIntervalMs: params.neighborAnnounceIntervalMs }
370
+ : {}),
371
+ ...(params.neighborMeshRefreshIntervalMs >= 0
372
+ ? { neighborMeshRefreshIntervalMs: params.neighborMeshRefreshIntervalMs }
373
+ : {}),
374
+ ...(params.neighborHaveTtlMs >= 0
375
+ ? { neighborHaveTtlMs: params.neighborHaveTtlMs }
376
+ : {}),
377
+ ...(params.neighborRepairBudgetBps >= 0
378
+ ? { neighborRepairBudgetBps: params.neighborRepairBudgetBps }
379
+ : {}),
380
+ ...(params.neighborRepairBurstMs >= 0
381
+ ? { neighborRepairBurstMs: params.neighborRepairBurstMs }
382
+ : {}),
383
+ }, {
384
+ timeoutMs: Math.max(10_000, Math.min(120_000, timeoutMs || 120_000)),
385
+ ...(params.maxDataAgeMs > 0 ? { staleAfterMs: params.maxDataAgeMs } : {}),
386
+ ...(params.joinReqTimeoutMs >= 0
387
+ ? { joinReqTimeoutMs: params.joinReqTimeoutMs }
388
+ : {}),
389
+ ...(params.candidateShuffleTopK >= 0
390
+ ? { candidateShuffleTopK: params.candidateShuffleTopK }
391
+ : {}),
392
+ candidateScoringMode: params.candidateScoringMode,
393
+ ...(params.bootstrapEnsureIntervalMs >= 0
394
+ ? { bootstrapEnsureIntervalMs: params.bootstrapEnsureIntervalMs }
395
+ : {}),
396
+ ...(params.trackerQueryIntervalMs >= 0
397
+ ? { trackerQueryIntervalMs: params.trackerQueryIntervalMs }
398
+ : {}),
399
+ ...(params.joinAttemptsPerRound >= 0
400
+ ? { joinAttemptsPerRound: params.joinAttemptsPerRound }
401
+ : {}),
402
+ ...(params.candidateCooldownMs >= 0
403
+ ? { candidateCooldownMs: params.candidateCooldownMs }
404
+ : {}),
405
+ signal: timeoutSignal,
406
+ bootstrapMaxPeers: params.bootstrapMaxPeers,
407
+ });
408
+ return true;
409
+ }
410
+ catch {
411
+ return false;
412
+ }
413
+ };
414
+ const runPhase = async (indices) => {
415
+ const tasks = indices.map((pos) => async () => {
416
+ const ok = await joinOne(subscriberIndices[pos]);
417
+ joined[pos] = ok;
418
+ if (ok) {
419
+ joinOk += 1;
420
+ attachDurationsByPos[pos] = Date.now() - joinStart;
421
+ }
422
+ joinCompleted += 1;
423
+ return ok;
424
+ });
425
+ await runWithConcurrency(tasks, params.joinConcurrency);
426
+ };
427
+ try {
428
+ if (params.joinPhases) {
429
+ const relayPositions = [];
430
+ const leafPositions = [];
431
+ for (let i = 0; i < subscriberIndices.length; i++) {
432
+ const idx = subscriberIndices[i];
433
+ if (relaySet.has(idx))
434
+ relayPositions.push(i);
435
+ else
436
+ leafPositions.push(i);
437
+ }
438
+ await runPhase(relayPositions);
439
+ const settleMs = Math.max(0, Math.floor(params.joinPhaseSettleMs));
440
+ if (settleMs > 0) {
441
+ await delay(settleMs, { signal: timeoutSignal });
442
+ }
443
+ await runPhase(leafPositions);
444
+ }
445
+ else {
446
+ await runPhase(subscriberIndices.map((_, i) => i));
447
+ }
448
+ }
449
+ finally {
450
+ if (joinProgressTimer)
451
+ clearInterval(joinProgressTimer);
452
+ }
453
+ const joinDone = Date.now();
454
+ if (params.progress) {
455
+ const mu = process.memoryUsage();
456
+ const rssMb = mu.rss / (1024 * 1024);
457
+ const heapUsedMb = mu.heapUsed / (1024 * 1024);
458
+ const openConns = Math.max(0, network.metrics.connectionsOpened - network.metrics.connectionsClosed);
459
+ console.log(`[fanout-tree-sim] phase=join_done ok=${joinOk}/${subscriberIndices.length} openConns=${openConns} rssMb=${rssMb.toFixed(1)} heapUsedMb=${heapUsedMb.toFixed(1)} joinMs=${joinDone - joinStart}`);
460
+ }
461
+ const attachDurations = attachDurationsByPos.filter((d) => d >= 0).sort((a, b) => a - b);
462
+ const attachSamples = attachDurations.length;
463
+ const attachP50 = attachSamples > 0 ? quantile(attachDurations, 0.5) : NaN;
464
+ const attachP95 = attachSamples > 0 ? quantile(attachDurations, 0.95) : NaN;
465
+ const attachP99 = attachSamples > 0 ? quantile(attachDurations, 0.99) : NaN;
466
+ const attachMax = attachSamples > 0 ? attachDurations[attachSamples - 1] : NaN;
467
+ const joinedHashes = new Set(subscriberIndices
468
+ .filter((_, i) => joined[i])
469
+ .map((i) => session.peers[i].services.fanout.publicKeyHash));
470
+ const joinedCount = joinedHashes.size;
471
+ const joinedSubscriberIndices = subscriberIndices.filter((_, i) => joined[i]);
472
+ const computeTreeShapeStats = () => {
473
+ const levels = [];
474
+ const levelByIndex = new Array(params.nodes).fill(NaN);
475
+ const childrenCounts = [];
476
+ let treeOrphans = 0;
477
+ let treeRootChildren = 0;
478
+ for (let i = 0; i < session.peers.length; i++) {
479
+ const s = session.peers[i].services.fanout.getChannelStats(params.topic, rootId);
480
+ if (!s)
481
+ continue;
482
+ if (Number.isFinite(s.level)) {
483
+ levels.push(s.level);
484
+ levelByIndex[i] = s.level;
485
+ }
486
+ if (s.effectiveMaxChildren > 0) {
487
+ childrenCounts.push(s.children);
488
+ }
489
+ if (s.level === 0) {
490
+ treeRootChildren = s.children;
491
+ }
492
+ else if (Number.isFinite(s.level) && !s.parent) {
493
+ treeOrphans += 1;
494
+ }
495
+ }
496
+ levels.sort((a, b) => a - b);
497
+ childrenCounts.sort((a, b) => a - b);
498
+ const treeMaxLevel = levels.length > 0 ? levels[levels.length - 1] : 0;
499
+ const treeLevelP95 = levels.length > 0 ? quantile(levels, 0.95) : 0;
500
+ const treeLevelAvg = levels.length > 0 ? levels.reduce((a, b) => a + b, 0) / levels.length : 0;
501
+ const treeChildrenP95 = childrenCounts.length > 0 ? quantile(childrenCounts, 0.95) : 0;
502
+ const treeChildrenMax = childrenCounts.length > 0 ? childrenCounts[childrenCounts.length - 1] : 0;
503
+ return {
504
+ treeMaxLevel,
505
+ treeLevelP95,
506
+ treeLevelAvg,
507
+ treeOrphans,
508
+ treeChildrenP95,
509
+ treeChildrenMax,
510
+ treeRootChildren,
511
+ levelByIndex,
512
+ };
513
+ };
514
+ const formationTree = computeTreeShapeStats();
515
+ // Underlay (libp2p connection graph) shortest paths, used to spot wasted
516
+ // open connections that don't contribute to the overlay tree.
517
+ const underlayAdj = Array.from({ length: params.nodes }, () => new Set());
518
+ for (let i = 0; i < session.peers.length; i++) {
519
+ for (const c of session.peers[i].getConnections()) {
520
+ // @ts-ignore - bench shim uses the same field name as real libp2p connections
521
+ if (c.status && c.status !== "open")
522
+ continue;
523
+ const j = parseSimPeerIndex(c.remotePeer);
524
+ if (j === i)
525
+ continue;
526
+ if (j < 0 || j >= params.nodes)
527
+ continue;
528
+ underlayAdj[i].add(j);
529
+ }
530
+ }
531
+ let formationUnderlayEdges = 0;
532
+ for (const s of underlayAdj)
533
+ formationUnderlayEdges += s.size;
534
+ formationUnderlayEdges = Math.floor(formationUnderlayEdges / 2);
535
+ const underlayDist = new Array(params.nodes).fill(Infinity);
536
+ const q = [];
537
+ underlayDist[rootIndex] = 0;
538
+ q.push(rootIndex);
539
+ for (let qi = 0; qi < q.length; qi++) {
540
+ const u = q[qi];
541
+ const du = underlayDist[u];
542
+ for (const v of underlayAdj[u]) {
543
+ if (underlayDist[v] !== Infinity)
544
+ continue;
545
+ underlayDist[v] = du + 1;
546
+ q.push(v);
547
+ }
548
+ }
549
+ const formationUnderlayDists = joinedSubscriberIndices
550
+ .map((i) => underlayDist[i])
551
+ .filter((d) => Number.isFinite(d))
552
+ .sort((a, b) => a - b);
553
+ const formationUnderlayDistP95 = formationUnderlayDists.length > 0 ? quantile(formationUnderlayDists, 0.95) : NaN;
554
+ const formationUnderlayDistMax = formationUnderlayDists.length > 0
555
+ ? formationUnderlayDists[formationUnderlayDists.length - 1]
556
+ : NaN;
557
+ const formationStretches = joinedSubscriberIndices
558
+ .map((i) => {
559
+ const overlay = formationTree.levelByIndex[i];
560
+ const under = underlayDist[i];
561
+ if (!Number.isFinite(overlay) || !Number.isFinite(under) || under <= 0)
562
+ return NaN;
563
+ return overlay / under;
564
+ })
565
+ .filter((x) => Number.isFinite(x))
566
+ .sort((a, b) => a - b);
567
+ const formationStretchP95 = formationStretches.length > 0 ? quantile(formationStretches, 0.95) : NaN;
568
+ const formationStretchMax = formationStretches.length > 0 ? formationStretches[formationStretches.length - 1] : NaN;
569
+ const formationOrphanPct = joinedCount === 0 ? 0 : (100 * formationTree.treeOrphans) / joinedCount;
570
+ const formationStretchPenalty = Number.isFinite(formationStretchP95)
571
+ ? Math.max(0, formationStretchP95 - 1) * 10
572
+ : 0;
573
+ const formationScore = (Number.isFinite(attachP95) ? attachP95 / 1000 : 0) +
574
+ formationTree.treeLevelP95 +
575
+ formationOrphanPct +
576
+ formationStretchPenalty;
577
+ const churnController = new AbortController();
578
+ const churnSignal = anySignal([timeoutSignal, churnController.signal]);
579
+ let churnEvents = 0;
580
+ let churnedPeersTotal = 0;
581
+ const wantsMaintenance = (params.churnEveryMs > 0 && params.churnDownMs > 0 && params.churnFraction > 0) ||
582
+ params.assertMaxOrphans > 0 ||
583
+ params.assertMaxOrphanArea > 0 ||
584
+ params.assertRecoveryP95Ms > 0 ||
585
+ params.assertMaxReparentsPerMin > 0;
586
+ const maintenanceController = new AbortController();
587
+ const maintenanceSignal = anySignal([
588
+ timeoutSignal,
589
+ maintenanceController.signal,
590
+ ]);
591
+ let maintDurationMs = 0;
592
+ let maintSamples = 0;
593
+ let maintMaxOrphans = 0;
594
+ let maintOrphanAreaMs = 0;
595
+ const pendingRecoveryStarts = [];
596
+ const recoveryDurations = [];
597
+ let maintLevelP95DriftMax = 0;
598
+ let maintChildrenP95DriftMax = 0;
599
+ const reparentBaselineByHash = new Map();
600
+ let maintReparentsTotal = 0;
601
+ let maintMaxReparentsPerPeer = 0;
602
+ // Delivery tracking
603
+ const publishAt = new Map();
604
+ const joinedHashList = [...joinedHashes];
605
+ const hashToIndex = new Map();
606
+ for (let i = 0; i < joinedHashList.length; i++) {
607
+ hashToIndex.set(joinedHashList[i], i);
608
+ }
609
+ const bitsetBytes = Math.ceil(Math.max(0, params.messages) / 8);
610
+ const receivedBits = joinedHashList.map(() => new Uint8Array(bitsetBytes));
611
+ const receivedCounts = new Uint32Array(joinedHashList.length);
612
+ let duplicates = 0;
613
+ let deliveredWithinDeadline = 0;
614
+ let deliveredSamples = [];
615
+ const sampleCap = Math.max(1, Math.floor(params.maxLatencySamples));
616
+ let sampleSeen = 0;
617
+ const makeOnData = (localHash) => (ev) => {
618
+ const d = ev?.detail;
619
+ if (!d)
620
+ return;
621
+ if (d.topic !== params.topic)
622
+ return;
623
+ if (d.root !== rootId)
624
+ return;
625
+ const seq = d.seq >>> 0;
626
+ const index = hashToIndex.get(localHash);
627
+ if (index == null)
628
+ return; // not joined / not tracked
629
+ if (seq >= params.messages)
630
+ return;
631
+ const bits = receivedBits[index];
632
+ const byteIndex = seq >>> 3;
633
+ const mask = 1 << (seq & 7);
634
+ if (byteIndex >= bits.length)
635
+ return;
636
+ if ((bits[byteIndex] & mask) !== 0) {
637
+ duplicates += 1;
638
+ return;
639
+ }
640
+ bits[byteIndex] |= mask;
641
+ receivedCounts[index] += 1;
642
+ const sentAt = publishAt.get(seq);
643
+ if (sentAt != null) {
644
+ const latency = Date.now() - sentAt;
645
+ if (params.deadlineMs > 0 && latency <= params.deadlineMs) {
646
+ deliveredWithinDeadline += 1;
647
+ }
648
+ sampleSeen += 1;
649
+ if (deliveredSamples.length < sampleCap) {
650
+ deliveredSamples.push(latency);
651
+ }
652
+ else {
653
+ const j = int(rng, sampleSeen);
654
+ if (j < sampleCap)
655
+ deliveredSamples[j] = latency;
656
+ }
657
+ }
658
+ };
659
+ for (let i = 0; i < subscriberIndices.length; i++) {
660
+ if (!joined[i])
661
+ continue;
662
+ const idx = subscriberIndices[i];
663
+ const node = session.peers[idx].services.fanout;
664
+ const localHash = node.publicKeyHash;
665
+ node.addEventListener("fanout:data", makeOnData(localHash));
666
+ }
667
+ // Publish
668
+ const payload = new Uint8Array(Math.max(0, params.msgSize));
669
+ for (let i = 0; i < payload.length; i++)
670
+ payload[i] = i & 0xff;
671
+ const quantileFromCounts = (counts, maxValue, total, q) => {
672
+ if (total <= 0)
673
+ return 0;
674
+ const target = Math.min(total - 1, Math.max(0, Math.floor(q * (total - 1))));
675
+ let seen = 0;
676
+ for (let v = 0; v <= maxValue; v++) {
677
+ seen += counts[v] ?? 0;
678
+ if (seen > target)
679
+ return v;
680
+ }
681
+ return maxValue;
682
+ };
683
+ const levelCounts = [];
684
+ const childCounts = [];
685
+ const sampleMaintenance = (now) => {
686
+ let onlineJoined = 0;
687
+ let orphansOnline = 0;
688
+ let levelsTotal = 0;
689
+ let levelsMax = 0;
690
+ levelCounts.fill(0);
691
+ let childrenTotal = 0;
692
+ let childrenMax = 0;
693
+ childCounts.fill(0);
694
+ for (const idx of joinedSubscriberIndices) {
695
+ const peer = session.peers[idx];
696
+ if (network.isPeerOffline(peer.peerId, now))
697
+ continue;
698
+ onlineJoined += 1;
699
+ const s = peer.services.fanout.getChannelStats(params.topic, rootId);
700
+ if (!s) {
701
+ orphansOnline += 1;
702
+ continue;
703
+ }
704
+ if (Number.isFinite(s.level)) {
705
+ const lvl = Math.max(0, Math.floor(s.level));
706
+ levelsMax = Math.max(levelsMax, lvl);
707
+ while (levelCounts.length <= lvl)
708
+ levelCounts.push(0);
709
+ levelCounts[lvl] = (levelCounts[lvl] ?? 0) + 1;
710
+ levelsTotal += 1;
711
+ }
712
+ if (s.effectiveMaxChildren > 0) {
713
+ const c = Math.max(0, Math.floor(s.children));
714
+ childrenMax = Math.max(childrenMax, c);
715
+ while (childCounts.length <= c)
716
+ childCounts.push(0);
717
+ childCounts[c] = (childCounts[c] ?? 0) + 1;
718
+ childrenTotal += 1;
719
+ }
720
+ if (s.level > 0 && !s.parent) {
721
+ orphansOnline += 1;
722
+ }
723
+ }
724
+ const levelP95 = levelsTotal > 0
725
+ ? quantileFromCounts(levelCounts, levelsMax, levelsTotal, 0.95)
726
+ : NaN;
727
+ const childrenP95 = childrenTotal > 0
728
+ ? quantileFromCounts(childCounts, childrenMax, childrenTotal, 0.95)
729
+ : NaN;
730
+ return {
731
+ onlineJoined,
732
+ orphansOnline,
733
+ levelP95,
734
+ childrenP95,
735
+ };
736
+ };
737
+ const maintenanceLoop = async () => {
738
+ if (!wantsMaintenance)
739
+ return;
740
+ const sampleEveryMs = Math.max(100, Math.min(1_000, Math.floor(params.nodes / 10)));
741
+ const baselineOrphans = formationTree.treeOrphans;
742
+ const baselineLevelP95 = formationTree.treeLevelP95;
743
+ const baselineChildrenP95 = formationTree.treeChildrenP95;
744
+ let lastAt = Date.now();
745
+ let lastOrphans = 0;
746
+ const startAt = lastAt;
747
+ try {
748
+ for (;;) {
749
+ if (maintenanceSignal.aborted)
750
+ return;
751
+ const now = Date.now();
752
+ const dt = Math.max(0, now - lastAt);
753
+ if (dt > 0) {
754
+ maintOrphanAreaMs += lastOrphans * dt;
755
+ }
756
+ lastAt = now;
757
+ const snap = sampleMaintenance(now);
758
+ lastOrphans = snap.orphansOnline;
759
+ maintMaxOrphans = Math.max(maintMaxOrphans, snap.orphansOnline);
760
+ maintSamples += 1;
761
+ if (Number.isFinite(snap.levelP95)) {
762
+ maintLevelP95DriftMax = Math.max(maintLevelP95DriftMax, Math.abs(snap.levelP95 - baselineLevelP95));
763
+ }
764
+ if (Number.isFinite(snap.childrenP95)) {
765
+ maintChildrenP95DriftMax = Math.max(maintChildrenP95DriftMax, Math.abs(snap.childrenP95 - baselineChildrenP95));
766
+ }
767
+ if (snap.orphansOnline <= baselineOrphans &&
768
+ pendingRecoveryStarts.length > 0) {
769
+ for (const s of pendingRecoveryStarts)
770
+ recoveryDurations.push(now - s);
771
+ pendingRecoveryStarts.length = 0;
772
+ }
773
+ await delay(sampleEveryMs, { signal: maintenanceSignal });
774
+ }
775
+ }
776
+ finally {
777
+ const endAt = Date.now();
778
+ const dt = Math.max(0, endAt - lastAt);
779
+ if (dt > 0) {
780
+ maintOrphanAreaMs += lastOrphans * dt;
781
+ }
782
+ maintDurationMs = Math.max(0, endAt - startAt);
783
+ for (const s of pendingRecoveryStarts)
784
+ recoveryDurations.push(endAt - s);
785
+ pendingRecoveryStarts.length = 0;
786
+ }
787
+ };
788
+ const churnLoop = async () => {
789
+ const everyMs = Math.max(0, Math.floor(params.churnEveryMs));
790
+ const downMs = Math.max(0, Math.floor(params.churnDownMs));
791
+ const fraction = Math.max(0, Math.min(1, Number(params.churnFraction)));
792
+ if (everyMs <= 0 || downMs <= 0 || fraction <= 0)
793
+ return;
794
+ if (joinedSubscriberIndices.length === 0)
795
+ return;
796
+ for (;;) {
797
+ if (churnSignal.aborted)
798
+ return;
799
+ await delay(everyMs, { signal: churnSignal });
800
+ if (churnSignal.aborted)
801
+ return;
802
+ const target = Math.min(joinedSubscriberIndices.length, Math.max(1, Math.floor(joinedSubscriberIndices.length * fraction)));
803
+ const chosen = new Set();
804
+ const maxAttempts = Math.max(10, target * 20);
805
+ for (let tries = 0; chosen.size < target && tries < maxAttempts; tries++) {
806
+ const idx = joinedSubscriberIndices[int(rng, joinedSubscriberIndices.length)];
807
+ const peer = session.peers[idx];
808
+ if (network.isPeerOffline(peer.peerId))
809
+ continue;
810
+ chosen.add(idx);
811
+ }
812
+ if (chosen.size === 0)
813
+ continue;
814
+ churnEvents += 1;
815
+ churnedPeersTotal += chosen.size;
816
+ const now = Date.now();
817
+ if (wantsMaintenance)
818
+ pendingRecoveryStarts.push(now);
819
+ await Promise.all([...chosen].map(async (idx) => {
820
+ const peer = session.peers[idx];
821
+ network.setPeerOffline(peer.peerId, downMs, now);
822
+ await network.disconnectPeer(peer.peerId);
823
+ }));
824
+ }
825
+ };
826
+ const publishStart = Date.now();
827
+ if (wantsMaintenance) {
828
+ for (const p of session.peers) {
829
+ const nodeHash = p.services.fanout.publicKeyHash;
830
+ const m = p.services.fanout.getChannelMetrics(params.topic, rootId);
831
+ reparentBaselineByHash.set(nodeHash, m.reparentDisconnect + m.reparentStale + m.reparentKicked);
832
+ }
833
+ }
834
+ const maintenancePromise = maintenanceLoop().catch(() => { });
835
+ const churnPromise = churnLoop().catch(() => { });
836
+ try {
837
+ for (let seq = 0; seq < params.messages; seq++) {
838
+ if (timeoutSignal.aborted) {
839
+ throw timeoutSignal.reason ?? new Error("fanout-tree-sim aborted");
840
+ }
841
+ publishAt.set(seq, Date.now());
842
+ await root.publishData(params.topic, rootId, payload);
843
+ if (params.intervalMs > 0) {
844
+ await delay(params.intervalMs, { signal: timeoutSignal });
845
+ }
846
+ }
847
+ }
848
+ finally {
849
+ churnController.abort();
850
+ await churnPromise;
851
+ churnSignal.clear?.();
852
+ }
853
+ const publishDone = Date.now();
854
+ // Signal end-of-stream so subscribers can detect tail gaps and repair.
855
+ if (params.repair && params.messages > 0) {
856
+ await root.publishEnd(params.topic, rootId, params.messages);
857
+ }
858
+ if (params.settleMs > 0) {
859
+ await delay(params.settleMs, { signal: timeoutSignal });
860
+ }
861
+ maintenanceController.abort();
862
+ await maintenancePromise;
863
+ maintenanceSignal.clear?.();
864
+ recoveryDurations.sort((a, b) => a - b);
865
+ const maintRecoveryCount = recoveryDurations.length;
866
+ const maintRecoveryP50Ms = maintRecoveryCount > 0 ? quantile(recoveryDurations, 0.5) : 0;
867
+ const maintRecoveryP95Ms = maintRecoveryCount > 0 ? quantile(recoveryDurations, 0.95) : 0;
868
+ if (wantsMaintenance) {
869
+ for (const p of session.peers) {
870
+ const nodeHash = p.services.fanout.publicKeyHash;
871
+ const m = p.services.fanout.getChannelMetrics(params.topic, rootId);
872
+ const total = m.reparentDisconnect + m.reparentStale + m.reparentKicked;
873
+ const base = reparentBaselineByHash.get(nodeHash) ?? 0;
874
+ const delta = Math.max(0, total - base);
875
+ maintReparentsTotal += delta;
876
+ maintMaxReparentsPerPeer = Math.max(maintMaxReparentsPerPeer, delta);
877
+ }
878
+ }
879
+ const maintDurationMin = maintDurationMs / 60_000;
880
+ const maintReparentsPerMin = maintDurationMin > 0 ? maintReparentsTotal / maintDurationMin : 0;
881
+ const maintOrphanArea = maintOrphanAreaMs / 1_000;
882
+ // Compute delivery
883
+ const expected = joinedCount * params.messages;
884
+ let delivered = 0;
885
+ for (const c of receivedCounts)
886
+ delivered += c;
887
+ const joinedPct = subscriberCount === 0 ? 100 : (100 * joinedCount) / subscriberCount;
888
+ const deliveredPct = expected === 0 ? 100 : (100 * delivered) / expected;
889
+ const deliveredWithinDeadlinePct = expected === 0 ? 100 : (100 * deliveredWithinDeadline) / expected;
890
+ // Internal protocol drops (from upload shaping / overload logic)
891
+ let droppedForwardsTotal = 0;
892
+ let droppedForwardsMax = 0;
893
+ let droppedForwardsMaxNode;
894
+ for (const p of session.peers) {
895
+ const s = p.services.fanout.getChannelStats(params.topic, rootId);
896
+ if (!s)
897
+ continue;
898
+ droppedForwardsTotal += s.droppedForwards;
899
+ if (s.droppedForwards > droppedForwardsMax) {
900
+ droppedForwardsMax = s.droppedForwards;
901
+ droppedForwardsMaxNode = p.services.fanout.publicKeyHash;
902
+ }
903
+ }
904
+ // Tree shape stats (best-effort)
905
+ const tree = computeTreeShapeStats();
906
+ const treeMaxLevel = tree.treeMaxLevel;
907
+ const treeLevelP95 = tree.treeLevelP95;
908
+ const treeLevelAvg = tree.treeLevelAvg;
909
+ const treeOrphans = tree.treeOrphans;
910
+ const treeChildrenP95 = tree.treeChildrenP95;
911
+ const treeChildrenMax = tree.treeChildrenMax;
912
+ const treeRootChildren = tree.treeRootChildren;
913
+ // Stream backpressure stats (queued bytes)
914
+ const queuedBytesSamples = [];
915
+ let streamQueuedBytesTotal = 0;
916
+ let streamQueuedBytesMax = 0;
917
+ let streamQueuedBytesMaxNode;
918
+ for (const p of session.peers) {
919
+ const q = Math.max(0, Math.floor(p.services.fanout.getQueuedBytes()));
920
+ streamQueuedBytesTotal += q;
921
+ queuedBytesSamples.push(q);
922
+ if (q > streamQueuedBytesMax) {
923
+ streamQueuedBytesMax = q;
924
+ streamQueuedBytesMaxNode = p.services.fanout.publicKeyHash;
925
+ }
926
+ }
927
+ queuedBytesSamples.sort((a, b) => a - b);
928
+ const streamQueuedBytesP95 = queuedBytesSamples.length > 0 ? quantile(queuedBytesSamples, 0.95) : 0;
929
+ const streamQueuedBytesByLane = [];
930
+ for (const p of session.peers) {
931
+ for (const ps of p.services.fanout.peers.values()) {
932
+ const byLane =
933
+ // @ts-ignore - optional debug helper (may not exist in built typings yet)
934
+ ps.getOutboundQueuedBytesByLane?.() ?? [0, 0, 0, 0];
935
+ for (let lane = 0; lane < byLane.length; lane++) {
936
+ streamQueuedBytesByLane[lane] =
937
+ (streamQueuedBytesByLane[lane] ?? 0) + (byLane[lane] ?? 0);
938
+ }
939
+ }
940
+ }
941
+ for (let lane = 0; lane < 4; lane++) {
942
+ streamQueuedBytesByLane[lane] = streamQueuedBytesByLane[lane] ?? 0;
943
+ }
944
+ // Peak upload vs cap (best-effort; counts framed bytes, including overhead)
945
+ const uploadCapByHash = new Map();
946
+ if (params.rootUploadLimitBps > 0) {
947
+ uploadCapByHash.set(root.publicKeyHash, params.rootUploadLimitBps);
948
+ }
949
+ for (let i = 0; i < subscriberIndices.length; i++) {
950
+ if (!joined[i])
951
+ continue;
952
+ const idx = subscriberIndices[i];
953
+ if (!relaySet.has(idx))
954
+ continue;
955
+ if (params.relayUploadLimitBps <= 0)
956
+ continue;
957
+ const h = session.peers[idx].services.fanout.publicKeyHash;
958
+ uploadCapByHash.set(h, params.relayUploadLimitBps);
959
+ }
960
+ let maxUploadFracPct = 0;
961
+ let maxUploadNode;
962
+ let maxUploadBps = 0;
963
+ for (const [hash, pm] of session.network.peerMetricsByHash) {
964
+ const cap = uploadCapByHash.get(hash);
965
+ if (!cap || cap <= 0)
966
+ continue;
967
+ const frac = (100 * pm.maxBytesPerSecond) / cap;
968
+ if (frac > maxUploadFracPct) {
969
+ maxUploadFracPct = frac;
970
+ maxUploadNode = hash;
971
+ maxUploadBps = pm.maxBytesPerSecond;
972
+ }
973
+ }
974
+ deliveredSamples.sort((a, b) => a - b);
975
+ let protocolControlSends = 0;
976
+ let protocolControlBytesSent = 0;
977
+ let protocolControlBytesSentJoin = 0;
978
+ let protocolControlBytesSentRepair = 0;
979
+ let protocolControlBytesSentTracker = 0;
980
+ let protocolControlReceives = 0;
981
+ let protocolControlBytesReceived = 0;
982
+ let protocolDataSends = 0;
983
+ let protocolDataPayloadBytesSent = 0;
984
+ let protocolDataReceives = 0;
985
+ let protocolDataPayloadBytesReceived = 0;
986
+ let protocolRepairReqSent = 0;
987
+ let protocolFetchReqSent = 0;
988
+ let protocolIHaveSent = 0;
989
+ let protocolTrackerFeedbackSent = 0;
990
+ let protocolCacheHitsServed = 0;
991
+ let protocolHoleFillsFromNeighbor = 0;
992
+ let protocolRouteCacheHits = 0;
993
+ let protocolRouteCacheMisses = 0;
994
+ let protocolRouteCacheExpirations = 0;
995
+ let protocolRouteCacheEvictions = 0;
996
+ let protocolRouteProxyQueries = 0;
997
+ let protocolRouteProxyTimeouts = 0;
998
+ let protocolRouteProxyFanout = 0;
999
+ let staleForwardsDroppedTotal = 0;
1000
+ let staleForwardsDroppedMax = 0;
1001
+ let staleForwardsDroppedMaxNode;
1002
+ let dataWriteDropsTotal = 0;
1003
+ let dataWriteDropsMax = 0;
1004
+ let dataWriteDropsMaxNode;
1005
+ let reparentDisconnectTotal = 0;
1006
+ let reparentStaleTotal = 0;
1007
+ let reparentKickedTotal = 0;
1008
+ let earningsTotal = 0;
1009
+ const earningsByHash = new Map();
1010
+ for (const p of session.peers) {
1011
+ const nodeHash = p.services.fanout.publicKeyHash;
1012
+ const m = p.services.fanout.getChannelMetrics(params.topic, rootId);
1013
+ staleForwardsDroppedTotal += m.staleForwardsDropped;
1014
+ if (m.staleForwardsDropped > staleForwardsDroppedMax) {
1015
+ staleForwardsDroppedMax = m.staleForwardsDropped;
1016
+ staleForwardsDroppedMaxNode = nodeHash;
1017
+ }
1018
+ dataWriteDropsTotal += m.dataWriteDrops;
1019
+ if (m.dataWriteDrops > dataWriteDropsMax) {
1020
+ dataWriteDropsMax = m.dataWriteDrops;
1021
+ dataWriteDropsMaxNode = nodeHash;
1022
+ }
1023
+ reparentDisconnectTotal += m.reparentDisconnect;
1024
+ reparentStaleTotal += m.reparentStale;
1025
+ reparentKickedTotal += m.reparentKicked;
1026
+ earningsTotal += m.earnings;
1027
+ earningsByHash.set(nodeHash, m.earnings);
1028
+ protocolControlSends += m.controlSends;
1029
+ protocolControlBytesSent += m.controlBytesSent;
1030
+ protocolControlBytesSentJoin += m.controlBytesSentJoin;
1031
+ protocolControlBytesSentRepair += m.controlBytesSentRepair;
1032
+ protocolControlBytesSentTracker += m.controlBytesSentTracker;
1033
+ protocolControlReceives += m.controlReceives;
1034
+ protocolControlBytesReceived += m.controlBytesReceived;
1035
+ protocolDataSends += m.dataSends;
1036
+ protocolDataPayloadBytesSent += m.dataPayloadBytesSent;
1037
+ protocolDataReceives += m.dataReceives;
1038
+ protocolDataPayloadBytesReceived += m.dataPayloadBytesReceived;
1039
+ protocolRepairReqSent += m.repairReqSent;
1040
+ protocolFetchReqSent += m.fetchReqSent;
1041
+ protocolIHaveSent += m.ihaveSent;
1042
+ protocolTrackerFeedbackSent += m.trackerFeedbackSent;
1043
+ protocolCacheHitsServed += m.cacheHitsServed;
1044
+ protocolHoleFillsFromNeighbor += m.holeFillsFromNeighbor;
1045
+ protocolRouteCacheHits += m.routeCacheHits;
1046
+ protocolRouteCacheMisses += m.routeCacheMisses;
1047
+ protocolRouteCacheExpirations += m.routeCacheExpirations;
1048
+ protocolRouteCacheEvictions += m.routeCacheEvictions;
1049
+ protocolRouteProxyQueries += m.routeProxyQueries;
1050
+ protocolRouteProxyTimeouts += m.routeProxyTimeouts;
1051
+ protocolRouteProxyFanout += m.routeProxyFanout;
1052
+ }
1053
+ const relayHashes = [...uploadCapByHash.keys()];
1054
+ const relayEarnings = relayHashes.map((h) => earningsByHash.get(h) ?? 0).sort((a, b) => a - b);
1055
+ const earningsRelayCount = relayEarnings.length;
1056
+ const earningsRelayP50 = earningsRelayCount > 0 ? quantile(relayEarnings, 0.5) : 0;
1057
+ const earningsRelayP95 = earningsRelayCount > 0 ? quantile(relayEarnings, 0.95) : 0;
1058
+ const earningsRelayMax = earningsRelayCount > 0 ? relayEarnings[relayEarnings.length - 1] : 0;
1059
+ const idealPayloadBytes = expected * Math.max(0, params.msgSize);
1060
+ const overheadFactorData = idealPayloadBytes <= 0
1061
+ ? 1
1062
+ : protocolDataPayloadBytesSent / idealPayloadBytes;
1063
+ const deliveredPayloadBytes = delivered * Math.max(0, params.msgSize);
1064
+ const controlBpp = deliveredPayloadBytes <= 0 ? 0 : protocolControlBytesSent / deliveredPayloadBytes;
1065
+ const trackerBpp = deliveredPayloadBytes <= 0
1066
+ ? 0
1067
+ : protocolControlBytesSentTracker / deliveredPayloadBytes;
1068
+ const repairBpp = deliveredPayloadBytes <= 0
1069
+ ? 0
1070
+ : protocolControlBytesSentRepair / deliveredPayloadBytes;
1071
+ return {
1072
+ params,
1073
+ bootstrapCount: bootstrapIndices.length,
1074
+ subscriberCount,
1075
+ relayCount: relaySet.size,
1076
+ joinedCount,
1077
+ joinedPct,
1078
+ joinMs: joinDone - joinStart,
1079
+ attachSamples,
1080
+ attachP50,
1081
+ attachP95,
1082
+ attachP99,
1083
+ attachMax,
1084
+ formationTreeMaxLevel: formationTree.treeMaxLevel,
1085
+ formationTreeLevelP95: formationTree.treeLevelP95,
1086
+ formationTreeLevelAvg: formationTree.treeLevelAvg,
1087
+ formationTreeOrphans: formationTree.treeOrphans,
1088
+ formationTreeChildrenP95: formationTree.treeChildrenP95,
1089
+ formationTreeChildrenMax: formationTree.treeChildrenMax,
1090
+ formationTreeRootChildren: formationTree.treeRootChildren,
1091
+ formationUnderlayEdges,
1092
+ formationUnderlayDistP95,
1093
+ formationUnderlayDistMax,
1094
+ formationStretchP95,
1095
+ formationStretchMax,
1096
+ formationScore,
1097
+ publishMs: publishDone - publishStart,
1098
+ expected,
1099
+ delivered,
1100
+ deliveredPct,
1101
+ deliveredWithinDeadline,
1102
+ deliveredWithinDeadlinePct,
1103
+ duplicates,
1104
+ latencySamples: deliveredSamples.length,
1105
+ latencyP50: quantile(deliveredSamples, 0.5),
1106
+ latencyP95: quantile(deliveredSamples, 0.95),
1107
+ latencyP99: quantile(deliveredSamples, 0.99),
1108
+ latencyMax: deliveredSamples[deliveredSamples.length - 1] ?? NaN,
1109
+ droppedForwardsTotal,
1110
+ droppedForwardsMax,
1111
+ droppedForwardsMaxNode,
1112
+ staleForwardsDroppedTotal,
1113
+ staleForwardsDroppedMax,
1114
+ staleForwardsDroppedMaxNode,
1115
+ dataWriteDropsTotal,
1116
+ dataWriteDropsMax,
1117
+ dataWriteDropsMaxNode,
1118
+ reparentDisconnectTotal,
1119
+ reparentStaleTotal,
1120
+ reparentKickedTotal,
1121
+ treeMaxLevel,
1122
+ treeLevelP95,
1123
+ treeLevelAvg,
1124
+ treeOrphans,
1125
+ treeChildrenP95,
1126
+ treeChildrenMax,
1127
+ treeRootChildren,
1128
+ maxUploadBps,
1129
+ maxUploadFracPct,
1130
+ maxUploadNode,
1131
+ streamQueuedBytesTotal,
1132
+ streamQueuedBytesMax,
1133
+ streamQueuedBytesP95,
1134
+ streamQueuedBytesMaxNode,
1135
+ streamQueuedBytesByLane,
1136
+ churnEvents,
1137
+ churnedPeersTotal,
1138
+ maintDurationMs,
1139
+ maintSamples,
1140
+ maintMaxOrphans,
1141
+ maintOrphanArea,
1142
+ maintRecoveryCount,
1143
+ maintRecoveryP50Ms,
1144
+ maintRecoveryP95Ms,
1145
+ maintReparentsPerMin,
1146
+ maintMaxReparentsPerPeer,
1147
+ maintLevelP95DriftMax,
1148
+ maintChildrenP95DriftMax,
1149
+ overheadFactorData,
1150
+ controlBpp,
1151
+ trackerBpp,
1152
+ repairBpp,
1153
+ earningsTotal,
1154
+ earningsRelayCount,
1155
+ earningsRelayP50,
1156
+ earningsRelayP95,
1157
+ earningsRelayMax,
1158
+ protocolControlSends,
1159
+ protocolControlBytesSent,
1160
+ protocolControlBytesSentJoin,
1161
+ protocolControlBytesSentRepair,
1162
+ protocolControlBytesSentTracker,
1163
+ protocolControlReceives,
1164
+ protocolControlBytesReceived,
1165
+ protocolDataSends,
1166
+ protocolDataPayloadBytesSent,
1167
+ protocolDataReceives,
1168
+ protocolDataPayloadBytesReceived,
1169
+ protocolRepairReqSent,
1170
+ protocolFetchReqSent,
1171
+ protocolIHaveSent,
1172
+ protocolTrackerFeedbackSent,
1173
+ protocolCacheHitsServed,
1174
+ protocolHoleFillsFromNeighbor,
1175
+ protocolRouteCacheHits,
1176
+ protocolRouteCacheMisses,
1177
+ protocolRouteCacheExpirations,
1178
+ protocolRouteCacheEvictions,
1179
+ protocolRouteProxyQueries,
1180
+ protocolRouteProxyTimeouts,
1181
+ protocolRouteProxyFanout,
1182
+ network: session.network.metrics,
1183
+ };
1184
+ };
1185
+ const result = await run();
1186
+ if (profileEnabled && profileCpuStart) {
1187
+ try {
1188
+ profileEld?.disable();
1189
+ }
1190
+ catch {
1191
+ // ignore
1192
+ }
1193
+ const cpu = process.cpuUsage(profileCpuStart);
1194
+ const mem = process.memoryUsage();
1195
+ const p95 = profileEld ? profileEld.percentile(95) / 1e6 : 0;
1196
+ const max = profileEld ? profileEld.max / 1e6 : 0;
1197
+ result.profile = {
1198
+ cpuUserMs: cpu.user / 1_000,
1199
+ cpuSystemMs: cpu.system / 1_000,
1200
+ rssMb: mem.rss / 1e6,
1201
+ heapUsedMb: mem.heapUsed / 1e6,
1202
+ eventLoopDelayP95Ms: p95,
1203
+ eventLoopDelayMaxMs: max,
1204
+ };
1205
+ }
1206
+ return result;
1207
+ }
1208
+ finally {
1209
+ if (timer)
1210
+ clearTimeout(timer);
1211
+ try {
1212
+ profileEld?.disable();
1213
+ }
1214
+ catch {
1215
+ // ignore
1216
+ }
1217
+ try {
1218
+ await session.stop();
1219
+ }
1220
+ catch {
1221
+ // ignore teardown aborts in the shim
1222
+ }
1223
+ }
1224
+ };
1225
+ //# sourceMappingURL=fanout-tree-sim-lib.js.map