Skip to content

Cluster fixes - #789

Open
jdmarshall wants to merge 6 commits into
prometheus:mainfrom
jdmarshall:clusterFixes
Open

Cluster fixes#789
jdmarshall wants to merge 6 commits into
prometheus:mainfrom
jdmarshall:clusterFixes

Conversation

@jdmarshall

@jdmarshall jdmarshall commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Rework of worker and cluster lifecycle management. Also allows the primary cluster thread to report stats, adds debug information to common possible failure paths.

Fixes #155, #181, #183, #280, #501, #563, #788

@jdmarshall jdmarshall modified the milestones: v1, v0.16 Jul 28, 2026
@jdmarshall

jdmarshall commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

This changes unreleased API modifications for AggregatorRegistry, so this will need to be part of v0.16.

Also fixes a memory leak in trunk for broadcastchannels, and improves lifecycle management for dead worker threads (both kinds)

Comment thread lib/cluster.js Outdated
Comment thread lib/registry.js Outdated
Comment thread test/workerTest.js
return { threadId, channel };
});

await delay(5); // Let announcements arrive

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just speaking from past experience, this seems like it could become a flaky test in the future (on slow CI machines).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll check if there's callback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test already includes delay(), btw.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No callback. Might be stuck with this one.

Comment thread lib/cluster.js Outdated
.then(metrics => Registry.aggregate(metrics.flat()).metrics())
.then(result => done(undefined, result), done);
const myMetrics = Promise.all(
registries.map(async r => r.getMetricsAsJSON()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you do something similar in a couple other places in this PR, and those places don't add the extra async to the map callback.

In particular, cluster now uses a similar announcement system to
filter workers.

This fixes prometheus#181.
Signed-off-by: Jason Marshall <jdmarshall@users.noreply.github.com>
Signed-off-by: alencristen <299997878+alencristen@users.noreply.github.com>
99.9% of the time process.send() is going to work. We don't need to
guard it when it's already inside of a try block. Just guard the
retry send.

Also reduces the amount of excessive mocking going on in the tests
by using jest more instead of creating our own mocks.

Signed-off-by: Jason Marshall <jdmarshall@users.noreply.github.com>

@cjihrig cjihrig left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but there are a couple linter issues in lib/worker.js.

@krajorama krajorama left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a frontend developer , but trying to help with review using LLM.

I think in general smaller PRs are easier to review and argue about.

Findings:

index.d.ts

index.d.ts still declares the removed addWorker — index.d.ts:209

addWorker(worker: Worker): void; no longer exists at runtime. TypeScript users compile clean and get a TypeError. Delete the declaration.

Primary metric inclusion is breaking

But filed under ### Changed — CHANGELOG.md:44

clusterMetrics() now always aggregates the primary's registries — confirmed: with zero workers it returns the primary's process_cpu_seconds_total etc. Calling collectDefaultMetrics() unconditionally in both primary and workers is a very common pattern, and those users will silently start seeing the primary's process metrics summed into every series. This is released API (v15.x), so it belongs under ### Breaking with a migration note.

It also deserves an opt-out. WorkerRegistry takes a primary flag; ClusterRegistry has no equivalent knob. Two smaller consequences: setRegistries's doc comment (lib/cluster.js:154 still says "Call from workers to…", and the empty-cluster return value changed from '' to '\n' — which is what forced the .trim() at test/clusterTest.js:76 and is worth its own changelog line.

Unreachable branch

lib/cluster.js:119

allMetrics = [myMetrics, ...workerMetrics] always has ≥1 element, so allMetrics.length === 0, the debug('No workers found…') call, and the '' fast path are dead. Test workerMetrics.length or drop the branch.

The worker-threads half of "lifecycle for dead worker threads (both kinds)" isn't there

lib/cluster.js gained a disconnect handler; lib/worker.js gained nothing. The 'close' listener at lib/worker.js:247only fires when the local channel object is closed, never when the remote thread dies. Verified: after worker.terminate(), every subsequent workerMetrics() rejects after 5 s, permanently. This is pre-existing (identical on main) so not a regression — but given the PR's stated scope, it's the obvious gap.

The two new "listeners don't accumulate" tests cannot fail

test/clusterTest.js:79, test/workerTest.js:140

The require sits outside the loop, so jest.resetModules() inside it never produces a fresh module instance; ar is assigned and never read (also a lint smell); and there are no assertions. Measured:

test-style loop -> cluster message listeners: 1
fresh module each time -> cluster message listeners: 6
So the property under test isn't guaranteed by listenersAdded at all — it's guaranteed by the require cache. Re-require inside the loop and assert cluster.listenerCount('message').

Test hygiene in test/clusterTest.js line 101

the originalWorkers restore was dropped, so the global cluster.workers stays mocked for the rest of the file (later announce() calls then send to jest mocks).
line 89: jest.resetModules() followed by new Registry(regType) from the top-of-file require is a no-op.
line 147: gauge.remove() isn't in a finally — a failed assertion leaves primary_gauge_test in the global registry, polluting later tests.
Nothing covers disconnect pruning workers, duplicate-announcement dedup, or collector-error propagation.

Comment thread lib/worker.js
*/
async function workerListener(event) {
const name = `@prometheus/client:worker:${threadId}`;
const channel = new BroadcastChannel(name).unref();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this create and leak a BroadcastChannel on every event?

Comment thread lib/cluster.js
if (message.type === ANNOUNCEMENT) {
process.send({ type: ANNOUNCEMENT });
} else if (message.type === GET_METRICS_REQ) {
const metrics = await Promise.all(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if await throws an exception, this is outside the try so possibly unhandled error?

Comment thread lib/cluster.js
announce();
} else {
process.on('message', workerListener);
process.send({ type: ANNOUNCEMENT });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-primary branch calls process.send unconditionally. With cluster.isPrimary === false but no IPC channel (NODE_UNIQUE_ID leaked into a grandchild — the case some process managers hit), this throws where main constructs fine:

PR: THREW: TypeError: process.send is not a function
main: OK: constructed without throwing
main never called send at construction time, so this is a new failure mode. Guard with typeof process.send === 'function' && process.connected.

Comment thread lib/worker.js
};
requests.set(requestId, request);
const responsePromises = [...this.channels.keys()].map(
const responsePromises = [...channels.keys()].sort().map(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[...channels.keys()].sort() sorts channel names, so with ≥10 threads you get 1, 10, 11, 2, 20, 3. lib/cluster.js sorts numerically on worker.id; this undoes the numeric intent of 0162ab5 ("aggregate metrics in a deterministic order") on the worker-threads side. Still deterministic, but no longer matching cluster.js. The threadId now carried in the response payload is also unused for ordering — keep a name→threadId map and sort on that.

@jdmarshall jdmarshall mentioned this pull request Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cluster event listeners are attached even if not using clusters

4 participants