Skip to content

Commit

Permalink
[Discover] [Unified Data Table] Improve Discover / Unified Data Table…
Browse files Browse the repository at this point in the history
… performance in ES|QL mode (elastic#191249)

## Summary

This PR includes several performance improvements for ES|QL mode in
Discover:
- Memoize `UnifiedDataTableRenderCellValue` to reduce re-renders.
- Show the loading spinner immediately on state changes that should
trigger a refetch so that changed state isn't applied to the grid before
the fetch completes. This also helps to reduce re-renders on state
changes.
- Replace [`EuiDataGrid` in-memory
sorting](https://eui.elastic.co/#/tabular-content/data-grid-advanced#data-grid-in-memory)
with a custom implementation using the `@kbn/sort-predicates` package
(the same one Lens uses for its data grid). EUI in-memory sorting has a
drastic impact on performance because it renders the entire grid off
screen in order to parse cell values, detect their schema, and then sort
the rows. This can cause rendering delays of several seconds for larger
datasets on each render of the data grid. The new approach will sort in
memory based on actual field values and pass the sorted rows to the data
grid, which is both much more performant as well as improving the
sorting behaviour across field types.
- Use the `overscanRowCount` option from
[react-window](https://github.com/bvaughn/react-window) to render some
additional rows outside of view in the data grid, which minimizes pop-in
of rows while scrolling quickly.

There are also a couple of bug fixes included in the PR as a result of
the new sorting behaviour:
- Numeric columns are now sorted correctly where previously they used
alphabetic sorting.
- The "Copy column" action in data grid columns now correctly copies the
column values in the current sort order instead of copying them unsorted
as it used to.

Before - 66,904 `UnifiedDataTableRenderCellValue` render calls:


https://github.com/user-attachments/assets/04caca0d-35e7-421c-b8e3-5a37a1d65177

After - 424 `UnifiedDataTableRenderCellValue` render calls:


https://github.com/user-attachments/assets/dba1a106-6bd6-4f1b-b60e-4741228c488b

Before profile:
<img width="300" alt="profile_old"
src="https://github.com/user-attachments/assets/44c07f30-55d2-44ab-a9b7-a912fa7beca1">

After profile:
<img width="300" alt="profile_new"
src="https://github.com/user-attachments/assets/d61cfd2a-8ac0-45bb-9afa-d3d97b0d03ce">

Before scrolling:


https://github.com/user-attachments/assets/8d9dbdbd-df3e-48df-af9a-539deda8d5ea

After scrolling:


https://github.com/user-attachments/assets/d6309275-e00d-44ee-a59f-2d1e5d30bb60

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Jatin Kathuria <jtn.kathuria@gmail.com>
  • Loading branch information
3 people authored Sep 4, 2024
1 parent 8b2cb75 commit 2629fa8
Show file tree
Hide file tree
Showing 11 changed files with 1,137 additions and 700 deletions.
2 changes: 2 additions & 0 deletions packages/kbn-discover-utils/src/__mocks__/es_hits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const generateFieldValue = (field: DataViewField, index: number) => {
return Array.from(field.name).reduce((sum, char) => sum + char.charCodeAt(0) + index, 0);
case KBN_FIELD_TYPES.STRING:
return `${field.name}_${index}`;
case KBN_FIELD_TYPES._SOURCE:
return { [field.name]: `${field.name}_${index}` };
default:
throw new Error(`Unsupported type ${field.type}`);
}
Expand Down
1,508 changes: 914 additions & 594 deletions packages/kbn-unified-data-table/src/components/data_table.test.tsx

Large diffs are not rendered by default.

140 changes: 54 additions & 86 deletions packages/kbn-unified-data-table/src/components/data_table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
EuiLoadingSpinner,
EuiIcon,
EuiDataGridRefProps,
EuiDataGridInMemory,
EuiDataGridControlColumn,
EuiDataGridCustomBodyProps,
EuiDataGridStyle,
Expand All @@ -43,7 +42,7 @@ import { getShouldShowFieldHandler } from '@kbn/discover-utils';
import type { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { ThemeServiceStart } from '@kbn/react-kibana-context-common';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { type DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { AdditionalFieldGroups } from '@kbn/unified-field-list';
import { DATA_GRID_DENSITY_STYLE_MAP, useDataGridDensity } from '../hooks/use_data_grid_density';
Expand Down Expand Up @@ -91,8 +90,15 @@ import {
type ColorIndicatorControlColumnParams,
getAdditionalRowControlColumns,
} from './custom_control_columns';
import { useSorting } from '../hooks/use_sorting';

const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS];
const THEME_DEFAULT = { darkMode: false };
const VIRTUALIZATION_OPTIONS: EuiDataGridProps['virtualizationOptions'] = {
// Allowing some additional rows to be rendered outside
// the view minimizes pop-in when scrolling quickly
overscanRowCount: 20,
};

export type SortOrder = [string, string];

Expand All @@ -102,13 +108,6 @@ export enum DataLoadingState {
loaded = 'loaded',
}

const themeDefault = { darkMode: false };

interface SortObj {
id: string;
direction: string;
}

/**
* Unified Data Table props
*/
Expand Down Expand Up @@ -484,7 +483,7 @@ export const UnifiedDataTable = ({
}: UnifiedDataTableProps) => {
const { fieldFormats, toastNotifications, dataViewFieldEditor, uiSettings, storage, data } =
services;
const { darkMode } = useObservable(services.theme?.theme$ ?? of(themeDefault), themeDefault);
const { darkMode } = useObservable(services.theme?.theme$ ?? of(THEME_DEFAULT), THEME_DEFAULT);
const dataGridRef = useRef<EuiDataGridRefProps>(null);
const [isFilterActive, setIsFilterActive] = useState(false);
const [isCompareActive, setIsCompareActive] = useState(false);
Expand All @@ -507,20 +506,55 @@ export const UnifiedDataTable = ({
}
}, [isFilterActive, hasSelectedDocs, setIsFilterActive]);

const timeFieldName = dataView.timeFieldName;
const shouldPrependTimeFieldColumn = useCallback(
(activeColumns: string[]) =>
canPrependTimeFieldColumn(
activeColumns,
timeFieldName,
columnsMeta,
showTimeCol,
isPlainRecord
),
[timeFieldName, isPlainRecord, showTimeCol, columnsMeta]
);

const visibleColumns = useMemo(() => {
return getVisibleColumns(
displayedColumns,
dataView,
shouldPrependTimeFieldColumn(displayedColumns)
);
}, [dataView, displayedColumns, shouldPrependTimeFieldColumn]);

const { sortedRows, sorting } = useSorting({
rows,
visibleColumns,
columnsMeta,
sort,
dataView,
isPlainRecord,
isSortEnabled,
defaultColumns,
onSort,
});

const displayedRows = useMemo(() => {
if (!rows) {
if (!sortedRows) {
return [];
}

if (!isFilterActive || !hasSelectedDocs) {
return rows;
return sortedRows;
}
const rowsFiltered = rows.filter((row) => isDocSelected(row.id));
if (!rowsFiltered.length) {
// in case the selected docs are no longer part of the sample of 500, show all docs
return rows;
}
return rowsFiltered;
}, [rows, isFilterActive, hasSelectedDocs, isDocSelected]);

const rowsFiltered = sortedRows.filter((row) => isDocSelected(row.id));

return rowsFiltered.length
? rowsFiltered
: // in case the selected docs are no longer part of the sample of 500, show all docs
sortedRows;
}, [sortedRows, isFilterActive, hasSelectedDocs, isDocSelected]);

const valueToStringConverter: ValueToStringConverter = useCallback(
(rowIndex, columnId, options) => {
Expand Down Expand Up @@ -709,25 +743,6 @@ export const UnifiedDataTable = ({
[dataView, onFieldEdited, services?.dataViewFieldEditor]
);

const timeFieldName = dataView.timeFieldName;
const shouldPrependTimeFieldColumn = useCallback(
(activeColumns: string[]) =>
canPrependTimeFieldColumn(
activeColumns,
timeFieldName,
columnsMeta,
showTimeCol,
isPlainRecord
),
[timeFieldName, isPlainRecord, showTimeCol, columnsMeta]
);

const visibleColumns = useMemo(
() =>
getVisibleColumns(displayedColumns, dataView, shouldPrependTimeFieldColumn(displayedColumns)),
[dataView, displayedColumns, shouldPrependTimeFieldColumn]
);

const getCellValue = useCallback<UseDataGridColumnsCellActionsProps['getCellValue']>(
(fieldName, rowIndex) =>
displayedRows[rowIndex % displayedRows.length].flattened[fieldName] as Serializable,
Expand Down Expand Up @@ -848,47 +863,6 @@ export const UnifiedDataTable = ({
[visibleColumns, onSetColumns, shouldPrependTimeFieldColumn]
);

/**
* Sorting
*/
const sortingColumns = useMemo(
() =>
sort
.map(([id, direction]) => ({ id, direction }))
.filter(({ id }) => visibleColumns.includes(id)),
[sort, visibleColumns]
);

const onTableSort = useCallback(
(sortingColumnsData) => {
if (isSortEnabled) {
if (onSort) {
onSort(sortingColumnsData.map(({ id, direction }: SortObj) => [id, direction]));
}
}
},
[onSort, isSortEnabled]
);

const sorting = useMemo(() => {
if (isSortEnabled) {
// in ES|QL mode, sorting is disabled when in Document view
// ideally we want the @timestamp column to be sortable server side
// but it needs discussion before moving forward like this
if (isPlainRecord && !columns.length) {
return undefined;
}
return {
columns: sortingColumns,
onSort: onTableSort,
};
}
return {
columns: sortingColumns,
onSort: () => {},
};
}, [isSortEnabled, sortingColumns, isPlainRecord, columns.length, onTableSort]);

const canSetExpandedDoc = Boolean(setExpandedDoc && !!renderDocumentView);

const leadingControlColumns: EuiDataGridControlColumn[] = useMemo(() => {
Expand Down Expand Up @@ -1036,12 +1010,6 @@ export const UnifiedDataTable = ({
onUpdateDataGridDensity,
]);

const inMemory = useMemo(() => {
return isPlainRecord && columns.length
? ({ level: 'sorting' } as EuiDataGridInMemory)
: undefined;
}, [columns.length, isPlainRecord]);

const toolbarVisibility = useMemo(
() =>
defaultColumns
Expand Down Expand Up @@ -1161,13 +1129,13 @@ export const UnifiedDataTable = ({
sorting={sorting as EuiDataGridSorting}
toolbarVisibility={toolbarVisibility}
rowHeightsOptions={rowHeightsOptions}
inMemory={inMemory}
gridStyle={gridStyle}
renderCustomGridBody={renderCustomGridBody}
renderCustomToolbar={renderCustomToolbarFn}
trailingControlColumns={trailingControlColumns}
cellContext={cellContext}
renderCellPopover={renderCustomPopover}
virtualizationOptions={VIRTUALIZATION_OPTIONS}
/>
)}
</div>
Expand Down
113 changes: 113 additions & 0 deletions packages/kbn-unified-data-table/src/hooks/use_sorting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataTableRecord } from '@kbn/discover-utils';
import { getSortingCriteria } from '@kbn/sort-predicates';
import { useMemo } from 'react';
import type { EuiDataGridColumnSortingConfig, EuiDataGridProps } from '@elastic/eui';
import type { SortOrder } from '../components/data_table';
import type { DataTableColumnsMeta } from '../types';

export const useSorting = ({
rows,
visibleColumns,
columnsMeta,
sort,
dataView,
isPlainRecord,
isSortEnabled,
defaultColumns,
onSort,
}: {
rows: DataTableRecord[] | undefined;
visibleColumns: string[];
columnsMeta: DataTableColumnsMeta | undefined;
sort: SortOrder[];
dataView: DataView;
isPlainRecord: boolean;
isSortEnabled: boolean;
defaultColumns: boolean;
onSort: ((sort: string[][]) => void) | undefined;
}) => {
const sortingColumns = useMemo(() => {
return sort
.map(([id, direction]) => ({ id, direction }))
.filter((col) => visibleColumns.includes(col.id)) as EuiDataGridColumnSortingConfig[];
}, [sort, visibleColumns]);

const comparators = useMemo(() => {
if (!isPlainRecord || !rows || !sortingColumns.length) {
return;
}

return sortingColumns.reduce<Array<(a: DataTableRecord, b: DataTableRecord) => number>>(
(acc, { id, direction }) => {
const field = dataView.fields.getByName(id);

if (!field) {
return acc;
}

const sortField = getSortingCriteria(
columnsMeta?.[id]?.type ?? field.type,
id,
dataView.getFormatterForField(field)
);

acc.push((a, b) => sortField(a.flattened, b.flattened, direction as 'asc' | 'desc'));

return acc;
},
[]
);
}, [columnsMeta, dataView, isPlainRecord, rows, sortingColumns]);

const sortedRows = useMemo(() => {
if (!rows || !comparators) {
return rows;
}

return [...rows].sort((a, b) => {
for (const comparator of comparators) {
const result = comparator(a, b);

if (result !== 0) {
return result;
}
}

return 0;
});
}, [comparators, rows]);

const sorting = useMemo<EuiDataGridProps['sorting']>(() => {
if (!isSortEnabled) {
return {
columns: sortingColumns,
onSort: () => {},
};
}

// in ES|QL mode, sorting is disabled when in Document view
// ideally we want the @timestamp column to be sortable server side
// but it needs discussion before moving forward like this
if (isPlainRecord && defaultColumns) {
return undefined;
}

return {
columns: sortingColumns,
onSort: (sortingColumnsData) => {
onSort?.(sortingColumnsData.map(({ id, direction }) => [id, direction]));
},
};
}, [isSortEnabled, isPlainRecord, defaultColumns, sortingColumns, onSort]);

return { sortedRows, sorting };
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Side Public License, v 1.
*/

import React, { useEffect, useContext } from 'react';
import React, { useEffect, useContext, memo } from 'react';
import { i18n } from '@kbn/i18n';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import {
Expand All @@ -26,6 +26,8 @@ import { DataTablePopoverCellValue } from '../components/data_table_cell_value';

export const CELL_CLASS = 'unifiedDataTable__cellValue';

const IS_JEST_ENVIRONMENT = typeof jest !== 'undefined';

export const getRenderCellValueFn = ({
dataView,
rows,
Expand All @@ -49,15 +51,15 @@ export const getRenderCellValueFn = ({
isPlainRecord?: boolean;
isCompressed?: boolean;
}) => {
return function UnifiedDataTableRenderCellValue({
const UnifiedDataTableRenderCellValue = ({
rowIndex,
columnId,
isDetails,
setCellProps,
colIndex,
isExpandable,
isExpanded,
}: EuiDataGridCellValueElementProps) {
}: EuiDataGridCellValueElementProps) => {
const row = rows ? rows[rowIndex] : undefined;
const field = dataView.fields.getByName(columnId);
const ctx = useContext(UnifiedDataTableContext);
Expand Down Expand Up @@ -153,6 +155,14 @@ export const getRenderCellValueFn = ({
/>
);
};

// When memoizing renderCellValue, the following warning is logged in Jest tests:
// Failed prop type: Invalid prop `renderCellValue` supplied to `EuiDataGridCellContent`, expected one of type [function].
// This is due to incorrect prop type validation that EUI generates for testing components in Jest,
// but is not an actual issue encountered outside of tests
return IS_JEST_ENVIRONMENT
? UnifiedDataTableRenderCellValue
: memo(UnifiedDataTableRenderCellValue);
};

/**
Expand Down
Loading

0 comments on commit 2629fa8

Please sign in to comment.