Skip to content

Commit

Permalink
chore(explain-plan-helper): add support for express plan COMPASS-7794 (
Browse files Browse the repository at this point in the history
…#6071)

* support for express plan

* add covered check and fix test

* add support for count_scan

* fix long tree stage title

* add clustered index type

* clustered ux

* undo change

* fix typos

* use parser to extract fields

* remove todo

* fix everything

* remove link to commit
  • Loading branch information
mabaasit authored Jul 31, 2024
1 parent b706b31 commit 0a9492a
Show file tree
Hide file tree
Showing 13 changed files with 643 additions and 71 deletions.
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import type { ReactHTML, ReactElement, ReactNode } from 'react';
import {
InlineDefinition,
Expand Down Expand Up @@ -131,6 +131,73 @@ const indexIconDescriptionStyles = css({
verticalAlign: 'text-top',
});

const indexTypeToMessage = {
COVERED: 'Query covered by index:',
MULTIPLE: 'Query used the following indexes (shard results differ):',
INDEX: 'Query used the following index:',
} as const;

const IndexDetails = ({
indexType,
indexKeys,
onCreateIndexInsightClick,
}: Pick<
ExplainPlanSummaryProps,
'indexType' | 'indexKeys' | 'onCreateIndexInsightClick'
>) => {
const darkMode = useDarkMode();
const showInsights = usePreference('showInsights');
const warningColor = darkMode ? palette.yellow.base : palette.yellow.dark2;

if (indexType === 'CLUSTERED') {
return null;
}

if (indexType === 'COLLSCAN' || indexType === 'UNAVAILABLE') {
return (
<div className={statsStyles} style={{ color: warningColor }}>
<Icon glyph="Warning"></Icon>
<span>No index available for this query.</span>
{showInsights && (
<SignalPopover
signals={{
...PerformanceSignals.get('explain-plan-without-index'),
onPrimaryActionButtonClick: onCreateIndexInsightClick,
}}
></SignalPopover>
)}
</div>
);
}

return (
<div className={indexesSummaryStyles}>
<ExplainPlanSummaryStat
as="div"
label={indexTypeToMessage[indexType]}
definition={
<>
The index(es) used to fulfill the query. A value of{' '}
<IndexIcon className={indexIconDescriptionStyles} direction={1} />{' '}
indicates an ascending index, and a value of{' '}
<IndexIcon className={indexIconDescriptionStyles} direction={-1} />{' '}
indicates a descending index.
</>
}
></ExplainPlanSummaryStat>
{indexKeys.map(([field, value]) => {
return (
<IndexBadge
key={`${field}:${String(value)}`}
field={field}
value={value}
></IndexBadge>
);
})}
</div>
);
};

export const ExplainPlanSummary: React.FunctionComponent<
ExplainPlanSummaryProps
> = ({
Expand All @@ -144,23 +211,6 @@ export const ExplainPlanSummary: React.FunctionComponent<
onCreateIndexInsightClick,
}) => {
const darkMode = useDarkMode();
const showInsights = usePreference('showInsights');

const warningColor = darkMode ? palette.yellow.base : palette.yellow.dark2;

const indexMessageText = useMemo(() => {
const typeToMessage = {
COLLSCAN: 'No index available for this query.',
COVERED: 'Query covered by index:',
MULTIPLE: 'Query used the following indexes (shard results differ):',
INDEX: 'Query used the following index:',
UNAVAILABLE: '',
};
return typeToMessage[indexType];
}, [indexType]);

const hasNoIndex = ['COLLSCAN', 'UNAVAILABLE'].includes(indexType);

return (
<KeylineCard
className={summaryCardStyles}
Expand Down Expand Up @@ -258,53 +308,11 @@ export const ExplainPlanSummary: React.FunctionComponent<
definition="Number of indexes examined to fulfill the query."
></ExplainPlanSummaryStat>

{!hasNoIndex && (
<div className={indexesSummaryStyles}>
<ExplainPlanSummaryStat
as="div"
label={indexMessageText}
definition={
<>
The index(es) used to fulfill the query. A value of{' '}
<IndexIcon
className={indexIconDescriptionStyles}
direction={1}
/>{' '}
indicates an ascending index, and a value of{' '}
<IndexIcon
className={indexIconDescriptionStyles}
direction={-1}
/>{' '}
indicates a descending index.
</>
}
></ExplainPlanSummaryStat>
{indexKeys.map(([field, value]) => {
return (
<IndexBadge
key={`${field}:${String(value)}`}
field={field}
value={value}
></IndexBadge>
);
})}
</div>
)}

{hasNoIndex && (
<div className={statsStyles} style={{ color: warningColor }}>
<Icon glyph="Warning"></Icon>
<span>No index available for this query.</span>
{showInsights && (
<SignalPopover
signals={{
...PerformanceSignals.get('explain-plan-without-index'),
onPrimaryActionButtonClick: onCreateIndexInsightClick,
}}
></SignalPopover>
)}
</div>
)}
<IndexDetails
indexType={indexType}
indexKeys={indexKeys}
onCreateIndexInsightClick={onCreateIndexInsightClick}
></IndexDetails>
</ul>
</KeylineCard>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const parseExplainTree = (
const extractHighlights = (stage: Omit<ExplainTreeNodeData, 'highlights'>) => {
switch (stage.name) {
case 'IXSCAN':
case 'EXPRESS_IXSCAN':
return {
'Index Name': stage.details?.indexName,
'Multi Key Index': stage.details?.isMultiKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,19 @@ const cardStylesDarkMode = css({
borderColor: palette.gray.light2,
});

const stageTitleStyles = css({
const stageTitleContainerStyles = css({
display: 'flex',
alignItems: 'center',
gap: spacing[2],
cursor: 'pointer',
});

const stageTitleStyles = css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});

const separatorStyles = css({
marginTop: spacing[2],
marginBottom: spacing[2],
Expand Down Expand Up @@ -247,6 +253,9 @@ const Highlight: React.FunctionComponent<{
value: string;
field: string;
}> = ({ field, value }) => {
if (typeof value === 'undefined') {
return null;
}
return (
<li className={overflowTextStyles}>
<span>{field}: </span>
Expand Down Expand Up @@ -314,9 +323,9 @@ const ExecutionStats: React.FunctionComponent<ExecutionstatsProps> = ({
const StageView: React.FunctionComponent<StageViewProps> = (props) => {
return (
<>
<div className={stageTitleStyles}>
<div className={stageTitleContainerStyles}>
<Icon glyph={props.detailsOpen ? 'ChevronDown' : 'ChevronRight'} />
<Subtitle>{props.name}</Subtitle>
<Subtitle className={stageTitleStyles}>{props.name}</Subtitle>
</div>

<ExecutionStats
Expand Down
1 change: 1 addition & 0 deletions packages/explain-plan-helper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"reformat": "npm run eslint . -- --fix && npm run prettier -- --write ."
},
"dependencies": {
"@mongodb-js/shell-bson-parser": "^1.1.0",
"mongodb-explain-compat": "^3.0.4"
},
"devDependencies": {
Expand Down
40 changes: 40 additions & 0 deletions packages/explain-plan-helper/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,46 @@ describe('explain-plan-plan', function () {
expect(plan.isCovered).to.equal(false);
});
});

describe('Express plans', function () {
it('should have the correct `usedIndexes` value in EXPRESS_IXSCAN', async function () {
plan = await loadExplainFixture('express_index_scan.json');
expect(plan.usedIndexes).to.deep.equal([
{ fields: { a: 1, b: 1 }, index: 'a_1_b_1', shard: null },
]);
});
it('should have the correct `isCovered` value in EXPRESS_IXSCAN', async function () {
plan = await loadExplainFixture('express_index_scan.json');
expect(plan.isCovered, 'totalDocsExamined is > 0').to.be.false;
plan.totalDocsExamined = 0;
expect(plan.isCovered, 'totalDocsExamined is = 0').to.be.true;
});

it('returns CLUSTERED indexType for EXPRESS_CLUSTERED_IXSCAN', async function () {
plan = await loadExplainFixture('express_clustered_scan.json');
expect(plan.isClusteredScan).to.be.true;
expect(plan.indexType).to.equal('CLUSTERED');
expect(plan.usedIndexes).to.deep.equal([]);
});

it('returns CLUSTERED indexType for CLUSTERED_IXSCAN', async function () {
plan = await loadExplainFixture('clustered_scan.json');
expect(plan.isClusteredScan).to.be.true;
expect(plan.indexType).to.equal('CLUSTERED');
expect(plan.usedIndexes).to.deep.equal([]);
});
});

describe('Count Scan', function () {
beforeEach(async function () {
plan = await loadExplainFixture('count_scan.json');
});
it('should have the correct `usedIndexes` value from COUNT_SCAN stage', function () {
expect(plan.usedIndexes).to.deep.equal([
{ fields: { a: 1 }, index: 'a_1', shard: null },
]);
});
});
});

context('Edge Cases', function () {
Expand Down
25 changes: 22 additions & 3 deletions packages/explain-plan-helper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import convertExplainCompat from 'mongodb-explain-compat';
import { getPlannerInfo } from './get-planner-info';
import { getExecutionStats } from './get-execution-stats';
import type { ExecutionStats } from './get-execution-stats';
import { getStageIndexFields } from './utils';

const kParent = Symbol('ExplainPlan.kParent');

Expand Down Expand Up @@ -50,10 +51,12 @@ export class ExplainPlan {

get usedIndexes(): IndexInformation[] {
const ixscan = this.findAllStagesByName('IXSCAN');
const expressIxscan = this.findAllStagesByName('EXPRESS_IXSCAN');
const countScan = this.findAllStagesByName('COUNT_SCAN');
// special case for IDHACK stage, using the _id_ index.
const idhack = this.findStageByName('IDHACK');
const ret: IndexInformation[] = this.executionStats?.stageIndexes ?? [];
for (const stage of [...ixscan, idhack]) {
for (const stage of [...ixscan, ...expressIxscan, ...countScan, idhack]) {
if (!stage) continue;
let shard: string | null = null;
if (this.isSharded) {
Expand All @@ -65,7 +68,7 @@ export class ExplainPlan {
}
}
const index: string = stage === idhack ? '_id_' : stage.indexName;
const fields = stage === idhack ? { _id: 1 } : stage.keyPattern ?? {};
const fields = stage === idhack ? { _id: 1 } : getStageIndexFields(stage);
ret.push({ index, shard, fields });
}
if (this.isSharded) {
Expand All @@ -89,7 +92,9 @@ export class ExplainPlan {
return false;
}
const ixscan = this.findStageByName('IXSCAN');
return ixscan?.parentName !== 'FETCH';
const expressIxscan = this.findStageByName('EXPRESS_IXSCAN');
const stage = ixscan || expressIxscan;
return stage?.parentName !== 'FETCH';
}

get isMultiKey(): boolean {
Expand All @@ -114,9 +119,23 @@ export class ExplainPlan {
: 0;
}

get isClusteredScan(): boolean {
return Boolean(
this.findStageByName('EXPRESS_CLUSTERED_IXSCAN') ||
this.findStageByName('CLUSTERED_IXSCAN')
);
}

get indexType() {
const indexes = this.usedIndexes;

// Currently the CLUSTERED_IXSCAN and EXPRESS_CLUSTERED_IXSCAN do not report
// any indexes in the winning stage. Even though the query clearly uses
// an index. And since we can't determine the index used, we will return CLUSTER.
if (this.isClusteredScan) {
return 'CLUSTERED';
}

if (indexes.length === 0) {
return 'UNAVAILABLE';
}
Expand Down
Loading

0 comments on commit 0a9492a

Please sign in to comment.