From 1828b2bb1d54803ea51d02527924bb52ff1a9b9e Mon Sep 17 00:00:00 2001 From: stefanonardo Date: Thu, 14 May 2026 12:13:53 +0200 Subject: [PATCH] OCPBUGS-81521: Adapt dashboard Prometheus polling interval based on query response time Replace the hardcoded 15s polling interval in fetchPeriodically with an adaptive delay derived from an Exponential Moving Average of response times. Fast clusters stay at the 15s floor while slow/large clusters automatically back off up to 60s, reducing unnecessary Prometheus load. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../actions/__tests__/dashboards.spec.ts | 70 +++++++++++++ frontend/public/actions/dashboards.ts | 22 ++++- .../utils/__tests__/adaptive-polling.spec.ts | 98 +++++++++++++++++++ .../components/utils/adaptive-polling.ts | 34 +++++++ 4 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 frontend/public/components/utils/__tests__/adaptive-polling.spec.ts create mode 100644 frontend/public/components/utils/adaptive-polling.ts diff --git a/frontend/public/actions/__tests__/dashboards.spec.ts b/frontend/public/actions/__tests__/dashboards.spec.ts index 60315aed3f4..b01e343bde5 100644 --- a/frontend/public/actions/__tests__/dashboards.spec.ts +++ b/frontend/public/actions/__tests__/dashboards.spec.ts @@ -9,6 +9,7 @@ import { } from '../dashboards'; import { defaults } from '../../reducers/dashboards'; import { RESULTS_TYPE } from '../../reducers/dashboard-results'; +import { MIN_POLL_DELAY } from '../../components/utils/adaptive-polling'; const testStopWatch = (stopAction, type: RESULTS_TYPE, key: string) => { expect(stopAction(key)).toEqual({ @@ -102,4 +103,73 @@ describe('dashboards-actions', () => { it('stopWatchPrometheusQuery stops watching Prometheus', () => testStopWatch(stopWatchPrometheusQuery, RESULTS_TYPE.PROMETHEUS, 'fooQuery')); + + describe('adaptive polling', () => { + let setTimeoutSpy: jest.SpyInstance; + + beforeEach(() => { + setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + }); + + afterEach(() => { + setTimeoutSpy.mockRestore(); + jest.restoreAllMocks(); + }); + + const flushPromises = () => new Promise(process.nextTick); + + const setupWatchURL = (fetchMock: jest.Mock) => { + const activeState = ImmutableMap(defaults).setIn([RESULTS_TYPE.URL, 'testURL', 'active'], 1); + const getState = jest + .fn() + .mockReturnValueOnce({ dashboards: ImmutableMap(defaults) }) + .mockReturnValue({ dashboards: activeState }); + const dispatch = jest.fn(); + + watchURL('testURL', fetchMock)(dispatch, getState); + return { dispatch, getState }; + }; + + it('uses MIN_POLL_DELAY for fast responses', async () => { + const now = 1000; + jest + .spyOn(Date, 'now') + .mockReturnValueOnce(now) + .mockReturnValueOnce(now + 100); + + const fetchMock = jest.fn().mockResolvedValueOnce({ data: 'test' }); + setupWatchURL(fetchMock); + + await flushPromises(); + + const lastSetTimeout = setTimeoutSpy.mock.calls[setTimeoutSpy.mock.calls.length - 1]; + expect(lastSetTimeout[1]).toBe(MIN_POLL_DELAY); + }); + + it('increases delay for slow responses', async () => { + const now = 1000; + jest + .spyOn(Date, 'now') + .mockReturnValueOnce(now) + .mockReturnValueOnce(now + 3000); + + const fetchMock = jest.fn().mockResolvedValueOnce({ data: 'test' }); + setupWatchURL(fetchMock); + + await flushPromises(); + + const lastSetTimeout = setTimeoutSpy.mock.calls[setTimeoutSpy.mock.calls.length - 1]; + expect(lastSetTimeout[1]).toBe(30000); + }); + + it('does not jump to MAX_POLL_DELAY on first fetch error', async () => { + const fetchMock = jest.fn().mockRejectedValueOnce(new Error('network error')); + setupWatchURL(fetchMock); + + await flushPromises(); + + const lastSetTimeout = setTimeoutSpy.mock.calls[setTimeoutSpy.mock.calls.length - 1]; + expect(lastSetTimeout[1]).toBe(MIN_POLL_DELAY); + }); + }); }); diff --git a/frontend/public/actions/dashboards.ts b/frontend/public/actions/dashboards.ts index e6386b5d182..c8fdd303cc7 100644 --- a/frontend/public/actions/dashboards.ts +++ b/frontend/public/actions/dashboards.ts @@ -7,7 +7,13 @@ import { isWatchActive, RESULTS_TYPE } from '../reducers/dashboard-results'; import type { RootState } from '../redux'; import { getPrometheusURL, PrometheusEndpoint } from '../components/graphs/helpers'; import { PrometheusResponse } from '../components/graphs'; -import { URL_POLL_DEFAULT_DELAY } from '../components/utils/url-poll-hook'; +import { + computeAdaptiveDelay, + emaToDelay, + MIN_POLL_DELAY, + MAX_POLL_DELAY, + SCALE_FACTOR, +} from '../components/utils/adaptive-polling'; import { Fetch, RequestMap } from '@console/dynamic-plugin-sdk/src/api/internal-types'; export enum ActionType { @@ -63,23 +69,32 @@ const fetchPeriodically: FetchPeriodically = async ( getURL, getState, fetch, + responseTimeEma = 0, ) => { if (!isWatchActive(getState().dashboards, type, key)) { return; } + let nextEma = responseTimeEma; try { dispatch(updateWatchInFlight(type, key, true)); + const startTime = Date.now(); const data = await fetch(getURL()); + const elapsed = Date.now() - startTime; + [, nextEma] = computeAdaptiveDelay(elapsed, responseTimeEma); dispatch(setData(type, key, data)); dispatch(setError(type, key, null)); } catch (error) { + // Feed a synthetic slow response into the EMA to gradually back off without jumping to max + const errorSeed = + responseTimeEma === 0 ? MIN_POLL_DELAY / SCALE_FACTOR : MAX_POLL_DELAY / SCALE_FACTOR; + [, nextEma] = computeAdaptiveDelay(errorSeed, responseTimeEma); dispatch(setError(type, key, error)); dispatch(setData(type, key, null)); } finally { dispatch(updateWatchInFlight(type, key, false)); const timeout = setTimeout( - () => fetchPeriodically(dispatch, type, key, getURL, getState, fetch), - URL_POLL_DEFAULT_DELAY, + () => fetchPeriodically(dispatch, type, key, getURL, getState, fetch, nextEma), + emaToDelay(nextEma), ); dispatch(updateWatchTimeout(type, key, timeout)); } @@ -147,6 +162,7 @@ type FetchPeriodically = ( getURL: () => string, getState: () => RootState, fetch: Fetch, + responseTimeEma?: number, ) => void; export type DashboardsAction = Action; diff --git a/frontend/public/components/utils/__tests__/adaptive-polling.spec.ts b/frontend/public/components/utils/__tests__/adaptive-polling.spec.ts new file mode 100644 index 00000000000..f3873cfd448 --- /dev/null +++ b/frontend/public/components/utils/__tests__/adaptive-polling.spec.ts @@ -0,0 +1,98 @@ +import { + computeAdaptiveDelay, + emaToDelay, + MIN_POLL_DELAY, + MAX_POLL_DELAY, + EMA_ALPHA, + SCALE_FACTOR, +} from '../adaptive-polling'; + +describe('emaToDelay', () => { + it('clamps to MIN_POLL_DELAY for small EMA values', () => { + expect(emaToDelay(0)).toBe(MIN_POLL_DELAY); + expect(emaToDelay(500)).toBe(MIN_POLL_DELAY); + expect(emaToDelay(1499)).toBe(MIN_POLL_DELAY); + }); + + it('scales proportionally for mid-range EMA values', () => { + expect(emaToDelay(2000)).toBe(20000); + expect(emaToDelay(3000)).toBe(30000); + expect(emaToDelay(4500)).toBe(45000); + }); + + it('clamps to MAX_POLL_DELAY for large EMA values', () => { + expect(emaToDelay(6000)).toBe(MAX_POLL_DELAY); + expect(emaToDelay(10000)).toBe(MAX_POLL_DELAY); + }); + + it('falls back to MIN_POLL_DELAY for non-finite values', () => { + expect(emaToDelay(NaN)).toBe(MIN_POLL_DELAY); + expect(emaToDelay(Infinity)).toBe(MIN_POLL_DELAY); + expect(emaToDelay(-Infinity)).toBe(MIN_POLL_DELAY); + }); +}); + +describe('computeAdaptiveDelay', () => { + it('uses elapsed directly as EMA on first call (previousEma = 0)', () => { + const [delay, ema] = computeAdaptiveDelay(500, 0); + expect(ema).toBe(500); + expect(delay).toBe(MIN_POLL_DELAY); + }); + + it('applies EMA smoothing with previous value', () => { + const [, ema] = computeAdaptiveDelay(4000, 3000); + const expected = EMA_ALPHA * 4000 + (1 - EMA_ALPHA) * 3000; + expect(ema).toBe(expected); + }); + + it('returns MIN_POLL_DELAY for fast responses', () => { + const [delay] = computeAdaptiveDelay(200, 300); + expect(delay).toBe(MIN_POLL_DELAY); + }); + + it('returns proportional delay for moderate responses', () => { + const [delay, ema] = computeAdaptiveDelay(3000, 3000); + expect(ema).toBe(3000); + expect(delay).toBe(30000); + }); + + it('returns MAX_POLL_DELAY for very slow responses', () => { + const [delay] = computeAdaptiveDelay(10000, 8000); + expect(delay).toBe(MAX_POLL_DELAY); + }); + + it('dampens a single outlier spike via EMA smoothing', () => { + // Stable at 1s, then a 10s spike + const [, ema1] = computeAdaptiveDelay(1000, 1000); + expect(ema1).toBe(1000); + + const [delay, ema2] = computeAdaptiveDelay(10000, ema1); + const expected = EMA_ALPHA * 10000 + (1 - EMA_ALPHA) * 1000; + expect(ema2).toBe(expected); + // Should not jump to MAX_POLL_DELAY from a single spike + expect(delay).toBeLessThan(MAX_POLL_DELAY); + }); + + it('recovers gradually after error backoff', () => { + const errorInput = MAX_POLL_DELAY / SCALE_FACTOR; + // Start from stable fast state + const [, emaAfterError] = computeAdaptiveDelay(errorInput, 500); + expect(emaAfterError).toBeGreaterThan(500); + + // Follow up with a fast response — EMA should decrease + const [, emaRecovery] = computeAdaptiveDelay(500, emaAfterError); + expect(emaRecovery).toBeLessThan(emaAfterError); + }); + + it('defaults previousEma to 0 when omitted', () => { + const [delay, ema] = computeAdaptiveDelay(2000); + expect(ema).toBe(2000); + expect(delay).toBe(20000); + }); + + it('falls back safely for non-finite or negative elapsedMs', () => { + expect(computeAdaptiveDelay(NaN, 1000)).toEqual([MIN_POLL_DELAY, 1000]); + expect(computeAdaptiveDelay(Infinity, 1000)).toEqual([MIN_POLL_DELAY, 1000]); + expect(computeAdaptiveDelay(-1, 1000)).toEqual([MIN_POLL_DELAY, 1000]); + }); +}); diff --git a/frontend/public/components/utils/adaptive-polling.ts b/frontend/public/components/utils/adaptive-polling.ts new file mode 100644 index 00000000000..4929eceb817 --- /dev/null +++ b/frontend/public/components/utils/adaptive-polling.ts @@ -0,0 +1,34 @@ +export const MIN_POLL_DELAY = 15000; +export const MAX_POLL_DELAY = 60000; +export const EMA_ALPHA = 0.3; +export const SCALE_FACTOR = 10; + +/** Converts a smoothed response time (EMA) to a clamped polling delay in ms. */ +export const emaToDelay = (ema: number): number => + Number.isFinite(ema) + ? Math.max(MIN_POLL_DELAY, Math.min(MAX_POLL_DELAY, Math.round(ema * SCALE_FACTOR))) + : MIN_POLL_DELAY; + +/** + * Computes the next adaptive polling delay using an Exponential Moving Average + * of response times. Returns `[nextDelay, updatedEma]`. + * + * On first call pass `previousEma` as 0 (or omit) to seed the EMA with `elapsedMs`. + * + * With current parameters (alpha=0.3, scale=10x, 15s–60s clamp): + * ~500ms response = 15s poll (floor) + * ~2s response = 20s poll + * ~3s response = 30s poll + * ~5s response = 50s poll + * ~6s+ response = 60s poll (ceiling) + */ +export const computeAdaptiveDelay = ( + elapsedMs: number, + previousEma: number = 0, +): [number, number] => { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + return [MIN_POLL_DELAY, previousEma]; + } + const ema = previousEma === 0 ? elapsedMs : EMA_ALPHA * elapsedMs + (1 - EMA_ALPHA) * previousEma; + return [emaToDelay(ema), ema]; +};