diff --git a/src/documentdb/queryInsights/ExplainPlanAnalyzer.addIndexStrategyAdvisories.test.ts b/src/documentdb/queryInsights/ExplainPlanAnalyzer.addIndexStrategyAdvisories.test.ts index 1750b8e1b..2c36b0ab3 100644 --- a/src/documentdb/queryInsights/ExplainPlanAnalyzer.addIndexStrategyAdvisories.test.ts +++ b/src/documentdb/queryInsights/ExplainPlanAnalyzer.addIndexStrategyAdvisories.test.ts @@ -288,6 +288,121 @@ describe('ExplainPlanAnalyzer.addIndexStrategyAdvisories', () => { }); }); + it('detects isBitmap under a shard branch in the winning plan', () => { + // Simulates a sharded cluster where the IXSCAN with isBitmap lives + // under a SHARD_MERGE > shards > FETCH > IXSCAN path. + // Before the fix, findStageInPlan() did not recurse into shards. + const analysis = makeAnalysis(); + const explainResult: Document = { + queryPlanner: { + winningPlan: { + stage: 'SHARD_MERGE', + shards: [ + { + stage: 'SINGLE_SHARD', + inputStage: { + stage: 'FETCH', + inputStage: { + stage: 'IXSCAN', + indexName: 'bitmapIndex_1', + isBitmap: true, + }, + }, + }, + ], + }, + }, + executionStats: { + nReturned: 100, + executionTimeMillis: 13, + totalDocsExamined: 100, + totalKeysExamined: 100, + executionStages: { + stage: 'SHARD_MERGE', + shards: [ + { + stage: 'SINGLE_SHARD', + inputStage: { + stage: 'FETCH', + inputStage: { + stage: 'IXSCAN', + indexName: 'bitmapIndex_1', + indexUsage: [ + { + scanKeys: [ + 'key 1: [(isInequality: false, estimatedEntryCount: 100)]', + ], + }, + ], + }, + }, + }, + ], + }, + }, + }; + + ExplainPlanAnalyzer.addIndexStrategyAdvisories(analysis, 1000, explainResult); + + const ids = getDiagnosticIds(analysis.performanceRating.diagnostics); + expect(ids).toContain('bitmap_index'); + }); + + it('detects IXSCAN in planner shards even when exec stats use flat structure', () => { + // Verifies that bitmap index detection works when the queryPlanner + // includes shard info (SHARD_MERGE > SINGLE_SHARD > IXSCAN) but + // executionStats use a simplified flat structure (FETCH > IXSCAN) + // without shard wrappers. This is important because production + // sharded clusters may return plan trees in either format, and the + // bitmap detector must be able to traverse both shapes correctly. + const analysis = makeAnalysis(); + const explainResult: Document = { + queryPlanner: { + winningPlan: { + stage: 'SHARD_MERGE', + shards: [ + { + stage: 'SINGLE_SHARD', + inputStage: { + stage: 'FETCH', + inputStage: { + stage: 'IXSCAN', + indexName: 'bitmapIndex_1', + isBitmap: true, + }, + }, + }, + ], + }, + }, + executionStats: { + nReturned: 100, + executionTimeMillis: 13, + totalDocsExamined: 100, + totalKeysExamined: 100, + executionStages: { + stage: 'FETCH', + inputStage: { + stage: 'IXSCAN', + indexName: 'bitmapIndex_1', + indexUsage: [ + { + scanKeys: [ + 'key 1: [(isInequality: false, estimatedEntryCount: 100)]', + ], + }, + ], + }, + }, + }, + }; + + ExplainPlanAnalyzer.addIndexStrategyAdvisories(analysis, 1000, explainResult); + + const ids = getDiagnosticIds(analysis.performanceRating.diagnostics); + expect(ids).toContain('bitmap_index'); + }); + describe('cumulative advisory demotions', () => { it('demotes score twice when both bitmap and severe multikey thresholds are met', () => { // Single-field bitmap with 50% coverage + 25× multikey expansion diff --git a/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts b/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts index d07015188..411454eb6 100644 --- a/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts +++ b/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts @@ -770,6 +770,19 @@ export class ExplainPlanAnalyzer { } } + // Traverse shard branches (sharded clusters) + if (plan.shards) { + const shardEntries = Array.isArray(plan.shards) ? plan.shards : Object.values(plan.shards); + for (const shardEntry of shardEntries) { + const shard = shardEntry as Document; + const planRoot = shard.winningPlan || shard.executionStages || shard.inputStage || shard; + const found = this.findStageInPlan(planRoot, stageName); + if (found) { + return found; + } + } + } + return undefined; } diff --git a/src/documentdb/queryInsights/StagePropertyExtractor.ts b/src/documentdb/queryInsights/StagePropertyExtractor.ts index d77509a57..86bdf3721 100644 --- a/src/documentdb/queryInsights/StagePropertyExtractor.ts +++ b/src/documentdb/queryInsights/StagePropertyExtractor.ts @@ -58,6 +58,7 @@ export class StagePropertyExtractor { /** * Collects all stages from the queryPlanner winning plan tree into a flat array. + * Traverses single inputStage, multiple inputStages, and shard branches (sharded clusters). */ private static collectPlannerStages(stage: Document, accumulator: Document[]): void { if (!stage || !stage.stage) { @@ -74,6 +75,14 @@ export class StagePropertyExtractor { this.collectPlannerStages(child, accumulator); }); } + if (stage.shards) { + const shardEntries = Array.isArray(stage.shards) ? stage.shards : Object.values(stage.shards); + for (const shardEntry of shardEntries) { + const shard = shardEntry as Document; + const planRoot = shard.winningPlan || shard; + this.collectPlannerStages(planRoot, accumulator); + } + } } /** @@ -105,9 +114,12 @@ export class StagePropertyExtractor { }); } if (stage.shards) { - (stage.shards as Document[]).forEach((shard: Document) => { - this.traverseStages(shard, accumulator); - }); + const shardEntries = Array.isArray(stage.shards) ? stage.shards : Object.values(stage.shards); + for (const shardEntry of shardEntries) { + const shard = shardEntry as Document; + const planRoot = shard.executionStages || shard.inputStage || shard; + this.traverseStages(planRoot, accumulator); + } } }