Files
mongo/jstests/libs/query/analyze_plan.js
Alya Carina Berciu 6ce6f7b087 SERVER-115104 Pretty-print join plans as trees in e2e tests (#45048)
GitOrigin-RevId: f6c8ec2d9295e4f92a1e36785487ef9699fc03e5
2025-12-15 17:25:23 +00:00

1392 lines
49 KiB
JavaScript

// Contains helpers for checking, based on the explain output, properties of a
// plan. For instance, there are helpers for checking whether a plan is a collection
// scan or whether the plan is covered (index only).
import {documentEq} from "jstests/aggregation/extras/utils.js";
/**
* Returns query planner part of explain for every node in the explain report.
*/
export function getQueryPlanners(explain) {
return getAllNodeExplains(explain).flatMap((nodeExplain) => {
// When the shards are present in 'queryPlanner.winningPlan', then the 'nodeExplain' itself
// represents the shard's 'queryPlanner'.
const isQueryPlanner = nodeExplain.hasOwnProperty("winningPlan");
if (isQueryPlanner) {
return [nodeExplain];
}
// Otherwise, the planner outputs will be nested deeper under a 'queryPlanner' field.
return getNestedProperties(nodeExplain, "queryPlanner");
});
}
/**
* Utility to return the 'queryPlanner' section of 'explain'. The input is the root of the explain
* output.
*/
export function getQueryPlanner(explain) {
explain = getSingleNodeExplain(explain);
if ("queryPlanner" in explain) {
const qp = explain.queryPlanner;
// Sharded case.
if ("winningPlan" in qp && "shards" in qp.winningPlan) {
return qp.winningPlan.shards[0];
}
return qp;
}
assert(explain.hasOwnProperty("stages"), explain);
const stage = explain.stages[0];
const cursorStage = stage.$cursor || stage.$geoNearCursor;
assert(cursorStage, explain);
assert(cursorStage.hasOwnProperty("queryPlanner"), explain);
return cursorStage.queryPlanner;
}
/**
* Help function to extract shards from explain in sharded environment. Returns null for
* non-sharded plans.
*/
export function getShardsFromExplain(explain) {
if (explain.hasOwnProperty("queryPlanner") && explain.queryPlanner.hasOwnProperty("winningPlan")) {
return explain.queryPlanner.winningPlan.shards;
}
return null;
}
/**
* Extracts and returns an array of explain outputs for every shard in a sharded cluster; returns
* the original explain output in case of a single replica set.
*/
export function getAllNodeExplains(explain) {
let shardsExplain = [];
// If 'splitPipeline' is defined, there could be explains for each shard in the 'mergerPart' of
// the 'splitPipeline', e.g. $unionWith.
if (explain.splitPipeline) {
const splitPipelineShards = getNestedProperties(explain.splitPipeline, "shards");
shardsExplain.push(...splitPipelineShards.flatMap(Object.values));
}
if (explain.shards) {
shardsExplain.push(...Object.values(explain.shards));
}
// NOTE: When shards explain is present in the 'queryPlanner.winningPlan' the shard explains are
// placed in the array and therefore there is no need to call Object.values() on each element.
const shards = getShardsFromExplain(explain);
if (shards) {
assert(Array.isArray(shards), shards);
shardsExplain.push(...shards);
}
if (shardsExplain.length > 0) {
return shardsExplain;
}
return [explain];
}
/**
* Returns the output from a single shard if 'explain' was obtained from an unsharded collection;
* returns 'explain' as is otherwise.
*/
export function getSingleNodeExplain(explain) {
if ("shards" in explain) {
const shards = explain.shards;
const shardNames = Object.keys(shards);
// There should only be one shard given that this function assumes that 'explain' was
// obtained from an unsharded collection.
assert.eq(shardNames.length, 1, explain);
return shards[shardNames[0]];
}
return explain;
}
/**
* Returns the winning plan from the corresponding sub-node of classic/SBE explain output. Takes
* into account that the plan may or may not have agg stages.
* For sharded collections, this may return the top-level "winningPlan" which contains the shards.
* To ensure getting the winning plan for a specific shard, provide as input the specific explain
* for that shard i.e, explain.queryPlanner.winningPlan.shards[shardNames[0]].
*/
export function getWinningPlanFromExplain(explain, isSBEPlan = false) {
let getWinningSBEPlan = (queryPlanner) => queryPlanner.winningPlan.slotBasedPlan;
// The 'queryPlan' format is used when the SBE engine is turned on. If this field is present,
// it will hold a serialized winning plan, otherwise it will be stored in the 'winningPlan'
// field itself.
let getWinningPlan = (queryPlanner) =>
queryPlanner.winningPlan.hasOwnProperty("queryPlan")
? queryPlanner.winningPlan.queryPlan
: queryPlanner.winningPlan;
if ("shards" in explain) {
for (const shardName in explain.shards) {
let queryPlanner = getQueryPlanner(explain.shards[shardName]);
return isSBEPlan ? getWinningSBEPlan(queryPlanner) : getWinningPlan(queryPlanner);
}
}
if (explain.hasOwnProperty("pipeline")) {
const pipeline = explain.pipeline;
// Pipeline stages' explain output come in two shapes:
// 1. When in single node, as a single object array
// 2. When in sharded, as an object.
if (pipeline.constructor === Array) {
return getWinningPlanFromExplain(pipeline[0].$cursor, isSBEPlan);
} else {
return getWinningPlanFromExplain(pipeline, isSBEPlan);
}
}
let queryPlanner = explain;
if (explain.hasOwnProperty("queryPlanner") || explain.hasOwnProperty("stages")) {
queryPlanner = getQueryPlanner(explain);
}
return isSBEPlan ? getWinningSBEPlan(queryPlanner) : getWinningPlan(queryPlanner);
}
/**
* Returns an element of explain output which represents a rejected candidate plan.
*/
export function getRejectedPlan(rejectedPlan) {
// The 'queryPlan' format is used when the SBE engine is turned on. If this field is present,
// it will hold a serialized winning plan, otherwise it will be stored in the 'rejectedPlan'
// element itself.
return rejectedPlan.hasOwnProperty("queryPlan") ? rejectedPlan.queryPlan : rejectedPlan;
}
/**
* Returns a sub-element of the 'cachedPlan' explain output which represents a query plan.
*/
export function getCachedPlan(cachedPlan) {
// The 'queryPlan' format is used when the SBE engine is turned on. If this field is present, it
// will hold a serialized cached plan, otherwise it will be stored in the 'cachedPlan' field
// itself.
return cachedPlan.hasOwnProperty("queryPlan") ? cachedPlan.queryPlan : cachedPlan;
}
function isPlainObject(value) {
return value && typeof value == "object" && value.constructor === Object;
}
export const kExplainChildFieldNames = [
"inputStage",
"inputStages",
"thenStage",
"elseStage",
"outerStage",
"stages",
"innerStage",
"child",
"leftChild",
"rightChild",
];
/**
* Removes unwanted/variable fields from the explain plan without altering the tree structure,
* and if flatten is set to true, turns the tree structure into an array (useful for plans where every node has one child).
*/
export function normalizePlan(plan, flatten = true) {
let results = [];
if (!isPlainObject(plan)) {
return results;
}
// Expand this array if you find new fields which are inconsistent across different test runs.
const ignoreFields = ["isCached", "indexVersion", "planNodeId"];
// Iterates over the plan while ignoring the `ignoreFields`, to create flattened stages whenever
// `kExplainChildFieldNames` are encountered.
const stack = [["root", {...plan}]];
while (stack.length > 0) {
const [name, next] = stack.pop();
ignoreFields.forEach((field) => delete next[field]);
for (const childField of kExplainChildFieldNames) {
if (childField in next) {
const child = next[childField];
if (flatten) delete next[childField];
if (Array.isArray(child)) {
for (let i = 0; i < child.length; i++) {
stack.push([childField, child[i]]);
}
} else {
stack.push([childField, child]);
}
}
}
if (flatten) {
results.push(next);
} else if (name == "root" && !flatten) {
results = plan;
}
}
return results;
}
/**
* Returns an object containing the winning plan and an array of rejected plans for the given
* queryPlanner. Each of those plans is returned in its flattened form.
*/
export function formatQueryPlanner(queryPlanner, shouldFlatten = true) {
let winningPlan = normalizePlan(getWinningPlanFromExplain(queryPlanner), shouldFlatten);
let rejectedPlans = queryPlanner.rejectedPlans.map((plan) => normalizePlan(plan, shouldFlatten));
return {winningPlan, rejectedPlans};
}
/**
* Formats the given pipeline, which must be an array of stage objects. Returns an array of
* formatted stages. It excludes fields which might differ in the explain across multiple executions
* of the same query.
*/
export function formatPipeline(pipeline, shouldFlatten = true) {
const results = [];
// Pipeline must be an array of objects
if (!pipeline || !Array.isArray(pipeline) || !pipeline.every(isPlainObject)) {
return results;
}
// Expand this array if you find new fields which are inconsistent across different test runs.
const ignoreFields = ["lsid"];
for (const stage of pipeline) {
const keys = Object.keys(stage).filter((key) => key.startsWith("$"));
if (keys.length !== 1) {
throw Error("This is not a stage: " + tojson(stage));
}
const stageName = keys[0];
if (stageName == "$cursor") {
const queryPlanner = stage[stageName].queryPlanner;
results.push({[stageName]: formatQueryPlanner(queryPlanner, shouldFlatten)});
} else {
const stageCopy = {...stage[stageName]};
ignoreFields.forEach((field) => delete stageCopy[field]);
// Don't keep any fields that are on the same level as the stage name
results.push({[stageName]: stageCopy});
}
}
return results;
}
/**
* Helper function to only add `field` to `dest` if it is present in `src`. A lambda can be passed
* to transform the field value when it is added to `dest`.
*/
function addIfPresent(field, src, dest, lambda = (i) => i) {
if (src && dest && field in src) {
dest[field] = lambda(src[field]);
}
}
/**
* If queryPlanner contains an array of shards, this returns both the merger part and shards
* part. Both are flattened.
*/
function invertShards(queryPlanner, shouldFlatten = true) {
const winningPlan = queryPlanner.winningPlan;
const shards = winningPlan.shards;
if (!Array.isArray(shards)) {
throw Error("Expected shards field to be array, got: " + tojson(shards));
}
const topStage = {...winningPlan};
delete topStage.shards;
const res = {mergerPart: normalizePlan(topStage, shouldFlatten), shardsPart: {}};
shards.forEach((shard) => (res.shardsPart[shard.shardName] = formatQueryPlanner(shard, shouldFlatten)));
return res;
}
/**
* Returns a formatted version of the explain, excluding fields which might differ in the explain
* across multiple executions of the same query (e.g. caching information or UUIDs).
*/
export function formatExplainRoot(explain, shouldFlatten = true) {
let res = {};
if (!isPlainObject(explain)) {
return res;
}
const formatExplainPipeline = (pipeline) => {
return formatPipeline(pipeline, shouldFlatten);
};
addIfPresent("mergeType", explain, res);
if ("splitPipeline" in explain) {
addIfPresent("mergerPart", explain.splitPipeline, res, formatExplainPipeline);
addIfPresent("shardsPart", explain.splitPipeline, res, formatExplainPipeline);
}
if ("shards" in explain) {
for (const [shardName, shardExplain] of Object.entries(explain["shards"])) {
res[shardName] =
"queryPlanner" in shardExplain
? formatQueryPlanner(shardExplain.queryPlanner, shouldFlatten)
: formatExplainPipeline(shardExplain.stages);
}
} else if ("queryPlanner" in explain && "shards" in explain.queryPlanner.winningPlan) {
res = {...res, ...invertShards(explain.queryPlanner, shouldFlatten)};
} else if ("queryPlanner" in explain) {
res = {...res, ...formatQueryPlanner(explain.queryPlanner, shouldFlatten)};
} else if ("stages" in explain) {
res.stages = formatExplainPipeline(explain.stages);
}
addIfPresent("queryShapeHash", explain, res);
return res;
}
/**
* Traverses a explain plan to find all occurrences of a specified stage.
*
* This function recursively navigates through the 'root' document, which should be
* the output of an explain command (or any of its subdocuments). It
* identifies and collects all plan stages that match the provided 'stage' name.
* The function supports explain outputs from both aggregation pipelines and
* query commands (e.g. find, count, etc..).
*
* @param {object} root The explain plan document or a subdocument thereof.
* @param {string|null} stage The name of the stage to search for (e.g., 'IXSCAN', 'COLLSCAN').
* If 'null', the function returns all stages found in the 'root' document.
* @returns {Array<object>} A list of objects, where each object represents a stage
* matching the 'stage' argument. Returns an empty array
* if no matching stages are found or if the plan is empty.
*/
export function getPlanStages(root, stage) {
let results = [];
if (root.stage === stage || stage === undefined || root.nodeType === stage) {
results.push(root);
}
if ("inputStage" in root) {
results = results.concat(getPlanStages(root.inputStage, stage));
}
if ("inputStages" in root) {
for (let i = 0; i < root.inputStages.length; i++) {
results = results.concat(getPlanStages(root.inputStages[i], stage));
}
}
if ("queryPlanner" in root) {
results = results.concat(getPlanStages(getWinningPlanFromExplain(root.queryPlanner), stage));
}
if ("thenStage" in root) {
results = results.concat(getPlanStages(root.thenStage, stage));
}
if ("elseStage" in root) {
results = results.concat(getPlanStages(root.elseStage, stage));
}
if ("outerStage" in root) {
results = results.concat(getPlanStages(root.outerStage, stage));
}
if ("innerStage" in root) {
results = results.concat(getPlanStages(root.innerStage, stage));
}
if ("queryPlan" in root) {
results = results.concat(getPlanStages(root.queryPlan, stage));
}
if ("child" in root) {
results = results.concat(getPlanStages(root.child, stage));
}
if ("leftChild" in root) {
results = results.concat(getPlanStages(root.leftChild, stage));
}
if ("rightChild" in root) {
results = results.concat(getPlanStages(root.rightChild, stage));
}
if ("shards" in root) {
if (Array.isArray(root.shards)) {
results = root.shards.reduce(
(res, shard) =>
res.concat(
getPlanStages(
shard.hasOwnProperty("winningPlan")
? getWinningPlanFromExplain(shard)
: shard.executionStages,
stage,
),
),
results,
);
} else {
const shards = Object.keys(root.shards);
results = shards.reduce((res, shard) => res.concat(getPlanStages(root.shards[shard], stage)), results);
}
}
if ("stages" in root) {
results = results.concat(getAggPlanStages(root, stage));
}
return results;
}
/**
* Given the root stage of explain's JSON representation of a query plan ('root'), returns a list of
* all the stages in 'root'.
*/
export function getAllPlanStages(root) {
return getPlanStages(root);
}
/**
* Given the root stage of explain's JSON representation of a query plan ('root'), returns the
* subdocument with its stage as 'stage'. Returns null if the plan does not have such a stage.
* Asserts that no more than one stage is a match.
*/
export function getPlanStage(root, stage) {
assert(stage, "Stage was not defined in getPlanStage.");
let planStageList = getPlanStages(root, stage);
if (planStageList.length === 0) {
return null;
} else {
assert(
planStageList.length === 1,
"getPlanStage expects to find 0 or 1 matching stages. planStageList: " + tojson(planStageList),
);
return planStageList[0];
}
}
/**
* Returns the set of rejected plans from the given replset or sharded explain output.
*/
export function getRejectedPlans(root) {
if (root.hasOwnProperty("queryPlanner")) {
if (root.queryPlanner.winningPlan.hasOwnProperty("shards")) {
const rejectedPlans = [];
for (let shard of root.queryPlanner.winningPlan.shards) {
for (let rejectedPlan of shard.rejectedPlans) {
rejectedPlans.push(Object.assign({shardName: shard.shardName}, rejectedPlan));
}
}
return rejectedPlans;
}
return root.queryPlanner.rejectedPlans;
} else if ("shards" in root) {
for (const shardName in root.shards) {
return getRejectedPlans(root.shards[shardName]);
}
} else {
return root.stages[0]["$cursor"].queryPlanner.rejectedPlans;
}
}
/**
* Given the root stage of explain's JSON representation of a query plan ('root'), returns true if
* the query planner reports at least one rejected alternative plan, and false otherwise.
*/
export function hasRejectedPlans(root) {
function sectionHasRejectedPlans(explainSection) {
assert(explainSection.hasOwnProperty("rejectedPlans"), tojson(explainSection));
return explainSection.rejectedPlans.length !== 0;
}
function cursorStageHasRejectedPlans(cursorStage) {
assert(cursorStage.hasOwnProperty("$cursor"), tojson(cursorStage));
assert(cursorStage.$cursor.hasOwnProperty("queryPlanner"), tojson(cursorStage));
return sectionHasRejectedPlans(cursorStage.$cursor.queryPlanner);
}
if (root.hasOwnProperty("shards")) {
// This is a sharded agg explain. Recursively check whether any of the shards has rejected
// plans.
const shardExplains = [];
for (const shard in root.shards) {
shardExplains.push(root.shards[shard]);
}
return shardExplains.some(hasRejectedPlans);
} else if (root.hasOwnProperty("stages")) {
// This is an agg explain.
const cursorStages = getAggPlanStages(root, "$cursor");
return cursorStages.find((cursorStage) => cursorStageHasRejectedPlans(cursorStage)) !== undefined;
} else {
// This is some sort of query explain.
assert(root.hasOwnProperty("queryPlanner"), tojson(root));
assert(root.queryPlanner.hasOwnProperty("winningPlan"), tojson(root));
if (!root.queryPlanner.winningPlan.hasOwnProperty("shards")) {
// This is an unsharded explain.
return sectionHasRejectedPlans(root.queryPlanner);
}
// This is a sharded explain. Each entry in the shards array contains a 'winningPlan' and
// 'rejectedPlans'.
return root.queryPlanner.winningPlan.shards.find((shard) => sectionHasRejectedPlans(shard)) !== undefined;
}
}
/**
* Returns an array of execution stages from the given replset or sharded explain output.
*/
export function getExecutionStages(root) {
if (root.hasOwnProperty("executionStats") && root.executionStats.executionStages.hasOwnProperty("shards")) {
const executionStages = [];
for (let shard of root.executionStats.executionStages.shards) {
executionStages.push(
Object.assign(
{shardName: shard.shardName, executionSuccess: shard.executionSuccess},
shard.executionStages,
),
);
}
return executionStages;
}
if (root.hasOwnProperty("shards")) {
const executionStages = [];
for (const shard in root.shards) {
executionStages.push(root.shards[shard].executionStats.executionStages);
}
return executionStages;
}
return [root.executionStats.executionStages];
}
/**
* Returns an array of "executionStats" from the given replset or sharded explain output.
*/
export function getExecutionStats(root) {
if (root.hasOwnProperty("shards")) {
return Object.values(root.shards).map((shardExplain) => shardExplain.executionStats);
}
assert(root.hasOwnProperty("executionStats"), root);
if (
root.executionStats.hasOwnProperty("executionStages") &&
root.executionStats.executionStages.hasOwnProperty("shards")
) {
return root.executionStats.executionStages.shards;
}
return [root.executionStats];
}
/**
* Extract the high-level stable fields from the root. These include fields such as "stage",
* "nReturned", and "totalDocsExamined" that should remain the same across runs. Unstable
* measurement fields such as "executionTimeMillis" and "totalChildMillis" are excluded, as are more
* detailed fields.
*/
export function extractStableExecutionFieldsSummary(root) {
if (!root) return undefined;
const stableKeys = [
"stage",
"nCounted",
"nReturned",
"limitAmount",
"skipAmount",
"isEOF",
"totalDocsExamined",
"totalKeysExamined",
];
const stable = {};
for (const key of stableKeys) {
if (key in root) {
stable[key] = root[key];
}
}
const input = extractStableExecutionFieldsSummary(root.inputStage);
if (input) {
stable.inputStage = input;
}
return stable;
}
/**
* Get high-level stable fields from the executionStats section of explain from the root
* executionStages field and any nested executionStages objects.
*/
export function getStableExecutionStats(explain) {
const execStats = explain.executionStats;
const topLevel = execStats.executionStages;
const stableFields = extractStableExecutionFieldsSummary(topLevel);
if (topLevel.shards) {
const sortedShards = [...topLevel.shards].sort((a, b) => a.shardName.localeCompare(b.shardName));
stableFields.shards = sortedShards.map((shard) => ({
nReturned: shard.nReturned,
totalDocsExamined: shard.totalDocsExamined,
totalKeysExamined: shard.totalKeysExamined,
executionStages: extractStableExecutionFieldsSummary(shard.executionStages),
}));
}
return [{executionStages: stableFields}];
}
/**
* Returns the winningPlan.queryPlan of each shard in the explain in a list.
*/
export function getShardQueryPlans(root) {
let result = [];
if (root.hasOwnProperty("shards")) {
for (let shardName of Object.keys(root.shards)) {
let shard = root.shards[shardName];
result.push(shard.queryPlanner.winningPlan.queryPlan);
}
} else {
for (let shard of root.queryPlanner.winningPlan.shards) {
result.push(shard.winningPlan.queryPlan);
}
}
return result;
}
/**
* Returns an array of strings representing the "planSummary" values found in the input explain.
* Assumes the given input is the root of an explain.
*
* The helper supports sharded and unsharded explain.
*/
export function getPlanSummaries(root) {
let res = [];
// Queries that use the find system have top-level queryPlanner and winningPlan fields. If it's
// a sharded query, the shards have their own winningPlan fields to look at.
if ("queryPlanner" in root && "winningPlan" in root.queryPlanner) {
const wp = root.queryPlanner.winningPlan;
if ("shards" in wp) {
for (let shard of wp.shards) {
res.push(shard.winningPlan.planSummary);
}
} else {
res.push(wp.planSummary);
}
}
// Queries that use the agg system either have a top-level stages field or a top-level shards
// field. getQueryPlanner pulls the queryPlanner out of the stages/cursor subfields.
if ("stages" in root) {
res.push(getQueryPlanner(root).winningPlan.planSummary);
}
if ("shards" in root) {
for (let shardName of Object.keys(root.shards)) {
let shard = root.shards[shardName];
res.push(getQueryPlanner(shard).winningPlan.planSummary);
}
}
return res.filter((elem) => elem !== undefined);
}
/**
* Given the root stage of agg explain's JSON representation of a query plan ('root'), returns all
* subdocuments whose stage is 'stage'. This can either be an agg stage name like "$cursor" or
* "$sort", or a query stage name like "IXSCAN" or "SORT".
*
* If 'useQueryPlannerSection' is set to 'true', the 'queryPlanner' section of the explain output
* will be used to lookup the given 'stage', even if 'executionStats' section is available.
*
* Returns an empty array if the plan does not have the requested stage. Asserts that agg explain
* structure matches expected format.
*/
export function getAggPlanStages(root, stage, useQueryPlannerSection = false) {
assert(stage, "Stage was not defined in getAggPlanStages.");
let results = [];
function getDocumentSources(docSourceArray) {
let results = [];
for (let i = 0; i < docSourceArray.length; i++) {
let properties = Object.getOwnPropertyNames(docSourceArray[i]);
if (properties[0] === stage) {
results.push(docSourceArray[i]);
}
}
return results;
}
function getStagesFromQueryLayerOutput(queryLayerOutput) {
let results = [];
assert(queryLayerOutput.hasOwnProperty("queryPlanner"));
assert(queryLayerOutput.queryPlanner.hasOwnProperty("winningPlan"));
// If execution stats are available, then use the execution stats tree. Otherwise use the
// plan info from the "queryPlanner" section.
if (queryLayerOutput.hasOwnProperty("executionStats") && !useQueryPlannerSection) {
assert(queryLayerOutput.executionStats.hasOwnProperty("executionStages"));
results = results.concat(getPlanStages(queryLayerOutput.executionStats.executionStages, stage));
} else {
results = results.concat(getPlanStages(getWinningPlanFromExplain(queryLayerOutput.queryPlanner), stage));
}
return results;
}
if (root.hasOwnProperty("stages")) {
assert(root.stages.constructor === Array);
results = results.concat(getDocumentSources(root.stages));
if (root.stages[0].hasOwnProperty("$cursor")) {
results = results.concat(getStagesFromQueryLayerOutput(root.stages[0].$cursor));
} else if (root.stages[0].hasOwnProperty("$geoNearCursor")) {
results = results.concat(getStagesFromQueryLayerOutput(root.stages[0].$geoNearCursor));
}
}
if (root.hasOwnProperty("shards")) {
for (let elem in root.shards) {
if (root.shards[elem].hasOwnProperty("queryPlanner")) {
// The shard was able to optimize away the pipeline, which means that the format of
// the explain output doesn't have the "stages" array.
assert.eq(true, root.shards[elem].queryPlanner.optimizedPipeline);
results = results.concat(getStagesFromQueryLayerOutput(root.shards[elem]));
// Move onto the next shard.
continue;
}
if (!root.shards[elem].hasOwnProperty("stages")) {
continue;
}
assert(root.shards[elem].stages.constructor === Array);
results = results.concat(getDocumentSources(root.shards[elem].stages));
const firstStage = root.shards[elem].stages[0];
if (firstStage.hasOwnProperty("$cursor")) {
results = results.concat(getStagesFromQueryLayerOutput(firstStage.$cursor));
} else if (firstStage.hasOwnProperty("$geoNearCursor")) {
results = results.concat(getStagesFromQueryLayerOutput(firstStage.$geoNearCursor));
}
}
}
// If the agg pipeline was completely optimized away, then the agg explain output will be
// formatted like the explain output for a find command.
if (root.hasOwnProperty("queryPlanner")) {
assert.eq(true, root.queryPlanner.optimizedPipeline);
results = results.concat(getStagesFromQueryLayerOutput(root));
}
return results;
}
/**
* Given the root stage of agg explain's JSON representation of a query plan ('root'), returns the
* subdocument with its stage as 'stage'. Returns null if the plan does not have such a stage.
* Asserts that no more than one stage is a match.
*
* If 'useQueryPlannerSection' is set to 'true', the 'queryPlanner' section of the explain output
* will be used to lookup the given 'stage', even if 'executionStats' section is available.
*/
export function getAggPlanStage(root, stage, useQueryPlannerSection = false) {
assert(stage, "Stage was not defined in getAggPlanStage.");
let planStageList = getAggPlanStages(root, stage, useQueryPlannerSection);
if (planStageList.length === 0) {
return null;
} else {
assert.eq(
1,
planStageList.length,
"getAggPlanStage expects to find 0 or 1 matching stages. planStageList: " + tojson(planStageList),
);
return planStageList[0];
}
}
/**
* Given the root stage of an explain's JSON representation of a query plan ('root'), returns the
* specified stage of the plan.
*
* The normal getAggPlanStages() doesn't find certain stages in the sharded scenario since they
* exist in the splitPipeline.
**/
export function getStageFromSplitPipeline(root, stage) {
function checkForStageInSubAggPipe(subAggPipe, stage) {
for (let i = 0; i < subAggPipe.length; i++) {
const subAggStage = subAggPipe[i];
if (subAggStage.hasOwnProperty(stage)) {
return subAggStage;
}
}
}
if (root.splitPipeline != null) {
// If there is only one shard, the whole pipeline will run on that shard.
let subAggPipe = root.splitPipeline === null ? root.shards["shard-rs0"].stages : root.splitPipeline.mergerPart;
// Check if the requested stage exists in the merger part.
const stageInMergerPart = checkForStageInSubAggPipe(subAggPipe, stage);
if (stageInMergerPart) {
return stageInMergerPart;
}
// If the requested stage isn't in the merger part, it might be in the shards part.
subAggPipe = root.splitPipeline.shardsPart;
return checkForStageInSubAggPipe(subAggPipe, stage);
} else {
return getAggPlanStage(root, stage);
}
}
export function getUnionWithStage(root) {
return getStageFromSplitPipeline(root, "$unionWith");
}
export function getLookupStage(root) {
return getStageFromSplitPipeline(root, "$lookup");
}
/**
* Given the root stage of agg explain's JSON representation of a query plan ('root'), returns
* whether the plan has a stage called 'stage'. It could have more than one to allow for sharded
* explain plans, and it can search for a query planner stage like "FETCH" or an agg stage like
* "$group."
*/
export function aggPlanHasStage(root, stage) {
return getAggPlanStages(root, stage).length > 0;
}
/**
* Given the root stage of explain's BSON representation of a query plan ('root'),
* returns true if the plan has a stage called 'stage'.
*/
export function planHasStage(db, root, stage) {
assert(stage, "Stage was not defined in planHasStage.");
return getPlanStages(root, stage).length > 0;
}
/**
* A query is covered iff it does *not* have a FETCH stage or a COLLSCAN.
*
* Given the root stage of explain's BSON representation of a query plan ('root'),
* returns true if the plan is index only. Otherwise returns false.
*/
export function isIndexOnly(db, root) {
return !planHasStage(db, root, "FETCH") && !planHasStage(db, root, "COLLSCAN");
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using
* an index scan, and false otherwise.
*/
export function isIxscan(db, root) {
return planHasStage(db, root, "IXSCAN");
}
/**
* Returns true if the plan is formed of a single EOF stage. False otherwise.
*/
export function isEofPlan(db, root) {
return planHasStage(db, root, "EOF");
}
/**
* Returns true if the plan contains fetch stages containing '$alwaysFalse' filters, or false
* otherwise.
*/
export function isAlwaysFalsePlan(root) {
const hasAlwaysFalseFilter = (stage) => stage && stage.filter && stage.filter["$alwaysFalse"] === 1;
return getPlanStages(root, "FETCH").every(hasAlwaysFalseFilter);
}
export function isIdhackOrExpress(db, root) {
return isExpress(db, root) || isIdhack(db, root);
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using
* the idhack fast path, and false otherwise. These can be represented either as
* explicit 'IDHACK' or as 'CLUSTERED_IXSCAN' stages with equal min & max
* record bounds in the case of clustered collections.
*/
export function isIdhack(db, root) {
if (planHasStage(db, root, "IDHACK")) {
return true;
}
if (!isClusteredIxscan(db, root)) {
return false;
}
const stage = getPlanStages(root, "CLUSTERED_IXSCAN")[0];
if (stage.minRecord instanceof ObjectId) {
return stage.minRecord.equals(stage.maxRecord);
} else {
if (isObject(stage.minRecord) && isObject(stage.maxRecord)) {
return documentEq(stage.minRecord, stage.maxRecord);
}
return stage.minRecord === stage.maxRecord;
}
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using
* the EXPRESS executor, and false otherwise.
*/
export function isExpress(db, root) {
return (
planHasStage(db, root, "EXPRESS_IXSCAN") ||
planHasStage(db, root, "EXPRESS_CLUSTERED_IXSCAN") ||
planHasStage(db, root, "EXPRESS_UPDATE") ||
planHasStage(db, root, "EXPRESS_DELETE")
);
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using
* a collection scan, and false otherwise.
*/
export function isCollscan(db, root) {
return planHasStage(db, root, "COLLSCAN");
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using
* a clustered Ix scan, and false otherwise.
*/
export function isClusteredIxscan(db, root) {
return planHasStage(db, root, "CLUSTERED_IXSCAN");
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using the aggregation
* framework, and false otherwise.
*/
export function isAggregationPlan(root) {
if (root.hasOwnProperty("shards")) {
const shards = Object.keys(root.shards);
return shards.reduce((res, shard) => (res + root.shards[shard].hasOwnProperty("stages") ? 1 : 0), 0) > 0;
}
return root.hasOwnProperty("stages");
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using just the query layer,
* and false otherwise.
*/
export function isQueryPlan(root) {
if (root.hasOwnProperty("shards")) {
const shards = Object.keys(root.shards);
return shards.reduce((res, shard) => (res + root.shards[shard].hasOwnProperty("queryPlanner") ? 1 : 0), 0) > 0;
}
return root.hasOwnProperty("queryPlanner");
}
/**
* Returns true if every winning plan present in the explain satisfies the predicate. Returns
* false otherwise.
*/
export function everyWinningPlan(explain, predicate) {
return getQueryPlanners(explain)
.map((queryPlanner) => getWinningPlanFromExplain(queryPlanner, false))
.every(predicate);
}
/**
* Get the "chunk skips" for a single shard. Here, "chunk skips" refer to documents excluded by the
* shard filter.
*/
export function getChunkSkipsFromShard(shardPlan, shardExecutionStages) {
const shardFilterPlanStage = getPlanStage(getWinningPlanFromExplain(shardPlan), "SHARDING_FILTER");
if (!shardFilterPlanStage) {
return 0;
}
if (shardFilterPlanStage.hasOwnProperty("planNodeId")) {
const shardFilterNodeId = shardFilterPlanStage.planNodeId;
// If the query plan's shard filter has a 'planNodeId' value, we search for the
// corresponding SBE filter stage and use its stats to determine how many documents were
// excluded.
const filters = getPlanStages(shardExecutionStages.executionStages, "filter").filter(
(stage) => stage.planNodeId === shardFilterNodeId,
);
return filters.reduce((numSkips, stage) => numSkips + (stage.numTested - stage.nReturned), 0);
} else {
// Otherwise, we assume that execution used a "classic" SHARDING_FILTER stage, which
// explicitly reports a "chunkSkips" value.
const filters = getPlanStages(shardExecutionStages.executionStages, "SHARDING_FILTER");
return filters.reduce((numSkips, stage) => numSkips + stage.chunkSkips, 0);
}
}
/**
* Get the sum of "chunk skips" from all shards. Here, "chunk skips" refer to documents excluded by
* the shard filter.
*/
export function getChunkSkipsFromAllShards(explainResult) {
const shardPlanArray = explainResult.queryPlanner.winningPlan.shards;
const shardExecutionStagesArray = explainResult.executionStats.executionStages.shards;
assert.eq(shardPlanArray.length, shardExecutionStagesArray.length, explainResult);
let totalChunkSkips = 0;
for (let i = 0; i < shardPlanArray.length; i++) {
totalChunkSkips += getChunkSkipsFromShard(shardPlanArray[i], shardExecutionStagesArray[i]);
}
return totalChunkSkips;
}
/**
* Given explain output at executionStats level verbosity, for a count query, confirms that the root
* stage is COUNT or RECORD_STORE_FAST_COUNT and that the result of the count is equal to
* 'expectedCount'.
*/
export function assertExplainCount({explainResults, expectedCount}) {
const execStages = explainResults.executionStats.executionStages;
// If passed through mongos, then the root stage should be the mongos SINGLE_SHARD stage or
// SHARD_MERGE stages, with COUNT as the root stage on each shard. If explaining directly on the
// shard, then COUNT is the root stage.
if ("SINGLE_SHARD" == execStages.stage || "SHARD_MERGE" == execStages.stage) {
for (let shardExplain of execStages.shards) {
const countStage = shardExplain.executionStages;
assert(
countStage.stage === "COUNT" || countStage.stage === "RECORD_STORE_FAST_COUNT",
`Root stage on shard is not COUNT or RECORD_STORE_FAST_COUNT. ` +
`The actual plan is: ${tojson(explainResults)}`,
);
}
assert.eq(
execStages.nCounted,
expectedCount,
assert.eq(execStages.nCounted, expectedCount, "wrong count result"),
);
} else {
assert(
execStages.stage === "COUNT" || execStages.stage === "RECORD_STORE_FAST_COUNT",
`Root stage on shard is not COUNT or RECORD_STORE_FAST_COUNT. ` +
`The actual plan is: ${tojson(explainResults)}`,
);
assert.eq(
execStages.nCounted,
expectedCount,
"Wrong count result. Actual: " + execStages.nCounted + "expected: " + expectedCount,
);
}
}
/**
* Verifies that a given query uses an index and is covered when used in a count command.
*/
export function assertCoveredQueryAndCount({collection, query, project, count}) {
let explain = collection.find(query, project).explain();
assert(
isIndexOnly(db, getWinningPlanFromExplain(explain.queryPlanner)),
"Winning plan was not covered: " + tojson(explain.queryPlanner.winningPlan),
);
// Same query as a count command should also be covered.
explain = collection.explain("executionStats").find(query).count();
assert(
isIndexOnly(db, getWinningPlanFromExplain(explain.queryPlanner)),
"Winning plan for count was not covered: " + tojson(explain.queryPlanner.winningPlan),
);
assertExplainCount({explainResults: explain, expectedCount: count});
}
/**
* Runs explain() operation on 'cmdObj' and verifies that all the stages in 'expectedStages' are
* present exactly once in the plan returned. When 'stagesNotExpected' array is passed, also
* verifies that none of those stages are present in the explain() plan.
*/
export function assertStagesForExplainOfCommand({coll, cmdObj, expectedStages, stagesNotExpected}) {
const plan = assert.commandWorked(coll.runCommand({explain: cmdObj}));
const winningPlan = getWinningPlanFromExplain(plan.queryPlanner);
for (let expectedStage of expectedStages) {
assert(
planHasStage(coll.getDB(), winningPlan, expectedStage),
"Could not find stage " + expectedStage + ". Plan: " + tojson(plan),
);
}
for (let stage of stagesNotExpected || []) {
assert(
!planHasStage(coll.getDB(), winningPlan, stage),
"Found stage " + stage + " when not expected. Plan: " + tojson(plan),
);
}
return plan;
}
/**
* Get the 'planCacheKey' from 'explain'.
*/
export function getPlanCacheKeyFromExplain(explain) {
return getQueryPlanners(explain)
.map((qp) => {
assert(qp.hasOwnProperty("planCacheKey"));
return qp.planCacheKey;
})
.at(0);
}
/**
* Get the 'planCacheShapeHash' from 'object'.
*/
export function getPlanCacheShapeHashFromObject(object) {
// TODO SERVER-93305: Remove deprecated 'queryHash' usages.
const planCacheShapeHash = object.planCacheShapeHash || object.queryHash;
assert.neq(planCacheShapeHash, undefined);
return planCacheShapeHash;
}
/**
* Get the 'planCacheShapeHash' from 'explain'.
*/
export function getPlanCacheShapeHashFromExplain(explain) {
return getQueryPlanners(explain)
.map(getPlanCacheShapeHashFromObject)
.reduce((hash0, hash1) => {
assert.eq(hash0, hash1);
return hash0;
});
}
/**
* Helper to run a explain on the given query shape and get the "planCacheKey" from the explain
* result.
*/
export function getPlanCacheKeyFromShape({query = {}, projection = {}, sort = {}, collation = {}, collection, db}) {
const explainRes = assert.commandWorked(
collection.explain().find(query, projection).collation(collation).sort(sort).finish(),
);
return getPlanCacheKeyFromExplain(explainRes);
}
/**
* Helper to run a explain on the given pipeline and get the "planCacheKey" from the explain
* result.
*/
export function getPlanCacheKeyFromPipeline(pipeline, collection) {
const explainRes = assert.commandWorked(collection.explain().aggregate(pipeline));
return getPlanCacheKeyFromExplain(explainRes);
}
/**
* Given the winning query plan, flatten query plan tree into a list of plan stage names.
*/
export function flattenQueryPlanTree(winningPlan) {
let stages = [];
while (winningPlan) {
stages.push(winningPlan.stage);
winningPlan = winningPlan.inputStage;
}
stages.reverse();
return stages;
}
/**
* Assert that a command plan has no FETCH stage or if the stage is present, it has no filter.
*/
export function assertNoFetchFilter({coll, cmdObj}) {
const plan = assert.commandWorked(coll.runCommand({explain: cmdObj}));
const winningPlan = getWinningPlanFromExplain(plan.queryPlanner);
const fetch = getPlanStage(winningPlan, "FETCH");
assert(fetch === null || !fetch.hasOwnProperty("filter"), "Unexpected fetch: " + tojson(fetch));
return winningPlan;
}
/**
* Assert that a find plan has a FETCH stage with expected filter and returns a specified number of
* results.
*/
export function assertFetchFilter({coll, predicate, expectedFilter, nReturned}) {
const exp = coll.find(predicate).explain("executionStats");
const plan = getWinningPlanFromExplain(exp.queryPlanner);
const fetch = getPlanStage(plan, "FETCH");
assert(fetch !== null, "Missing FETCH stage " + plan);
assert(fetch.hasOwnProperty("filter"), "Expected filter in the fetch stage, got " + tojson(fetch));
assert.eq(
expectedFilter,
fetch.filter,
"Expected filter " + tojson(expectedFilter) + " got " + tojson(fetch.filter),
);
if (nReturned !== null) {
assert.eq(
exp.executionStats.nReturned,
nReturned,
"Expected " + nReturned + " documents, got " + exp.executionStats.nReturned,
);
}
}
/**
* Recursively checks if a javascript object contains a nested property key and returns the values.
*/
export function getNestedProperties(object, key) {
let accumulator = [];
function traverse(object) {
if (typeof object !== "object") {
return;
}
for (const k in object) {
if (k == key) {
accumulator.push(object[k]);
}
traverse(object[k]);
}
return;
}
traverse(object);
return accumulator;
}
/**
* Recognizes the query engine used by the query (sbe/classic).
*/
export function getEngine(explain) {
const sbePlans = getQueryPlanners(explain).flatMap((queryPlanner) =>
getNestedProperties(queryPlanner, "slotBasedPlan"),
);
return sbePlans.length == 0 ? "classic" : "sbe";
}
export function getWarnings(explain) {
return getQueryPlanners(explain).flatMap((queryPlanner) => getNestedProperties(queryPlanner, "warning"));
}
/**
* Asserts that a pipeline runs with the engine that is passed in as a parameter.
*/
export function assertEngine(pipeline, engine, coll) {
const explain = coll.explain().aggregate(pipeline);
assert.eq(getEngine(explain), engine);
}
/**
* Returns the number of index scans in a query plan.
*/
export function getNumberOfIndexScans(explain) {
const indexScans = getPlanStages(getWinningPlanFromExplain(explain.queryPlanner), "IXSCAN");
return indexScans.length;
}
/**
* Returns the number of column scans in a query plan.
*/
export function getNumberOfColumnScans(explain) {
const columnIndexScans = getPlanStages(getWinningPlanFromExplain(explain.queryPlanner), "COLUMN_SCAN");
return columnIndexScans.length;
}
/*
* Returns whether a query is using a multikey index.
*/
export function isIxscanMultikey(winningPlan) {
let ixscanStage = getPlanStage(winningPlan, "IXSCAN");
return ixscanStage && ixscanStage.isMultiKey;
}
/**
* Verify that the explain command output 'explain' shows a BATCHED_DELETE stage with an
* nWouldDelete value equal to 'nWouldDelete'.
*/
export function checkNWouldDelete(explain, nWouldDelete) {
assert.commandWorked(explain);
assert("executionStats" in explain);
let executionStats = explain.executionStats;
assert("executionStages" in executionStats);
// If passed through mongos, then BATCHED_DELETE stage(s) should be below the SHARD_WRITE
// mongos stage. Otherwise the BATCHED_DELETE stage is the root stage.
let execStages = executionStats.executionStages;
if ("SHARD_WRITE" === execStages.stage) {
let totalToBeDeletedAcrossAllShards = 0;
execStages.shards.forEach(function (shardExplain) {
const rootStageName = shardExplain.executionStages.stage;
assert(rootStageName === "BATCHED_DELETE", tojson(execStages));
totalToBeDeletedAcrossAllShards += shardExplain.executionStages.nWouldDelete;
});
assert.eq(totalToBeDeletedAcrossAllShards, nWouldDelete, explain);
} else {
assert(execStages.stage === "BATCHED_DELETE", explain);
assert.eq(execStages.nWouldDelete, nWouldDelete, explain);
}
}
/**
* Recursively remove fields which conditionally appear in plans that may contribute to spurious
* differences. Modifies the parameter in-place, no return value.
*/
export function canonicalizePlan(p) {
delete p.planNodeId;
delete p.isCached;
delete p.cardinalityEstimate;
delete p.costEstimate;
delete p.estimatesMetadata;
if (p.hasOwnProperty("inputStage")) {
canonicalizePlan(p.inputStage);
} else if (p.hasOwnProperty("inputStages")) {
p.inputStages.forEach((s) => {
canonicalizePlan(s);
});
}
}
/**
* Returns index of stage in a aggregation pipeline stage plan running on a single node
* (will not work for sharded clusters).
* 'root' is root of explain JSON.
* Returns -1 if stage does not exist.
*/
export function getIndexOfStageOnSingleNode(root, stageName) {
if (root.hasOwnProperty("stages")) {
for (let i = 0; i < root.stages.length; i++) {
if (root.stages[i].hasOwnProperty(stageName)) {
return i;
}
}
}
return -1;
}
/**
* Given the root of an explain, return an array of all enumerated plans.
*/
export function getAllPlans(explain) {
return [getWinningPlanFromExplain(explain), ...getRejectedPlans(explain)];
}
/**
* Given the root of an explain, checks whether it has a $mergeCursors stage.
*/
export function hasMergeCursors(explain) {
if (explain.hasOwnProperty("splitPipeline")) {
if (explain.splitPipeline && explain.splitPipeline.hasOwnProperty("mergerPart")) {
if (
explain.splitPipeline.mergerPart[0] &&
explain.splitPipeline.mergerPart[0].hasOwnProperty("$mergeCursors")
) {
return true;
}
}
}
return false;
}