mongo/jstests/libs/analyze_plan.js

1411 lines
51 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 {usedBonsaiOptimizer} from "jstests/libs/optimizer_utils.js";
/**
* Returns query planner part of explain for every node in the explain report.
*/
export function getQueryPlanners(explain) {
return getAllNodeExplains(explain).flatMap(nodeExplain => {
const queryPlanners = getNestedProperties(nodeExplain, "queryPlanner");
return queryPlanners.length == 0 ? [nodeExplain] : queryPlanners;
});
}
/**
* Utility to return the 'queryPlanner' section of 'explain'. The input is the root of the explain
* output.
*
* This helper function can be used for any optimizer.
*/
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];
assert(stage.hasOwnProperty("$cursor"), explain);
const cursorStage = stage.$cursor;
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.
*
* This helper function can be used for any optimizer.
*/
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 a sub-element of the 'queryPlanner' explain output which represents a winning plan.
* 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, queryPlanner.winningPlan.shards[shardNames[0]].
*
* This helper function can be used for any optimizer.
*/
export function getWinningPlan(queryPlanner) {
// 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.
return queryPlanner.winningPlan.hasOwnProperty("queryPlan") ? queryPlanner.winningPlan.queryPlan
: queryPlanner.winningPlan;
}
export function getWinningSBEPlan(queryPlanner) {
assert(queryPlanner.winningPlan.hasOwnProperty("slotBasedPlan"), queryPlanner);
return queryPlanner.winningPlan.slotBasedPlan;
}
/**
* 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.
*
* This helper function can be used for any optimizer.
*/
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 the winning SBE plan from the corresponding sub-node of classic/SBE explain output. Takes
* into account that the plan may or may not have agg stages.
*
* This helper function can be used for any optimizer.
*/
export function getWinningSBEPlanFromExplain(explain) {
if ("shards" in explain) {
for (const shardName in explain.shards) {
let queryPlanner = getQueryPlanner(explain.shards[shardName]);
return getWinningSBEPlan(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 getWinningSBEPlanFromExplain(pipeline[0].$cursor);
} else {
return getWinningSBEPlanFromExplain(pipeline);
}
}
let queryPlanner = getQueryPlanner(explain);
return getWinningSBEPlan(queryPlanner);
}
/**
* Returns an element of explain output which represents a rejected candidate plan.
*
* This helper function can be used for any optimizer. However, currently for the CQF optimizer,
* rejected plans are not included in the explain output
*/
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.
*
* This helper function can be used only with "classic" optimizer. TODO SERVER-83768: extend the
* functionality of this helper for CQF plans.
*/
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;
}
/**
* Flattens the given plan by turning it into an array of stages/children. It excludes fields which
* might differ in the explain across multiple executions of the same query.
*/
export function flattenPlan(plan) {
const results = [];
if (!isPlainObject(plan)) {
return results;
}
const childFields = [
"inputStage",
"inputStages",
"thenStage",
"elseStage",
"outerStage",
"stages",
"innerStage",
"child",
"leftChild",
"rightChild"
];
// Expand this array if you find new fields which are inconsistent across different test runs.
const ignoreFields = ["isCached", "indexVersion", "filter", "planNodeId"];
// Iterates over the plan while ignoring the `ignoreFields`, to create flattened stages whenever
// `childFields` are encountered.
const stack = [["root", {...plan}]];
while (stack.length > 0) {
const [_, next] = stack.pop();
ignoreFields.forEach(field => delete next[field]);
for (const childField of childFields) {
if (childField in next) {
const child = next[childField];
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]);
}
}
}
results.push(next);
}
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) {
return {
winningPlan: flattenPlan(getWinningPlan(queryPlanner)),
rejectedPlans: queryPlanner.rejectedPlans.map(flattenPlan),
};
}
/**
* 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) {
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)});
} 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) {
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: flattenPlan(topStage), shardsPart: {}};
shards.forEach(shard => res.shardsPart[shard.shardName] = formatQueryPlanner(shard));
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) {
let res = {};
if (!isPlainObject(explain)) {
return res;
}
addIfPresent("mergeType", explain, res);
if ("splitPipeline" in explain) {
addIfPresent("mergerPart", explain.splitPipeline, res, formatPipeline);
addIfPresent("shardsPart", explain.splitPipeline, res, formatPipeline);
}
if ("shards" in explain) {
for (const [shardName, shardExplain] of Object.entries(explain["shards"])) {
res[shardName] = formatPipeline(shardExplain.stages);
}
} else if ("queryPlanner" in explain && "shards" in explain.queryPlanner.winningPlan) {
res = {...res, ...invertShards(explain.queryPlanner)};
} else if ("queryPlanner" in explain) {
res = {...res, ...formatQueryPlanner(explain.queryPlanner)};
} else if ("stages" in explain) {
res.stages = formatPipeline(explain.stages);
}
return res;
}
/**
* Given the root stage of explain's JSON representation of a query plan ('root'), returns all
* subdocuments whose stage is 'stage'. Returns an empty array if the plan does not have the
* requested stage. if 'stage' is 'null' returns all the stages in 'root'.
*
* This helper function can be used for any optimizer.
*/
export function getPlanStages(root, stage) {
var 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 (var i = 0; i < root.inputStages.length; i++) {
results = results.concat(getPlanStages(root.inputStages[i], stage));
}
}
if ("queryPlanner" in root) {
results = results.concat(getPlanStages(getWinningPlan(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") ? getWinningPlan(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);
}
}
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'.
*
* This helper function can be used for any optimizer.
*/
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.
*
* This helper function can be used for any optimizer.
*/
export function getPlanStage(root, stage) {
assert(stage, "Stage was not defined in getPlanStage.");
var 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.
*
* This helper function can be used for any optimizer.
*/
export function getRejectedPlans(root) {
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;
}
/**
* 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.
*
* This helper function can be used for any optimizer. Currently for CQF optimizer, this function
* returns always true (TODO SERVER-77719: address this behavior).
*/
export function hasRejectedPlans(root) {
function sectionHasRejectedPlans(explainSection, optimizer = "classic") {
if (optimizer == "CQF") {
// TODO SERVER-77719: The existence of alternative/rejected plans will be re-evaluated
// in the future.
return true;
}
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 {
let optimizer = getOptimizer(root);
// 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")) {
// SERVER-77719: Update regarding the expected behavior of the CQF optimizer. Currently
// CQF explains are empty, when the optimizer returns alternative plans, we should
// address this.
// This is an unsharded explain.
return sectionHasRejectedPlans(root.queryPlanner, optimizer);
}
if ("SINGLE_SHARD" == root.queryPlanner.winningPlan.stage) {
var shards = root.queryPlanner.winningPlan.shards;
shards.forEach(function assertShardHasRejectedPlans(shard) {
sectionHasRejectedPlans(shard, optimizer);
});
}
// 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, optimizer)) !== undefined;
}
}
/**
* Returns an array of execution stages from the given replset or sharded explain output.
*
* This helper function can be used for any optimizer.
*/
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.
*
* This helper function can be used for any optimizer.
*/
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];
}
/**
* Returns the winningPlan.queryPlan of each shard in the explain in a list.
*
* This helper function can be used for any optimizer.
*/
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;
}
/**
* Performs the given fn on each shard's explain output in root or the top-level explain, if root
* comes from a standalone explain. fn should accept a single node's top-level explain as input.
*
* This helper function currently only works for CQF queries. It can be extended to work for
* aggregation-like explains.
*/
export function runOnAllTopLevelExplains(root, fn) {
if (root.hasOwnProperty("shards")) {
// Sharded agg explain, where the aggregations get pushed down to find on the shards.
for (let shardName of Object.keys(root.shards)) {
let shard = root.shards[shardName];
fn(shard);
}
} else if (root.queryPlanner.winningPlan.hasOwnProperty("shards")) {
// Sharded find explain.
for (let shard of root.queryPlanner.winningPlan.shards) {
fn(shard);
}
} else {
// Standalone find explain.
fn(root);
}
}
/**
* 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. It can be used with any optimizer. It returns
* an empty list for non-CQF plans, since only CQF will attach planSummary to explain output.
*/
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.
*
* This helper function can be used for any optimizer.
*/
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(getWinningPlan(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.
*
* This helper function can be used for any optimizer.
*/
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 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."
*
* This helper function can be used for any optimizer.
*/
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.
*
* This helper function can be used for any optimizer.
*/
export function isIndexOnly(db, root) {
// SERVER-77719: Ensure that the decision for using the scan lines up with CQF optimizer.
return !planHasStage(db, root, "FETCH") && !planHasStage(db, root, "COLLSCAN") &&
!planHasStage(db, root, "PhysicalScan") && !planHasStage(db, root, "CoScan") &&
!planHasStage(db, root, "Seek");
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using
* an index scan, and false otherwise.
*
* This helper function can be used for any optimizer.
*/
export function isIxscan(db, root) {
// SERVER-77719: Ensure that the decision for using the scan lines up with CQF optimizer.
return planHasStage(db, root, "IXSCAN") || planHasStage(db, root, "IndexScan");
}
/**
* Returns true if the plan is formed of a single EOF stage. False otherwise.
*
* This helper function can be used for any optimizer.
*/
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);
}
/**
* 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 'EXPRESS' stages, or as 'CLUSTERED_IXSCAN' stages with equal min & max
* record bounds in the case of clustered collections.
*
* This helper function can be used only with classic optimizer (TODO SERVER-77719: address this
* behavior).
*/
export function isIdhackOrExpress(db, root) {
// SERVER-77719: Ensure that the decision for using the scan lines up with CQF optimizer.
if (planHasStage(db, root, "IDHACK") || isExpress(db, root)) {
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 {
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.
*
* This helper function can be used only with classic optimizer (TODO SERVER-77719: address this
* behavior).
*/
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 indicates that this plan was generated by the
* fastpath logic of the Bonsai optimiser.
*/
export function isBonsaiFastPathPlan(db, explain) {
return planHasStage(db, explain, "FASTPATH");
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using
* a collection scan, and false otherwise.
*
* This helper function can be used for any optimizer. This assumes that the PhysicalScan operator
* of CQF is equivalent to COLLSCAN.
*/
export function isCollscan(db, root) {
return planHasStage(db, root, "COLLSCAN") || planHasStage(db, root, "PhysicalScan");
}
/**
* Returns true if the BSON representation of a plan rooted at 'root' is using
* a clustered Ix scan, and false otherwise.
*
* This helper function can be used only for the "classic" optimizer. Note that it can be applied to
* CQF plans, but it will always return false because there is not yet a clustered IXSCAN
* representation in Bonsai.
*/
export function isClusteredIxscan(db, root) {
// SERVER-77719: Ensure that the decision for using the scan lines up with CQF optimizer.
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.
*
* This helper function can be used for any optimizer.
*/
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.
*
* This helper function can be used for any optimizer.
*/
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(getWinningPlan).every(predicate);
}
/**
* Get the "chunk skips" for a single shard. Here, "chunk skips" refer to documents excluded by the
* shard filter.
*
* This helper function can be used only with the "classic" optimizer. TODO SERVER-77719: extend the
* functionality of this helper for CQF operators
*/
export function getChunkSkipsFromShard(shardPlan, shardExecutionStages) {
const shardFilterPlanStage = getPlanStage(getWinningPlan(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.
*
* This helper function can be used only with the "classic" optimizer. TODO SERVER-77719: extend the
* functionality of this helper for CQF operators
*/
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'.
*
* This helper function can be used for any optimizer.
*/
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) {
let totalCounted = 0;
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)}`);
totalCounted += countStage.nCounted;
}
assert.eq(totalCounted,
expectedCount,
assert.eq(totalCounted, 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.
*
* This helper function can be used for any optimizer.
*/
export function assertCoveredQueryAndCount({collection, query, project, count}) {
let explain = collection.find(query, project).explain();
// SERVER-77719: Update regarding the expected behavior of the CQF optimizer.
switch (getOptimizer(explain)) {
case "classic":
assert(isIndexOnly(db, getWinningPlan(explain.queryPlanner)),
"Winning plan was not covered: " + tojson(explain.queryPlanner.winningPlan));
break;
default:
break;
}
// Same query as a count command should also be covered.
explain = collection.explain("executionStats").find(query).count();
// SERVER-77719: Update regarding the expected behavior of the CQF optimizer.
switch (getOptimizer(explain)) {
case "classic":
assert(isIndexOnly(db, getWinningPlan(explain.queryPlanner)),
"Winning plan for count was not covered: " +
tojson(explain.queryPlanner.winningPlan));
assertExplainCount({explainResults: explain, expectedCount: count});
break;
default:
break;
}
}
/**
* 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.
*
* This helper function can be used for any optimizer.
*/
export function assertStagesForExplainOfCommand({coll, cmdObj, expectedStages, stagesNotExpected}) {
const plan = assert.commandWorked(coll.runCommand({explain: cmdObj}));
const winningPlan = getWinningPlan(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 'queryHash' from 'object'.
*/
export function getPlanCacheShapeHashFromObject(object) {
const queryHash = object.queryHash;
assert.neq(queryHash, undefined);
return queryHash;
}
/**
* 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.
*
* This helper function can be used for any optimizer.
*/
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.
*
* This helper function can be used for any optimizer.
*/
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.
*
* This helper function can be used only with the "classic" optimizer. TODO SERVER-77719: extend the
* functionality of this helper for CQF operators
*/
export function assertNoFetchFilter({coll, cmdObj}) {
const plan = assert.commandWorked(coll.runCommand({explain: cmdObj}));
const winningPlan = getWinningPlan(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.
*
* This helper function can be used only with the "classic" optimizer.
*/
export function assertFetchFilter({coll, predicate, expectedFilter, nReturned}) {
const exp = coll.find(predicate).explain("executionStats");
const plan = getWinningPlan(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).
*
* This helper function can be used for any optimizer.
*/
export function getEngine(explain) {
const sbePlans = getQueryPlanners(explain).flatMap(
queryPlanner => getNestedProperties(queryPlanner, "slotBasedPlan"));
return sbePlans.length == 0 ? "classic" : "sbe";
}
/**
* Asserts that a pipeline runs with the engine that is passed in as a parameter.
*
* This helper function can be used for any optimizer.
*/
export function assertEngine(pipeline, engine, coll) {
const explain = coll.explain().aggregate(pipeline);
assert.eq(getEngine(explain), engine);
}
/**
* Returns the optimizer (name string) used to generate the explain output ("classic" or "CQF")
*
* This helper function can be used for any optimizer.
*/
export function getOptimizer(explain) {
if (usedBonsaiOptimizer(explain)) {
return "CQF";
} else {
return "classic";
}
}
/**
* Returns the number of index scans in a query plan.
*
* This helper function can be used for any optimizer.
*/
export function getNumberOfIndexScans(explain) {
let stages = {"classic": "IXSCAN", "CQF": "IndexScan"};
let optimizer = getOptimizer(explain);
const indexScans = getPlanStages(getWinningPlan(explain.queryPlanner), stages[optimizer]);
return indexScans.length;
}
/**
* Returns the number of column scans in a query plan.
*
* This helper function can be used for any optimizer.
*/
export function getNumberOfColumnScans(explain) {
// SERVER-77719: Update regarding the expected behavior of the CQF optimizer (what is the
// stage name for CQF for a column scan).
let stages = {"classic": "COLUMN_SCAN"};
let optimizer = getOptimizer(explain);
if (optimizer == "CQF") {
return 0;
}
const columnIndexScans = getPlanStages(getWinningPlan(explain.queryPlanner), stages[optimizer]);
return columnIndexScans.length;
}
/**
* Returns whether a query is using a multikey index.
*
* This helper function can be used only for "classic" optimizer.
*/
export function isIxscanMultikey(winningPlan) {
// SERVER-77719: Update to expected this method to allow also use with CQF optimizer.
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);
var 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.
var 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);
}
}
/**
* Returns whether an explain output has the optimizerPhases field.
*
* This helper function is relevant only for the CQF optimizer.
*/
export function explainHasOptimizerPhases(explain) {
let queryPlanner = getQueryPlanner(explain);
return queryPlanner.hasOwnProperty("optimizerPhases");
}
/**
* Returns the OptimizerPhases element.
*
* This helper function is relevant only for the CQF optimizer.
*/
export function getExplainOptimizerPhases(explain) {
let queryPlanner = getQueryPlanner(explain);
assert(explainHasOptimizerPhases(explain),
"Explain output does not have optimizer phases: " + tojson(explain));
return queryPlanner.optimizerPhases;
}