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]; +};