Skip to content

[Detail Bug] Data Quality dashboard cards can show stale incident counts after rapid filter changes (request race) #32697

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_3377c26d-da48-4ccd-b83a-22c542f4fe83/bugs/bug_d897e247-f5dc-438c-843b-8566d68c4a86

Introduced in #26765 by @ShaileshParmar11 on Mar 28, 2026

Summary

  • Context: IncidentTypeAreaChartWidget renders the "Open Incidents" / "Resolved Incidents" cards on the Data Quality dashboard, fetching per-day incident counts via fetchCountOfIncidentStatusTypeByDays whenever the dashboard's chartFilter prop changes.
  • Bug: The useEffect that triggers getCountOfIncidentStatus has no AbortController and no ignore-stale-response guard, so when chartFilter changes while a previous request is still in flight, the stale response can resolve after the fresh one and overwrite the correct data with data for a filter the user no longer has selected.
  • Actual vs. expected: After quickly changing a dashboard filter, the card's number and area-chart trend should reflect the newly-selected filter; instead it can display the counts for the previously-selected (now stale) filter, persisted in component state until the next filter change or re-fetch.
  • Impact: The wrong data is confined to this widget's in-card number/trend display. The card's click-through redirectPath is built by the parent (DqDashboardSectionContent.component.tsx) from the current defaultFilters prop, not from the widget's local chartData, so navigation always lands on the incident manager scoped to the correct date range.

Code with Bug

openmetadata-ui/src/main/resources/ui/src/components/DataQuality/ChartWidgets/IncidentTypeAreaChartWidget/IncidentTypeAreaChartWidget.component.tsx:

const getCountOfIncidentStatus = async () => {
  setIsChartLoading(true);
  try {
    const { data } = await fetchCountOfIncidentStatusTypeByDays(
      incidentStatusType,
      chartFilter
    );
    const updatedData = data.map((item) => ({
      timestamp: +item.timestamp,
      count: +item.stateId,
    }));
    setChartData(updatedData); // <-- BUG 🔴 no guard against resolving for a stale chartFilter
  } catch {
    setChartData([]);
  } finally {
    setIsChartLoading(false);
  }
};

useEffect(() => {
  getCountOfIncidentStatus(); // <-- BUG 🔴 no ignore-stale guard / no cleanup return
}, [chartFilter, incidentStatusType]);

Explanation

  • Every dashboard filter change creates a new chartFilter object identity and re-triggers the effect.
  • Multiple in-flight requests can overlap; setChartData applies whichever response resolves last, even if it corresponds to an older filter.
  • Resulting behavior: rapid filter A → filter B changes can briefly show B, then revert to stale A when request A resolves after request B.

Codebase Inconsistency

The click-through URL is constructed by the parent from current defaultFilters, not the widget’s potentially stale chartData, so navigation remains correct even when the card display is wrong:

openmetadata-ui/src/main/resources/ui/src/components/DataQuality/DqDashboard/DqDashboardSectionContent/DqDashboardSectionContent.component.tsx:

<IncidentTypeAreaChartWidget
  chartFilter={defaultFilters}
  height={60}
  incidentStatusType={TestCaseResolutionStatusTypes.New}
  name="open-incident"
  redirectPath={{
    pathname: incidentPath,
    search: QueryString.stringify({
      testCaseResolutionStatusType: TestCaseResolutionStatusTypes.New,
      startTs: defaultFilters.startTs,
      endTs: defaultFilters.endTs,
    }),
  }}
  title={t('label.open-incident-plural')}
/>

Failing Test

import '@testing-library/jest-dom/extend-expect';
import { act, render, screen } from '@testing-library/react';
import { TestCaseResolutionStatusTypes } from '../../../../generated/tests/testCaseResolutionStatus';
import { fetchCountOfIncidentStatusTypeByDays } from '../../../../rest/dataQualityDashboardAPI';
import { IncidentTypeAreaChartWidgetProps } from '../../DataQuality.interface';
import IncidentTypeAreaChartWidget from './IncidentTypeAreaChartWidget.component';

jest.mock('../../../../rest/dataQualityDashboardAPI', () => ({
  fetchCountOfIncidentStatusTypeByDays: jest.fn(),
}));
jest.mock('../../../Visualisations/Chart/CustomAreaChart.component', () =>
  jest.fn().mockImplementation(() => <div>CustomAreaChart.component</div>)
);

const defaultProps: IncidentTypeAreaChartWidgetProps = {
  incidentStatusType: TestCaseResolutionStatusTypes.New,
  name: 'Incident Type',
  title: 'Incident Type Area Chart Widget',
};

describe('IncidentTypeAreaChartWidget race repro', () => {
  it('should display the latest chartFilter data when an older request resolves after a newer one', async () => {
    let resolveA: (v: { data: Array<{ stateId: string; timestamp: string }> }) => void = () => {};
    let resolveB: (v: { data: Array<{ stateId: string; timestamp: string }> }) => void = () => {};
    const fetchMock = fetchCountOfIncidentStatusTypeByDays as jest.Mock;
    fetchMock.mockImplementation((_status: unknown, filters: any) => {
      if (filters?.startTs === 1) {
        return new Promise((r) => { resolveA = r; });
      }
      return new Promise((r) => { resolveB = r; });
    });

    const filterA = { startTs: 1, endTs: 10 };
    const filterB = { startTs: 100, endTs: 200 };
    const { rerender } = render(
      <IncidentTypeAreaChartWidget {...defaultProps} chartFilter={filterA} />
    );
    // Re-render with the new filter before the first request resolves.
    rerender(<IncidentTypeAreaChartWidget {...defaultProps} chartFilter={filterB} />);

    // Resolve the newer request first, then the older one, inside act() for determinism.
    await act(async () => {
      resolveB({ data: [{ stateId: '200', timestamp: '1729468800000' }] });
      resolveA({ data: [{ stateId: '1', timestamp: '1729468800000' }] });
    });

    // Expected: latest filter (B) value '200'. Actual (buggy): stale A value '1'.
    expect(screen.getByTestId('total-value').textContent).toEqual('200');
  });
});

Run against the unmodified component (FAIL):

FAIL @openmetadata src/components/DataQuality/ChartWidgets/IncidentTypeAreaChartWidget/IncidentTypeAreaChartWidget.race.repro.test.tsx (8.211 s)
  IncidentTypeAreaChartWidget race repro
    ✕ should display the latest chartFilter data when an older request resolves after a newer one (43 ms)

  ● IncidentTypeAreaChartWidget race repro › should display the latest chartFilter data when an older request resolves after a newer one

    expect(received).toEqual(expected) // deep equality

    Expected: "200"
    Received: "1"

      46 |
      47 |     // Expected: latest filter (B) value '200'. Actual (buggy): stale A value '1'.
    > 48 |     expect(screen.getByTestId('total-value').textContent).toEqual('200');
         |                                                           ^
      49 |   });
      50 | });

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total

Recommended Fix

Add an effect cleanup “ignore stale resolution” guard (rather than AbortController, because requests may be coalesced by the batcher):

useEffect(() => {
  let active = true;
  setIsChartLoading(true);
  fetchCountOfIncidentStatusTypeByDays(incidentStatusType, chartFilter)
    .then(({ data }) => {
      if (!active) return;
      setChartData(data.map((item) => ({
        timestamp: +item.timestamp,
        count: +item.stateId,
      })));
    })
    .catch(() => { if (active) setChartData([]); })
    .finally(() => { if (active) setIsChartLoading(false); });
  return () => { active = false; };
}, [chartFilter, incidentStatusType]);

Also apply the same guard pattern to sibling widgets with the same unguarded useEffect → setChartData shape:

  • openmetadata-ui/src/main/resources/ui/src/components/DataQuality/ChartWidgets/IncidentTimeChartWidget/IncidentTimeChartWidget.component.tsx
  • openmetadata-ui/src/main/resources/ui/src/components/DataQuality/ChartWidgets/TestCaseStatusAreaChartWidget/TestCaseStatusAreaChartWidget.component.tsx

History

This bug was introduced in commit 88c1cf1b476 ("feat: migrate Data quality dashboard to open source (PR #26765)").

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    • Status
      No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions