Skip to content

Commit c8dfdb1

Browse files
authored
fix: make health events emission consistent (#2570)
* fix health events emission * fix bloom test flakiness due to bloom filter properties
1 parent 26de2d1 commit c8dfdb1

3 files changed

Lines changed: 155 additions & 19 deletions

File tree

packages/sdk/src/health_indicator/health_indicator.spec.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ describe("HealthIndicator", () => {
1515
libp2p = mockLibp2p();
1616
events = mockEvents();
1717
healthIndicator = new HealthIndicator({ libp2p, events });
18-
healthIndicator.start();
1918
});
2019

2120
afterEach(() => {
@@ -28,6 +27,8 @@ describe("HealthIndicator", () => {
2827
});
2928

3029
it("should transition to Unhealthy when no connections", async () => {
30+
healthIndicator.start();
31+
3132
// Only track transition, starting as healthy
3233
(healthIndicator as any).value = HealthStatus.SufficientlyHealthy;
3334

@@ -49,6 +50,8 @@ describe("HealthIndicator", () => {
4950
});
5051

5152
it("should transition to MinimallyHealthy with one compatible peer", async () => {
53+
healthIndicator.start();
54+
5255
const statusChangePromise = new Promise<HealthStatus>((resolve) => {
5356
events.addEventListener("waku:health", (e: CustomEvent<HealthStatus>) =>
5457
resolve(e.detail)
@@ -70,6 +73,8 @@ describe("HealthIndicator", () => {
7073
});
7174

7275
it("should transition to SufficientlyHealthy with multiple compatible peers", async () => {
76+
healthIndicator.start();
77+
7378
const statusChangePromise = new Promise<HealthStatus>((resolve) => {
7479
events.addEventListener("waku:health", (e: CustomEvent<HealthStatus>) =>
7580
resolve(e.detail)
@@ -110,11 +115,124 @@ describe("HealthIndicator", () => {
110115
expect(removeEventSpy.firstCall.args[0]).to.equal("peer:identify");
111116
expect(removeEventSpy.secondCall.args[0]).to.equal("peer:disconnect");
112117
});
118+
119+
it("should reassess health immediately when peer disconnects", async () => {
120+
const peer1 = mockPeer("1", [FilterCodecs.SUBSCRIBE, LightPushCodec]);
121+
const peer2 = mockPeer("2", [FilterCodecs.SUBSCRIBE, LightPushCodec]);
122+
const connection1 = mockConnection("1");
123+
const connection2 = mockConnection("2");
124+
const connections = [connection1, connection2];
125+
126+
const getConnectionsStub = sinon
127+
.stub(libp2p, "getConnections")
128+
.returns(connections);
129+
const peerStoreStub = sinon.stub(libp2p.peerStore, "get");
130+
peerStoreStub.withArgs(connection1.remotePeer).resolves(peer1);
131+
peerStoreStub.withArgs(connection2.remotePeer).resolves(peer2);
132+
133+
const statusChangePromise = new Promise<HealthStatus>((resolve) => {
134+
events.addEventListener("waku:health", (e: CustomEvent<HealthStatus>) =>
135+
resolve(e.detail)
136+
);
137+
});
138+
139+
healthIndicator.start();
140+
141+
await statusChangePromise;
142+
expect(healthIndicator.toValue()).to.equal(
143+
HealthStatus.SufficientlyHealthy
144+
);
145+
146+
const statusChangePromise2 = new Promise<HealthStatus>((resolve) => {
147+
events.addEventListener("waku:health", (e: CustomEvent<HealthStatus>) =>
148+
resolve(e.detail)
149+
);
150+
});
151+
152+
const remainingConnections = [connection1];
153+
getConnectionsStub.returns(remainingConnections);
154+
155+
libp2p.dispatchEvent(new CustomEvent("peer:disconnect", { detail: "2" }));
156+
157+
const changedStatus = await statusChangePromise2;
158+
expect(changedStatus).to.equal(HealthStatus.MinimallyHealthy);
159+
expect(healthIndicator.toValue()).to.equal(HealthStatus.MinimallyHealthy);
160+
});
161+
162+
it("should perform initial health assessment on start", async () => {
163+
const peer = mockPeer("1", [FilterCodecs.SUBSCRIBE, LightPushCodec]);
164+
const connections = [mockConnection("1")];
165+
sinon.stub(libp2p, "getConnections").returns(connections);
166+
sinon.stub(libp2p.peerStore, "get").resolves(peer);
167+
168+
const statusChangePromise = new Promise<HealthStatus>((resolve) => {
169+
events.addEventListener("waku:health", (e: CustomEvent<HealthStatus>) =>
170+
resolve(e.detail)
171+
);
172+
});
173+
174+
healthIndicator.start();
175+
176+
const changedStatus = await statusChangePromise;
177+
expect(changedStatus).to.equal(HealthStatus.MinimallyHealthy);
178+
expect(healthIndicator.toValue()).to.equal(HealthStatus.MinimallyHealthy);
179+
});
180+
181+
it("should handle peer store errors gracefully", async function () {
182+
this.timeout(5000);
183+
184+
// Start with a healthy state
185+
(healthIndicator as any).value = HealthStatus.SufficientlyHealthy;
186+
187+
const connections = [mockConnection("1")];
188+
sinon.stub(libp2p, "getConnections").returns(connections);
189+
sinon.stub(libp2p.peerStore, "get").rejects(new Error("Peer not found"));
190+
191+
const statusChangePromise = new Promise<HealthStatus>((resolve) => {
192+
events.addEventListener("waku:health", (e: CustomEvent<HealthStatus>) =>
193+
resolve(e.detail)
194+
);
195+
});
196+
197+
healthIndicator.start();
198+
199+
const changedStatus = await statusChangePromise;
200+
expect(changedStatus).to.equal(HealthStatus.Unhealthy);
201+
expect(healthIndicator.toValue()).to.equal(HealthStatus.Unhealthy);
202+
});
203+
204+
it("should handle mixed protocol support correctly", async () => {
205+
// Start with a healthy state to ensure status change
206+
(healthIndicator as any).value = HealthStatus.SufficientlyHealthy;
207+
208+
const peer1 = mockPeer("1", [FilterCodecs.SUBSCRIBE]);
209+
const peer2 = mockPeer("2", [LightPushCodec]);
210+
const connection1 = mockConnection("1");
211+
const connection2 = mockConnection("2");
212+
const connections = [connection1, connection2];
213+
214+
sinon.stub(libp2p, "getConnections").returns(connections);
215+
const peerStoreStub = sinon.stub(libp2p.peerStore, "get");
216+
peerStoreStub.withArgs(connection1.remotePeer).resolves(peer1);
217+
peerStoreStub.withArgs(connection2.remotePeer).resolves(peer2);
218+
219+
const statusChangePromise = new Promise<HealthStatus>((resolve) => {
220+
events.addEventListener("waku:health", (e: CustomEvent<HealthStatus>) =>
221+
resolve(e.detail)
222+
);
223+
});
224+
225+
healthIndicator.start();
226+
227+
const changedStatus = await statusChangePromise;
228+
expect(changedStatus).to.equal(HealthStatus.MinimallyHealthy);
229+
expect(healthIndicator.toValue()).to.equal(HealthStatus.MinimallyHealthy);
230+
});
113231
});
114232

115233
function mockLibp2p(): Libp2p {
116234
const peerStore = {
117-
get: (id: any) => Promise.resolve(mockPeer(id.toString(), []))
235+
get: () => Promise.reject(new Error("Peer not found"))
118236
};
119237

120238
const events = new EventTarget();

packages/sdk/src/health_indicator/health_indicator.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export class HealthIndicator implements IHealthIndicator {
4343
"peer:disconnect",
4444
this.onPeerDisconnected as PeerEvent<PeerId>
4545
);
46+
47+
void this.assessHealth();
4648
}
4749

4850
public stop(): void {
@@ -64,40 +66,42 @@ export class HealthIndicator implements IHealthIndicator {
6466

6567
private async onPeerDisconnected(_event: CustomEvent<PeerId>): Promise<void> {
6668
log.info(`onPeerDisconnected: received libp2p event`);
67-
68-
const connections = this.libp2p.getConnections();
69-
70-
// we handle only Unhealthy here and onPeerIdentify will cover other cases
71-
if (connections.length > 0) {
72-
log.info("onPeerDisconnected: has connections, ignoring");
73-
}
74-
75-
log.info(
76-
`onPeerDisconnected: node identified as ${HealthStatus.Unhealthy}`
77-
);
78-
79-
this.updateAndDispatchHealthEvent(HealthStatus.Unhealthy);
69+
await this.assessHealth();
8070
}
8171

8272
private async onPeerIdentify(
8373
_event: CustomEvent<IdentifyResult>
8474
): Promise<void> {
8575
log.info(`onPeerIdentify: received libp2p event`);
76+
await this.assessHealth();
77+
}
8678

79+
private async assessHealth(): Promise<void> {
8780
const connections = this.libp2p.getConnections();
8881

82+
if (connections.length === 0) {
83+
log.info("assessHealth: no connections, setting to Unhealthy");
84+
this.updateAndDispatchHealthEvent(HealthStatus.Unhealthy);
85+
return;
86+
}
87+
8988
const peers = await Promise.all(
9089
connections.map(async (c) => {
9190
try {
9291
return await this.libp2p.peerStore.get(c.remotePeer);
9392
} catch (e) {
93+
log.warn(
94+
`assessHealth: failed to get peer ${c.remotePeer}, skipping`
95+
);
9496
return null;
9597
}
9698
})
9799
);
100+
98101
const filterPeers = peers.filter((p) =>
99102
p?.protocols.includes(FilterCodecs.SUBSCRIBE)
100103
).length;
104+
101105
const lightPushPeers = peers.filter((p) =>
102106
p?.protocols.includes(LightPushCodec)
103107
).length;
@@ -111,12 +115,14 @@ export class HealthIndicator implements IHealthIndicator {
111115
newValue = HealthStatus.MinimallyHealthy;
112116
} else {
113117
log.error(
114-
`onPeerIdentify: unexpected state, cannot identify health status of the node: Filter:${filterPeers}; LightPush:${lightPushPeers}`
118+
`assessHealth: unexpected state, cannot identify health status of the node: Filter:${filterPeers}; LightPush:${lightPushPeers}`
115119
);
116120
newValue = this.value;
117121
}
118122

119-
log.info(`onPeerIdentify: node identified as ${newValue}`);
123+
log.info(
124+
`assessHealth: node identified as ${newValue} Filter:${filterPeers}; LightPush:${lightPushPeers}`
125+
);
120126
this.updateAndDispatchHealthEvent(newValue);
121127
}
122128

packages/sds/src/bloom_filter/bloom.spec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,13 @@ describe("BloomFilter", () => {
9292
}
9393

9494
const actualErrorRate = falsePositives / testSize;
95-
expect(actualErrorRate).to.be.lessThan(bloomFilter.errorRate * 1.5);
95+
const expectedErrorRate = bloomFilter.errorRate;
96+
const zScore = 2;
97+
const stdError = Math.sqrt(
98+
(expectedErrorRate * (1 - expectedErrorRate)) / testSize
99+
);
100+
const upperBound = expectedErrorRate + zScore * stdError;
101+
expect(actualErrorRate).to.be.lessThan(upperBound);
96102
});
97103

98104
it("should never report false negatives", () => {
@@ -153,6 +159,12 @@ describe("BloomFilter with special patterns", () => {
153159
}
154160

155161
const fpRate = falsePositives / testSize;
156-
expect(fpRate).to.be.lessThan(bloomFilter.errorRate * 1.5);
162+
const expectedErrorRate = bloomFilter.errorRate;
163+
const zScore = 2;
164+
const stdError = Math.sqrt(
165+
(expectedErrorRate * (1 - expectedErrorRate)) / testSize
166+
);
167+
const upperBound = expectedErrorRate + zScore * stdError;
168+
expect(fpRate).to.be.lessThan(upperBound);
157169
});
158170
});

0 commit comments

Comments
 (0)