[Chore] Remove allocation from flattenHit (#180647)

## Summary

`flattenHit` is in a tight loop, and allocates numerous temporary
objects. This removes the majority of them.

For comparison, this implementation will now be more in line with
`collectBucket`, which uses a similar approach to avoid temporary
allocations.
84304bc0dc/src/plugins/data/common/search/tabify/tabify.ts (L30)

### For maintainers

- [x] 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)
This commit is contained in:
Thomas Neirynck 2024-04-26 09:44:08 -04:00 committed by GitHub
parent f1e02f6422
commit cb2c9d97b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -8,7 +8,12 @@
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { isPlainObject } from 'lodash';
import { Datatable, DatatableColumn, DatatableColumnType } from '@kbn/expressions-plugin/common';
import {
Datatable,
DatatableColumn,
DatatableRow,
DatatableColumnType,
} from '@kbn/expressions-plugin/common';
import type { DataView } from '@kbn/data-views-plugin/common';
// meta fields we won't merge with our result hit
@ -33,6 +38,55 @@ interface TabifyDocsOptions {
// Can be removed once the estypes.SearchHit knows about ignored_field_values
type Hit<T = unknown> = estypes.SearchHit<T> & { ignored_field_values?: Record<string, unknown[]> };
function flattenAccum(
flat: Record<string, any>,
obj: Record<string, any>,
keyPrefix: string,
indexPattern?: DataView,
params?: TabifyDocsOptions
) {
for (const k in obj) {
if (!obj.hasOwnProperty(k)) {
continue;
}
const val = obj[k];
const key = keyPrefix + k;
const field = indexPattern?.fields.getByName(key);
if (params?.shallow === false) {
const isNestedField = field?.type === 'nested';
if (Array.isArray(val) && !isNestedField) {
for (let i = 0; i < val.length; i++) {
const v = val[i];
if (isPlainObject(v)) {
flattenAccum(flat, v, key + '.', indexPattern, params);
}
}
continue;
}
} else if (flat[key] !== undefined) {
continue;
}
const hasValidMapping = field && field.type !== 'conflict';
const isValue = !isPlainObject(val);
if (hasValidMapping || isValue) {
if (!flat[key]) {
flat[key] = val;
} else if (Array.isArray(flat[key])) {
flat[key].push(val);
} else {
flat[key] = [flat[key], val];
}
continue;
}
flattenAccum(flat, val, key + '.', indexPattern, params);
}
}
/**
* Flattens an individual hit (from an ES response) into an object. This will
* create flattened field names, like `user.name`.
@ -44,43 +98,10 @@ type Hit<T = unknown> = estypes.SearchHit<T> & { ignored_field_values?: Record<s
export function flattenHit(hit: Hit, indexPattern?: DataView, params?: TabifyDocsOptions) {
const flat = {} as Record<string, any>;
function flatten(obj: Record<string, any>, keyPrefix: string = '') {
for (const [k, val] of Object.entries(obj)) {
const key = keyPrefix + k;
flattenAccum(flat, hit.fields || {}, '', indexPattern, params);
const field = indexPattern?.fields.getByName(key);
if (params?.shallow === false) {
const isNestedField = field?.type === 'nested';
if (Array.isArray(val) && !isNestedField) {
val.forEach((v) => isPlainObject(v) && flatten(v, key + '.'));
continue;
}
} else if (flat[key] !== undefined) {
continue;
}
const hasValidMapping = field && field.type !== 'conflict';
const isValue = !isPlainObject(val);
if (hasValidMapping || isValue) {
if (!flat[key]) {
flat[key] = val;
} else if (Array.isArray(flat[key])) {
flat[key].push(val);
} else {
flat[key] = [flat[key], val];
}
continue;
}
flatten(val, key + '.');
}
}
flatten(hit.fields || {});
if (params?.source !== false && hit._source) {
flatten(hit._source as Record<string, any>);
flattenAccum(flat, hit._source as Record<string, any>, '', indexPattern, params);
} else if (params?.includeIgnoredValues && hit.ignored_field_values) {
// If enabled merge the ignored_field_values into the flattened hit. This will
// merge values that are not actually indexed by ES (i.e. ignored), e.g. because
@ -90,7 +111,11 @@ export function flattenHit(hit: Hit, indexPattern?: DataView, params?: TabifyDoc
// merged them both together. We do not merge this (even if enabled) in case source has been
// merged, since we would otherwise duplicate values, since ignore_field_values and _source
// contain the same values.
Object.entries(hit.ignored_field_values).forEach(([fieldName, fieldValue]) => {
for (const fieldName in hit.ignored_field_values) {
if (!hit.ignored_field_values.hasOwnProperty(fieldName)) {
continue;
}
const fieldValue = hit.ignored_field_values[fieldName];
if (flat[fieldName]) {
// If there was already a value from the fields API, make sure we're merging both together
if (Array.isArray(flat[fieldName])) {
@ -102,37 +127,45 @@ export function flattenHit(hit: Hit, indexPattern?: DataView, params?: TabifyDoc
// If no previous value was assigned we can simply use the value from `ignored_field_values` as it is
flat[fieldName] = fieldValue;
}
});
}
}
// Merge all valid meta fields into the flattened object
indexPattern?.metaFields?.forEach((fieldName) => {
const isExcludedMetaField =
EXCLUDED_META_FIELDS.includes(fieldName) || fieldName.charAt(0) !== '_';
if (isExcludedMetaField) {
return;
if (indexPattern?.metaFields) {
for (let i = 0; i < indexPattern?.metaFields.length; i++) {
const fieldName = indexPattern?.metaFields[i];
const isExcludedMetaField =
EXCLUDED_META_FIELDS.includes(fieldName) || fieldName.charAt(0) !== '_';
if (!isExcludedMetaField) {
flat[fieldName] = hit[fieldName as keyof estypes.SearchHit];
}
}
flat[fieldName] = hit[fieldName as keyof estypes.SearchHit];
});
}
// Use a proxy to make sure that keys are always returned in a specific order,
// so we have a guarantee on the flattened order of keys.
return makeProxy(flat, indexPattern);
}
function makeProxy(flat: Record<string, any>, indexPattern?: DataView) {
function comparator(a: string | symbol, b: string | symbol) {
const aIsMeta = indexPattern?.metaFields?.includes(String(a));
const bIsMeta = indexPattern?.metaFields?.includes(String(b));
if (aIsMeta && bIsMeta) {
return String(a).localeCompare(String(b));
}
if (aIsMeta) {
return 1;
}
if (bIsMeta) {
return -1;
}
return String(a).localeCompare(String(b));
}
return new Proxy(flat, {
ownKeys: (target) => {
return Reflect.ownKeys(target).sort((a, b) => {
const aIsMeta = indexPattern?.metaFields?.includes(String(a));
const bIsMeta = indexPattern?.metaFields?.includes(String(b));
if (aIsMeta && bIsMeta) {
return String(a).localeCompare(String(b));
}
if (aIsMeta) {
return 1;
}
if (bIsMeta) {
return -1;
}
return String(a).localeCompare(String(b));
});
return Reflect.ownKeys(target).sort(comparator);
},
});
}
@ -144,9 +177,12 @@ export const tabifyDocs = (
): Datatable => {
const columns: DatatableColumn[] = [];
const rows = esResponse.hits.hits
const rows: Array<DatatableRow | undefined> = esResponse.hits.hits
.map((hit) => {
const flat = flattenHit(hit, index, params);
if (!flat) {
return;
}
for (const [key, value] of Object.entries(flat)) {
const field = index?.fields.getByName(key);
const fieldName = field?.name || key;
@ -172,6 +208,6 @@ export const tabifyDocs = (
return {
type: 'datatable',
columns,
rows,
rows: rows as DatatableRow[],
};
};