Repository: siimon/prom-client (2025-07-05T13:09:04Z)

Issue:
Cluster mode does not handle load shedding properly if timeouts are exceeded.
While looking at #628, I noticed a few other problems with the cluster mode.

The request is not deleted from the requests map until all responses are returned, which may not happen if a child is restarted.

It is also processing messages from the workers even if the request is done

```
	if (message.error) {
		request.done(new Error(message.error));
		return;
	}

	message.metrics.forEach(registry => request.responses.push(registry));
	request.pending--;

	if (request.pending === 0) {
		// finalize
		requests.delete(message.requestId);
		clearTimeout(request.errorTimeout);
```

Which means all responses from workers are processed regardless of whether another request has already sent.

The clients also aren't given a deadline, which means they have no way to abort the work if it is not processed in time.

I don't recall if there's a way to avoid the `JSON.parse()` call.  Given the structure of the response that is unlikely.  

Perhaps more importantly: every time a worker fails to deliver a response at all, the responses from its siblings are left in the lookup table in perpetuity, representing a memory leak, and a rather substantial one.

This also has implications for #645 

Additionally, cluster mode does not handle benchmarking well. Multiple calls to `new AggregatorRegistry()` leads to `addListeners()` calls if more than one instance of the module is loaded - which is a frequent if unfortunate occurrence in benchmarking code. 


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc9e0ea8..388aac0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ project adheres to [Semantic Versioning](http://semver.org/).
 - Faster stats gathering with lower memory overhead
 - Simplified number format logic
 - Simplified Histogram.observe() call stack
+- Fix memory leak in cluster.js by deleting all expired requests
 
 ### Added
 
diff --git a/lib/cluster.js b/lib/cluster.js
index 6c07acfa..f3a20218 100644
--- a/lib/cluster.js
+++ b/lib/cluster.js
@@ -47,8 +47,14 @@ class AggregatorRegistry extends Registry {
 			function done(err, result) {
 				if (settled) return;
 				settled = true;
-				if (err) reject(err);
-				else resolve(result);
+
+				requests.delete(requestId);
+
+				if (err !== undefined) {
+					reject(err);
+				} else {
+					resolve(result);
+				}
 			}
 
 			const request = {
@@ -79,7 +85,7 @@ class AggregatorRegistry extends Registry {
 			if (request.pending === 0) {
 				// No workers were up
 				clearTimeout(request.errorTimeout);
-				process.nextTick(() => done(null, ''));
+				process.nextTick(() => done(undefined, ''));
 			}
 		});
 	}
@@ -171,6 +177,10 @@ function addListeners() {
 			if (message.type === GET_METRICS_RES) {
 				const request = requests.get(message.requestId);
 
+				if (request === undefined) {
+					return;
+				}
+
 				if (message.error) {
 					request.done(new Error(message.error));
 					return;
@@ -181,12 +191,11 @@ function addListeners() {
 
 				if (request.pending === 0) {
 					// finalize
-					requests.delete(message.requestId);
 					clearTimeout(request.errorTimeout);
 
 					const registry = AggregatorRegistry.aggregate(request.responses);
 					const promString = registry.metrics();
-					request.done(null, promString);
+					request.done(undefined, promString);
 				}
 			}
 		});


Test patch:
diff --git a/test/clusterTest.js b/test/clusterTest.js
index d1a58314..4b9f5aa5 100644
--- a/test/clusterTest.js
+++ b/test/clusterTest.js
@@ -254,4 +254,21 @@ describe.each([
 			});
 		});
 	});
+
+	describe('message handling', () => {
+		it('does not error out on unexpected (or late) responses', () => {
+			jest.resetModules();
+
+			require('../lib/cluster');
+
+			//Emulate a response that has been deleted from requests
+			const unexpected = {
+				type: 'prom-client:getMetricsRes',
+				metrics: ['{}'],
+				requestId: -3,
+			};
+
+			expect(() => cluster.emit('message', {}, unexpected)).not.toThrow();
+		});
+	});
 });