Skip to content

Commit 032424e

Browse files
committed
feat: add destroy() method to PromQLExtension for memory leak prevention
When React components mount/unmount repeatedly, each creating a new PromQLExtension, memory leaks occur due to LRU caches with ttlAutopurge timers keeping references alive and in-flight HTTP requests holding closure references. This adds destroy() methods throughout the class hierarchy to properly release resources on unmount. Changes: - Add destroy() to PrometheusClient interface (optional) - HTTPPrometheusClient: track AbortControllers and abort pending requests - Cache: clear all LRU caches and reset cached data - CachedPrometheusClient: delegate to cache and underlying client - HybridComplete: delegate to prometheusClient - CompleteStrategy: add optional destroy() method - PromQLExtension: delegate to complete strategy Signed-off-by: Ben Blackmore <ben.blackmore@dash0.com>
1 parent 041228b commit 032424e

File tree

6 files changed

+196
-1
lines changed

6 files changed

+196
-1
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright 2025 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { HTTPPrometheusClient, CachedPrometheusClient } from './prometheus';
15+
16+
describe('HTTPPrometheusClient destroy', () => {
17+
it('should be safe to call destroy multiple times', () => {
18+
const client = new HTTPPrometheusClient({ url: 'http://localhost:8080' });
19+
// First call
20+
client.destroy();
21+
// Second call should not throw
22+
expect(() => client.destroy()).not.toThrow();
23+
});
24+
25+
it('should abort in-flight requests when destroy is called', async () => {
26+
let abortSignal: AbortSignal | null | undefined;
27+
28+
const mockFetch = (_url: RequestInfo, init?: RequestInit): Promise<Response> => {
29+
abortSignal = init?.signal;
30+
// Return a promise that never resolves to simulate an in-flight request
31+
return new Promise(() => {});
32+
};
33+
34+
const client = new HTTPPrometheusClient({
35+
url: 'http://localhost:8080',
36+
fetchFn: mockFetch,
37+
});
38+
39+
// Start a request (don't await it)
40+
client.labelNames();
41+
42+
// Verify the signal was captured and not aborted yet
43+
expect(abortSignal).toBeDefined();
44+
expect(abortSignal?.aborted).toBe(false);
45+
46+
// Destroy the client
47+
client.destroy();
48+
49+
// Verify the request was aborted
50+
expect(abortSignal?.aborted).toBe(true);
51+
});
52+
});
53+
54+
describe('CachedPrometheusClient destroy', () => {
55+
it('should be safe to call destroy multiple times', () => {
56+
const httpClient = new HTTPPrometheusClient({ url: 'http://localhost:8080' });
57+
const cachedClient = new CachedPrometheusClient(httpClient);
58+
59+
// First call
60+
cachedClient.destroy();
61+
// Second call should not throw
62+
expect(() => cachedClient.destroy()).not.toThrow();
63+
});
64+
65+
it('should call destroy on the underlying HTTPPrometheusClient', () => {
66+
const httpClient = new HTTPPrometheusClient({ url: 'http://localhost:8080' });
67+
68+
let destroyCalled = false;
69+
const originalDestroy = httpClient.destroy.bind(httpClient);
70+
httpClient.destroy = () => {
71+
destroyCalled = true;
72+
originalDestroy();
73+
};
74+
75+
const cachedClient = new CachedPrometheusClient(httpClient);
76+
cachedClient.destroy();
77+
78+
expect(destroyCalled).toBe(true);
79+
});
80+
81+
it('should handle underlying clients without destroy method', () => {
82+
// Create a minimal PrometheusClient without destroy
83+
const minimalClient = {
84+
labelNames: () => Promise.resolve([]),
85+
labelValues: () => Promise.resolve([]),
86+
metricMetadata: () => Promise.resolve({}),
87+
series: () => Promise.resolve([]),
88+
metricNames: () => Promise.resolve([]),
89+
flags: () => Promise.resolve({}),
90+
};
91+
92+
const cachedClient = new CachedPrometheusClient(minimalClient);
93+
94+
// Should not throw even though underlying client has no destroy
95+
expect(() => cachedClient.destroy()).not.toThrow();
96+
});
97+
});

web/ui/module/codemirror-promql/src/client/prometheus.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export interface PrometheusClient {
3939

4040
// flags returns flag values that prometheus was configured with.
4141
flags(): Promise<Record<string, string>>;
42+
43+
// destroy is called to release all resources held by this client
44+
destroy?(): void;
4245
}
4346

4447
export interface CacheConfig {
@@ -88,6 +91,7 @@ export class HTTPPrometheusClient implements PrometheusClient {
8891
// when calling it, thus the indirection via another function wrapper.
8992
private readonly fetchFn: FetchFn = (input: RequestInfo, init?: RequestInit): Promise<Response> => fetch(input, init);
9093
private requestHeaders: Headers = new Headers();
94+
private readonly abortControllers: Set<AbortController> = new Set<AbortController>();
9195

9296
constructor(config: PrometheusConfig) {
9397
this.url = config.url ? config.url : '';
@@ -199,11 +203,22 @@ export class HTTPPrometheusClient implements PrometheusClient {
199203
});
200204
}
201205

206+
destroy(): void {
207+
for (const controller of this.abortControllers) {
208+
controller.abort();
209+
}
210+
this.abortControllers.clear();
211+
}
212+
202213
private fetchAPI<T>(resource: string, init?: RequestInit): Promise<T> {
214+
const controller = new AbortController();
215+
this.abortControllers.add(controller);
216+
203217
if (init) {
204218
init.headers = this.requestHeaders;
219+
init.signal = controller.signal;
205220
} else {
206-
init = { headers: this.requestHeaders };
221+
init = { headers: this.requestHeaders, signal: controller.signal };
207222
}
208223
return this.fetchFn(this.url + resource, init)
209224
.then((res) => {
@@ -221,6 +236,9 @@ export class HTTPPrometheusClient implements PrometheusClient {
221236
throw new Error('missing "data" field in response JSON');
222237
}
223238
return apiRes.data;
239+
})
240+
.finally(() => {
241+
this.abortControllers.delete(controller);
224242
});
225243
}
226244

@@ -381,6 +399,14 @@ class Cache {
381399
}
382400
return [];
383401
}
402+
403+
destroy(): void {
404+
this.completeAssociation.clear();
405+
this.labelValues.clear();
406+
this.metricMetadata = {};
407+
this.labelNames = [];
408+
this.flags = {};
409+
}
384410
}
385411

386412
export class CachedPrometheusClient implements PrometheusClient {
@@ -448,4 +474,9 @@ export class CachedPrometheusClient implements PrometheusClient {
448474
return flags;
449475
});
450476
}
477+
478+
destroy(): void {
479+
this.cache.destroy();
480+
this.client.destroy?.();
481+
}
451482
}

web/ui/module/codemirror-promql/src/complete/hybrid.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,10 @@ export class HybridComplete implements CompleteStrategy {
575575
return this.prometheusClient;
576576
}
577577

578+
destroy(): void {
579+
this.prometheusClient?.destroy?.();
580+
}
581+
578582
promQL(context: CompletionContext): Promise<CompletionResult | null> | CompletionResult | null {
579583
const { state, pos } = context;
580584
const tree = syntaxTree(state).resolve(pos, -1);

web/ui/module/codemirror-promql/src/complete/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
1919
// Every different completion mode must implement this interface.
2020
export interface CompleteStrategy {
2121
promQL(context: CompletionContext): Promise<CompletionResult | null> | CompletionResult | null;
22+
destroy?(): void;
2223
}
2324

2425
// CompleteConfiguration should be used to customize the autocompletion.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2025 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { PromQLExtension } from './promql';
15+
import { CompleteStrategy } from './complete';
16+
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
17+
18+
describe('PromQLExtension destroy', () => {
19+
it('should be safe to call destroy multiple times', () => {
20+
const extension = new PromQLExtension();
21+
// First call
22+
extension.destroy();
23+
// Second call should not throw
24+
expect(() => extension.destroy()).not.toThrow();
25+
});
26+
27+
it('should call destroy on the complete strategy if available', () => {
28+
const extension = new PromQLExtension();
29+
30+
// Set up a mock complete strategy with destroy
31+
let destroyCalled = false;
32+
const mockCompleteStrategy: CompleteStrategy = {
33+
promQL: (_context: CompletionContext): CompletionResult | null => null,
34+
destroy: () => {
35+
destroyCalled = true;
36+
},
37+
};
38+
39+
extension.setComplete({ completeStrategy: mockCompleteStrategy });
40+
extension.destroy();
41+
42+
expect(destroyCalled).toBe(true);
43+
});
44+
45+
it('should handle complete strategies without destroy method', () => {
46+
const extension = new PromQLExtension();
47+
48+
// Set up a mock complete strategy without destroy
49+
const mockCompleteStrategy: CompleteStrategy = {
50+
promQL: (_context: CompletionContext): CompletionResult | null => null,
51+
};
52+
53+
extension.setComplete({ completeStrategy: mockCompleteStrategy });
54+
55+
// Should not throw even though complete strategy has no destroy
56+
expect(() => extension.destroy()).not.toThrow();
57+
});
58+
});

web/ui/module/codemirror-promql/src/promql.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export class PromQLExtension {
7979
return this;
8080
}
8181

82+
destroy(): void {
83+
this.complete.destroy?.();
84+
}
85+
8286
asExtension(languageType = LanguageType.PromQL): Extension {
8387
const language = promQLLanguage(languageType);
8488
let extension: Extension = [language];

0 commit comments

Comments
 (0)