From ac174efbafe94dc68c77e3d57ad719ffb149b3d7 Mon Sep 17 00:00:00 2001 From: Max Verbinnen <64088654+Max-Verbinnen@users.noreply.github.com> Date: Wed, 27 Aug 2025 11:56:45 +0100 Subject: [PATCH] SERVER-106643 Expose cursorType in explain for $cursor (#40592) GitOrigin-RevId: 2da874fc9ce72c97227e40088bc10bd160ec9b65 --- jstests/aggregation/explain/explain_cursor.js | 64 +++++++++++++++++++ .../sources/unionWith/unionWith_explain.js | 6 +- .../db/pipeline/document_source_cursor.cpp | 4 +- .../db/pipeline/document_source_cursor.h | 10 +++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 jstests/aggregation/explain/explain_cursor.js diff --git a/jstests/aggregation/explain/explain_cursor.js b/jstests/aggregation/explain/explain_cursor.js new file mode 100644 index 00000000000..1c6ca8143b8 --- /dev/null +++ b/jstests/aggregation/explain/explain_cursor.js @@ -0,0 +1,64 @@ +// Tests the behavior of explain() when used with aggregation pipeline to +// verify cursorType field. This test verifies that the cursorType field +// is correctly exposed in the $cursor.queryPlanner stage explain output. +// +// @tags: [ +// assumes_unsharded_collection, +// do_not_wrap_aggregations_in_facets, +// ] +import {checkSbeRestrictedOrFullyEnabled} from "jstests/libs/query/sbe_util.js"; +import {getAggPlanStage} from "jstests/libs/query/analyze_plan.js"; + +if (checkSbeRestrictedOrFullyEnabled(db)) { + jsTest.log.info("Skipping test because $count queries don't use the emptyDocuments cursor in SBE"); + quit(); +} + +const coll = db.explain_cursor; +coll.drop(); + +const kNumDocs = 10; + +for (let i = 0; i < kNumDocs; i++) { + assert.commandWorked(coll.insert({_id: i, a: i, b: i % 2})); +} + +function getCursorType(explainOutput) { + const cursorStage = getAggPlanStage(explainOutput, "$cursor"); + assert.neq(null, cursorStage, "No $cursor stage present"); + assert(cursorStage.$cursor.hasOwnProperty("queryPlanner"), "No $cursor.queryPlanner present"); + assert(cursorStage.$cursor.queryPlanner.hasOwnProperty("cursorType"), "No $cursor.queryPlanner.cursorType present"); + return cursorStage.$cursor.queryPlanner.cursorType; +} + +// Normal aggregation queries should use the regular cursor. +const groupExplain = coll.explain().aggregate([{$match: {b: 1}}, {$group: {_id: "$a", count: {$sum: 1}}}]); +assert.eq("regular", getCursorType(groupExplain)); + +const multiStageExplain = coll + .explain() + .aggregate([ + {$match: {a: {$lt: 8}}}, + {$project: {a: 1, category: {$cond: [{$gt: ["$a", 5]}, "high", "low"]}}}, + {$sort: {category: 1, a: -1}}, + ]); +assert.eq("regular", getCursorType(multiStageExplain)); + +// Count-like queries should use the emptyDocuments cursor. +const countExplain = coll.explain().aggregate([{$match: {a: {$gte: 5}}}, {$count: "filtered"}]); +assert.eq("emptyDocuments", getCursorType(countExplain)); + +const multiStageCountExplain = coll.explain().aggregate([ + { + "$addFields": { + "c": NumberInt(0), + }, + }, + { + "$project": { + "_id": 0, + "c": 1, + }, + }, +]); +assert.eq("emptyDocuments", getCursorType(multiStageCountExplain)); diff --git a/jstests/aggregation/sources/unionWith/unionWith_explain.js b/jstests/aggregation/sources/unionWith/unionWith_explain.js index ed34cb191b6..7962958afcf 100644 --- a/jstests/aggregation/sources/unionWith/unionWith_explain.js +++ b/jstests/aggregation/sources/unionWith/unionWith_explain.js @@ -43,7 +43,11 @@ const mongosIgnoredFields = ["works", "needTime", "queryHash", "planCacheShapeHa stagesIgnoredFields, ); -const queryPlannerIgnoredFields = ["optimizedPipeline", "optimizationTimeMillis"].concat(stagesIgnoredFields); +// We ignore `cursorType` because it's only set when there's a $cursor stage, which could be +// the case for the union but not for the regular query or vice versa. +const queryPlannerIgnoredFields = ["optimizedPipeline", "optimizationTimeMillis", "cursorType"].concat( + stagesIgnoredFields, +); function buildErrorString(unionExplain, realExplain, field) { return ( diff --git a/src/mongo/db/pipeline/document_source_cursor.cpp b/src/mongo/db/pipeline/document_source_cursor.cpp index aabd3508048..0b38cb0d7de 100644 --- a/src/mongo/db/pipeline/document_source_cursor.cpp +++ b/src/mongo/db/pipeline/document_source_cursor.cpp @@ -79,13 +79,15 @@ Value DocumentSourceCursor::serialize(const SerializationOptions& opts) const { BSONObjBuilder explainStatsBuilder; tassert( 10769400, "Expected the plannerContext to be set for explain", _plannerContext.has_value()); + + BSONObj extraInfo = BSON("cursorType" << toString(_cursorType)); Explain::explainStages( _sharedState->exec.get(), *_plannerContext, opts.verbosity.value(), _sharedState->execStatus, _winningPlanTrialStats, - BSONObj(), + extraInfo, SerializationContext::stateCommandReply(getExpCtx()->getSerializationContext()), BSONObj(), &explainStatsBuilder); diff --git a/src/mongo/db/pipeline/document_source_cursor.h b/src/mongo/db/pipeline/document_source_cursor.h index 549032c005a..be2265709ba 100644 --- a/src/mongo/db/pipeline/document_source_cursor.h +++ b/src/mongo/db/pipeline/document_source_cursor.h @@ -203,6 +203,16 @@ private: friend boost::intrusive_ptr documentSourceGeoNearCursorToStageFn( const boost::intrusive_ptr&); + static constexpr StringData toString(CursorType type) { + switch (type) { + case CursorType::kRegular: + return "regular"_sd; + case CursorType::kEmptyDocuments: + return "emptyDocuments"_sd; + } + MONGO_UNREACHABLE; + } + // Handle to catalog state. boost::intrusive_ptr _catalogResourceHandle;