import {resultsEq} from "jstests/aggregation/extras/utils.js"; import {FixtureHelpers} from "jstests/libs/fixture_helpers.js"; import {isLinux} from "jstests/libs/os_helpers.js"; import {ReplSetTest} from "jstests/libs/replsettest.js"; import {ShardingTest} from "jstests/libs/shardingtest.js"; export const kShellApplicationName = "MongoDB Shell"; export const kDefaultQueryStatsHmacKey = BinData(8, "MjM0NTY3ODkxMDExMTIxMzE0MTUxNjE3MTgxOTIwMjE="); export const queryShapeUpdateFieldsRequired = ["cmdNs", "command", "u", "q", "multi", "upsert"]; // The outer fields not nested inside queryShape. export const updateKeyFieldsRequired = [ "queryShape", "collectionType", "client", "ordered", "bypassDocumentValidation", ]; export const updateKeyFieldsComplex = [ ...updateKeyFieldsRequired, "comment", "readConcern", "apiDeprecationErrors", "apiVersion", "apiStrict", "maxTimeMS", "$readPreference", "hint", ]; /** * Utility for checking that the aggregated queryStats metrics are logical (follows sum >= max >= * min, and sum = max = min if only one execution). */ export function verifyMetrics(batch) { batch.forEach((element) => { // The cpuNanos metric should only appear for tests running on Linux. assert( isLinux() == "cpuNanos" in element.metrics, `cpuNanos must be returned on only Linux systems, but received: ${tojson(element)}`, ); function skipMetric(summaryValues) { // Skip over fields that aren't aggregated metrics with sum/min/max (execCount, // lastExecutionMicros). We also skip metrics that are aggregated, but that haven't // yet been updated (i.e have a sum of 0). This can happen on occasion when new metrics // are not yet in use, or are guarded by a feature flag. Instead, it may be better to // keep a list of the metrics we expect to see here in the long term for a more // consistent check. return ( summaryValues.sum === undefined || (typeof summaryValues.sum === "object" && summaryValues.sum.valueOf() === 0) ); } if (element.metrics.execCount === 1) { for (const [metricName, summaryValues] of Object.entries(element.metrics)) { if (skipMetric(summaryValues)) { continue; } const debugInfo = {[metricName]: summaryValues}; // If there has only been one execution, all metrics should have min, max, and sum // equal to each other. assert.eq(summaryValues.sum, summaryValues.min, debugInfo); assert.eq(summaryValues.sum, summaryValues.max, debugInfo); assert.eq(summaryValues.min, summaryValues.max, debugInfo); } } else { for (const [metricName, summaryValues] of Object.entries(element.metrics)) { if (skipMetric(summaryValues)) { continue; } const debugInfo = {[metricName]: summaryValues}; assert.gte(summaryValues.sum, summaryValues.min, debugInfo); assert.gte(summaryValues.sum, summaryValues.max, debugInfo); assert.lte(summaryValues.min, summaryValues.max, debugInfo); } } }); } /** * Return the latest query stats entry from the given collection. Only returns query shapes * generated by the shell that is running tests. * * @param conn - connection to database * @param {object} options { * {String} collName - name of collection * {object} - extraMatch - optional argument that can be used to filter the pipeline * } */ export function getLatestQueryStatsEntry( conn, options = { collName: "", }, ) { let sortedEntries = getQueryStats(conn, Object.merge({customSort: {"metrics.latestSeenTimestamp": -1}}, options)); assert.neq([], sortedEntries); return sortedEntries[0]; } /** * Collect query stats from a given collection. Only include query shapes generated by the shell * that is running tests. * * @param conn - connection to database * @param {object} options { * {String} collName - name of collection * {object} - extraMatch - optional argument that can be used to filter the pipeline * {object} - customSort - optional custom sort order - otherwise sorted by 'key' just to be * deterministic. * } */ export function getQueryStats( conn, options = { collName: "", }, ) { let match = {"key.client.application.name": kShellApplicationName, ...options.extraMatch}; if (options.collName) { match["key.queryShape.cmdNs.coll"] = options.collName; } const result = conn.adminCommand({ aggregate: 1, pipeline: [{$queryStats: {}}, {$sort: options.customSort || {key: 1}}, {$match: match}], cursor: {}, }); assert.commandWorked(result); return result.cursor.firstBatch; } /** * @param {object} conn - connection to database * @param {object} options { * {BinData} hmacKey * {String} collName - name of collection * {boolean} transformIdentifiers - whether to include transform identifiers * } */ export function getQueryStatsFindCmd( conn, options = { collName: "", transformIdentifiers: false, hmacKey: kDefaultQueryStatsHmacKey, }, ) { let matchExpr = { "key.queryShape.command": "find", "key.client.application.name": kShellApplicationName, }; return getQueryStatsWithTransform(conn, matchExpr, options); } /** * @param {object} conn - connection to database * @param {object} options { * {BinData} hmacKey * {String} collName - name of collection * {boolean} transformIdentifiers - whether to include transform identifiers * } */ export function getQueryStatsDistinctCmd( conn, options = { collName: "", transformIdentifiers: false, hmacKey: kDefaultQueryStatsHmacKey, }, ) { let matchExpr = { "key.queryShape.command": "distinct", "key.client.application.name": kShellApplicationName, }; return getQueryStatsWithTransform(conn, matchExpr, options); } /** * @param {object} conn - connection to database * @param {object} options { * {BinData} hmacKey * {String} collName - name of collection * {boolean} transformIdentifiers - whether to include transform identifiers * } */ export function getQueryStatsUpdateCmd( conn, options = { collName: "", transformIdentifiers: false, hmacKey: kDefaultQueryStatsHmacKey, }, ) { let matchExpr = { "key.queryShape.command": "update", "key.client.application.name": kShellApplicationName, }; return getQueryStatsWithTransform(conn, matchExpr, options); } /** * Collect query stats for a count command. Only include query shapes generated by the shell * that is running tests. * * @param {object} conn - connection to database * @param {object} options { * {BinData} hmacKey - key appended to message before hashing to construct MAC * {String} collName - name of collection * {boolean} transformIdentifiers - whether to transform identifiers in reported query stats * } */ export function getQueryStatsCountCmd( conn, options = { collName: "", transformIdentifiers: false, hmacKey: kDefaultQueryStatsHmacKey, }, ) { let matchExpr = { "key.queryShape.command": "count", "key.client.application.name": kShellApplicationName, }; return getQueryStatsWithTransform(conn, matchExpr, options); } /** * @param {object} conn - connection to database * @param {object} matchExpr - match expression for either find or distinct * @param {object} options { * {BinData} hmacKey * {String} collName - name of collection * {boolean} transformIdentifiers - whether to include transform identifiers * } */ export function getQueryStatsWithTransform( conn, matchExpr, options = { collName: "", transformIdentifiers: false, hmacKey: kDefaultQueryStatsHmacKey, }, ) { if (options.collName) { matchExpr["key.queryShape.cmdNs.coll"] = options.collName; } // Filter out agg queries, including $queryStats. let pipeline; if (options.transformIdentifiers) { pipeline = [ { $queryStats: { transformIdentifiers: { algorithm: "hmac-sha-256", hmacKey: options.hmacKey ? options.hmacKey : kDefaultQueryStatsHmacKey, }, }, }, {$match: matchExpr}, // Sort on queryStats key, or custom sort, so entries are in a deterministic order. {$sort: options.customSort || {key: 1}}, ]; } else { pipeline = [ {$queryStats: {}}, {$match: matchExpr}, // Sort on queryStats key, or custom sort, so entries are in a deterministic order. {$sort: options.customSort || {key: 1}}, ]; } const result = conn.adminCommand({aggregate: 1, pipeline: pipeline, cursor: {}}); assert.commandWorked(result); return result.cursor.firstBatch; } /** * Collects query stats from any aggregate command query shapes (with $queryStats requests filtered * out) that were generated by the shell that is running tests. * * /** * @param {object} conn - connection to database * @param {object} options { * {BinData} hmacKey * {boolean} transformIdentifiers - whether to include transform identifiers * } */ export function getQueryStatsAggCmd( db, options = { transformIdentifiers: false, hmacKey: kDefaultQueryStatsHmacKey, }, ) { let pipeline; let queryStatsStage = {$queryStats: {}}; if (options.transformIdentifiers) { queryStatsStage = { $queryStats: { transformIdentifiers: { algorithm: "hmac-sha-256", hmacKey: options.hmacKey ? options.hmacKey : kDefaultQueryStatsHmacKey, }, }, }; } pipeline = [ queryStatsStage, // Filter out find queries and $queryStats aggregations. { $match: { "key.queryShape.command": "aggregate", "key.queryShape.pipeline.0.$queryStats": {$exists: false}, "key.client.application.name": kShellApplicationName, }, }, // Sort on key so entries are in a deterministic order. {$sort: {key: 1}}, ]; return db.getSiblingDB("admin").aggregate(pipeline).toArray(); } export function confirmAllExpectedFieldsPresent(expectedKey, resultingKey) { let fieldsCounter = 0; for (const field in resultingKey) { fieldsCounter++; if (field === "client") { // client meta data is environment/machine dependent, so do not // assert on fields or specific fields other than the application name. assert.eq(resultingKey.client.application.name, kShellApplicationName); // SERVER-83926 We should never report the "mongos" section, since it is too specific // and would result in too many different query shapes. assert(!resultingKey.client.hasOwnProperty("mongos"), resultingKey.client); continue; } if (!expectedKey.hasOwnProperty(field)) { jsTest.log.info("Field present in actual object but missing from expected: " + field); jsTest.log.info("Expected", {expectedKey}); jsTest.log.info("Actual", {resultingKey}); } assert(expectedKey.hasOwnProperty(field), field); if (field == "otherNss") { // When otherNss contains multiple namespaces, the order is not always consistent, // so use resultsEq to get stable results. assert(resultsEq(expectedKey[field], resultingKey[field])); continue; } assert.eq(expectedKey[field], resultingKey[field]); } // Make sure the resulting key isn't missing any fields. assert.eq( fieldsCounter, Object.keys(expectedKey).length, "Query Shape Key is missing or has extra fields: " + tojson(resultingKey), ); } export function assertExpectedResults({ results, expectedQueryStatsKey, expectedExecCount, expectedDocsReturnedSum, expectedDocsReturnedMax, expectedDocsReturnedMin, expectedDocsReturnedSumOfSq, getMores, }) { const {key, keyHash, queryShapeHash, metrics, asOf} = results; confirmAllExpectedFieldsPresent(expectedQueryStatsKey, key); assert.eq(expectedExecCount, metrics.execCount); const queryExecStats = getQueryExecMetrics(metrics); assert.docEq( { sum: NumberLong(expectedDocsReturnedSum), max: NumberLong(expectedDocsReturnedMax), min: NumberLong(expectedDocsReturnedMin), sumOfSquares: NumberLong(expectedDocsReturnedSumOfSq), }, queryExecStats.docsReturned, ); const firstResponseExecMicros = getCursorMetrics(metrics).firstResponseExecMicros; const {firstSeenTimestamp, latestSeenTimestamp, lastExecutionMicros, totalExecMicros, workingTimeMillis, cpuNanos} = metrics; // The tests can't predict exact timings, so just assert these three fields have been set (are // non-zero). assert.neq(lastExecutionMicros, NumberLong(0)); assert.neq(firstSeenTimestamp.getTime(), 0); assert.neq(latestSeenTimestamp.getTime(), 0); assert.neq(asOf.getTime(), 0); assert.neq(keyHash.length, 0); assert.neq(queryShapeHash.length, 0); if (!isLinux()) { assert(!cpuNanos, "cpuNanos should only be returned on Linux."); } const distributionFields = ["sum", "max", "min", "sumOfSquares"]; for (const field of distributionFields) { assert.neq(totalExecMicros[field], NumberLong(0)); assert.neq(firstResponseExecMicros[field], NumberLong(0)); assert(bsonWoCompare(workingTimeMillis[field], NumberLong(0)) >= 0); if (isLinux()) { assert(bsonWoCompare(cpuNanos[field], NumberLong(0)) > 0); } if (metrics.execCount > 1) { // If there are prior executions of the same query shape, we can't be certain if those // runs had getMores or not, so we can only check totalExec >= firstResponse. assert(bsonWoCompare(totalExecMicros[field], firstResponseExecMicros[field]) >= 0); } else if (getMores) { // If there are getMore calls, totalExecMicros fields should be greater than or equal to // firstResponseExecMicros. if (field == "min" || field == "max") { // In the case that we've executed multiple queries with the same shape, it is // possible for the min or max to be equal. assert.gte(totalExecMicros[field], firstResponseExecMicros[field]); } else { assert(bsonWoCompare(totalExecMicros[field], firstResponseExecMicros[field]) > 0); } } else { // If there are no getMore calls, totalExecMicros fields should be equal to // firstResponseExecMicros. assert.eq(totalExecMicros[field], firstResponseExecMicros[field]); } } } export function getCursorMetrics(metrics) { // When feature flag QueryStatsMetricsSubsections is enabled, metrics related to cursor would be nested // inside "cursor" section within metrics. When it is disabled, metrics would have a flat structure, without // the "cursor" section. This function abstracts away that difference regardless of the status of the feature flag. // TODO SERVER-112150: Once all versions support feature flag, simply return metrics["cursor"]. const cursorMetricsSectionName = "cursor"; if (metrics.hasOwnProperty(cursorMetricsSectionName)) { return metrics[cursorMetricsSectionName]; } return { firstResponseExecMicros: metrics.firstResponseExecMicros, }; } export function getQueryExecMetrics(metrics) { // When feature flag QueryStatsMetricsSubsections is enabled, metrics related to query execution would be nested // inside "queryExec" section within metrics. When it is disabled, metrics would have a flat structure, without // the "queryExec" section. This function abstracts away that difference regardless of the status of the feature flag. // TODO SERVER-112150: Once all versions support feature flag, simply return metrics["queryExec"]. const queryExecSectionName = "queryExec"; if (metrics.hasOwnProperty(queryExecSectionName)) { return metrics[queryExecSectionName]; } return { docsReturned: metrics.docsReturned, keysExamined: metrics.keysExamined, docsExamined: metrics.docsExamined, bytesRead: metrics.bytesRead, readTimeMicros: metrics.readTimeMicros, delinquentAcquisitions: metrics.delinquentAcquisitions, totalAcquisitionDelinquencyMillis: metrics.totalAcquisitionDelinquencyMillis, maxAcquisitionDelinquencyMillis: metrics.maxAcquisitionDelinquencyMillis, numInterruptChecksPerSec: metrics.numInterruptChecksPerSec, overdueInterruptApproxMaxMillis: metrics.overdueInterruptApproxMaxMillis, }; } export function getQueryPlannerMetrics(metrics) { // When feature flag QueryStatsMetricsSubsections is enabled, metrics related to query planner would be nested // inside "queryPlanner" section within metrics. When it is disabled, metrics would have a flat structure, without // the "queryPlanner" section. This function abstracts away that difference regardless of the status of the feature flag. // TODO SERVER-112150: Once all versions support feature flag, simply return metrics["queryPlanner"]. const queryPlannerSectionName = "queryPlanner"; if (metrics.hasOwnProperty(queryPlannerSectionName)) { return metrics[queryPlannerSectionName]; } return { hasSortStage: metrics.hasSortStage, usedDisk: metrics.usedDisk, fromMultiPlanner: metrics.fromMultiPlanner, fromPlanCache: metrics.fromPlanCache, }; } export function getWriteMetrics(metrics) { // When featureFlagQueryStatsUpdateCommand is enabled, new write-related metrics would be // nested inside "writes" section. const writeMetricsSectionName = "writes"; assert(metrics.hasOwnProperty(writeMetricsSectionName), "Expected 'writes' field in metrics"); return metrics[writeMetricsSectionName]; } export function assertAggregatedMetric(metrics, metricName, {sum, min, max, sumOfSq}) { assert.docEq( { sum: NumberLong(sum), max: NumberLong(max), min: NumberLong(min), sumOfSquares: NumberLong(sumOfSq), }, metrics[metricName], `Metric: ${metricName}`, ); } export function assertAggregatedBoolean(metrics, metricName, {trueCount, falseCount}) { assert.docEq( { "true": NumberLong(trueCount), "false": NumberLong(falseCount), }, metrics[metricName], `Metric: ${metricName}`, ); } export function assertAggregatedMetricsSingleExec( results, {docsExamined, keysExamined, usedDisk, hasSortStage, fromPlanCache, fromMultiPlanner, writes}, ) { { // Need to check if new format is used. const queryStatSection = getQueryExecMetrics(results.metrics); const numericMetric = (x) => ({sum: x, min: x, max: x, sumOfSq: x ** 2}); assertAggregatedMetric(queryStatSection, "docsExamined", numericMetric(docsExamined)); assertAggregatedMetric(queryStatSection, "keysExamined", numericMetric(keysExamined)); if (writes) { const {nMatched, nUpserted, nModified, nDeleted, nInserted} = writes; const writesSection = getWriteMetrics(results.metrics); assertAggregatedMetric(writesSection, "nMatched", numericMetric(nMatched)); assertAggregatedMetric(writesSection, "nUpserted", numericMetric(nUpserted)); assertAggregatedMetric(writesSection, "nModified", numericMetric(nModified)); assertAggregatedMetric(writesSection, "nDeleted", numericMetric(nDeleted)); assertAggregatedMetric(writesSection, "nInserted", numericMetric(nInserted)); } } { const queryStatSection = getQueryPlannerMetrics(results.metrics); const booleanMetric = (x) => ({trueCount: x ? 1 : 0, falseCount: x ? 0 : 1}); assertAggregatedBoolean(queryStatSection, "usedDisk", booleanMetric(usedDisk)); assertAggregatedBoolean(queryStatSection, "hasSortStage", booleanMetric(hasSortStage)); assertAggregatedBoolean(queryStatSection, "fromPlanCache", booleanMetric(fromPlanCache)); assertAggregatedBoolean(queryStatSection, "fromMultiPlanner", booleanMetric(fromMultiPlanner)); } } export function asFieldPath(str) { return "$" + str; } export function asVarRef(str) { return "$$" + str; } export function resetQueryStatsStore(conn, queryStatsStoreSize) { // Set the cache size to 0MB to clear the queryStats store, and then reset to // queryStatsStoreSize. assert.commandWorked(conn.adminCommand({setParameter: 1, internalQueryStatsCacheSize: "0MB"})); assert.commandWorked(conn.adminCommand({setParameter: 1, internalQueryStatsCacheSize: queryStatsStoreSize})); } /** * Checks that the given object contains the dottedPath. * @param {object} object * @param {string} dottedPath */ function hasValueAtPath(object, dottedPath) { let nestedFields = dottedPath.split("."); for (const nestedField of nestedFields) { if (!object.hasOwnProperty(nestedField)) { return false; } object = object[nestedField]; } return true; } /** * Returns the object's value at the dottedPath. * @param {object} object * @param {string} dottedPath */ export function getValueAtPath(object, dottedPath) { let nestedFields = dottedPath.split("."); for (const nestedField of nestedFields) { if (!object.hasOwnProperty(nestedField)) { return false; } object = object[nestedField]; } return object; } /** * Runs an assertion callback function on a node with query stats enabled - once with a mongod, and * once with a mongos. * @param {String} collName - The desired collection name to use. The db will be "test". * @param {Function} callbackFn - The function to make the assertion on each connection. */ export function withQueryStatsEnabled(collName, callbackFn) { const options = { setParameter: {internalQueryStatsRateLimit: -1}, }; { const conn = MongoRunner.runMongod(options); const testDB = conn.getDB("test"); var coll = testDB[collName]; coll.drop(); callbackFn(coll); MongoRunner.stopMongod(conn); } { const st = new ShardingTest({shards: 2, mongosOptions: options}); const testDB = st.getDB("test"); var coll = testDB[collName]; st.shardColl(coll, {_id: 1}, {_id: 1}); callbackFn(coll); st.stop(); } } /** * We run the command on an new database with an empty collection that has an index {v:1}. We then * obtain the queryStats key entry that is created and check the following things. * 1. The command associated with the key matches the commandName. * 2. The fields nested inside of queryShape field of the key exactly matches those given by * shapeFields. * 3. The list of fields of the key exactly matches those given by keyFields. * /** * @param {object} coll - The given collection to run on * @param {string} commandName - string name of type of command, ex. "find", "aggregate", or * "distinct" * @param {object} commandObj - The command that will be run * @param {object} shapeFields - List of fields that are part of the queryShape and should be nested * inside of Query Shape * @param {object} keyFields - List of outer fields not nested inside queryShape but should be part * of the key */ export function runCommandAndValidateQueryStats({coll, commandName, commandObj, shapeFields, keyFields}) { const testDB = coll.getDB(); assert.commandWorked(testDB.runCommand(commandObj)); const entry = getLatestQueryStatsEntry(testDB.getMongo(), {collName: coll.getName()}); assert.eq(entry.key.queryShape.command, commandName); const kApplicationName = "MongoDB Shell"; assert.eq(entry.key.client.application.name, kApplicationName); { assert(hasValueAtPath(entry, "key.client.driver"), entry); assert(hasValueAtPath(entry, "key.client.driver.name"), entry); assert(hasValueAtPath(entry, "key.client.driver.version"), entry); } { assert(hasValueAtPath(entry, "key.client.os"), entry); assert(hasValueAtPath(entry, "key.client.os.type"), entry); assert(hasValueAtPath(entry, "key.client.os.name"), entry); assert(hasValueAtPath(entry, "key.client.os.architecture"), entry); assert(hasValueAtPath(entry, "key.client.os.version"), entry); } // SERVER-83926 Make sure the mongos section doesn't show up. assert(!hasValueAtPath(entry, "key.client.mongos"), entry); // Every path in shapeFields is in the queryShape. let shapeFieldsPrefixes = []; for (const field of shapeFields) { assert( hasValueAtPath(entry.key.queryShape, field), `QueryShape: ${tojson(entry.key.queryShape)} is missing field ${field}`, ); shapeFieldsPrefixes.push(field.split(".")[0]); } // Every field in queryShape is in shapeFields or is the base of a path in shapeFields. for (const field in entry.key.queryShape) { assert(shapeFieldsPrefixes.includes(field), `Unexpected field ${field} in shape for ${commandName}`); } // Every path in keyFields is in the key. let keyFieldsPrefixes = []; for (const field of keyFields) { if (field === "collectionType" && FixtureHelpers.isMongos(testDB)) { // TODO SERVER-76263 collectionType is not yet available on mongos. continue; } assert(hasValueAtPath(entry.key, field), `Key: ${tojson(entry.key)} is missing field ${field}`); keyFieldsPrefixes.push(field.split(".")[0]); } // Every field in the key is in keyFields or is the base of a path in keyFields. for (const field in entry.key) { assert(keyFieldsPrefixes.includes(field), `Unexpected field ${field} in key for ${commandName}`); } // $hint can only be string(index name) or object (index spec). assert.throwsWithCode(() => { coll.find({v: {$eq: 2}}) .hint({"v": 60, $hint: -128}) .itcount(); }, ErrorCodes.BadValue); } /** * Helper function that verifies each query stats entry has a hash, filters out the unique hashes * and returns a list of them. * @param {list} entries - List of entries returned from $queryStats. * @returns {list} list of unique hashes corresponding to the entries. */ function getUniqueValuesByName(entries, fieldName) { const hashes = new Set(); for (const entry of entries) { assert( entry[fieldName] && entry[fieldName] !== "", `Entry does not have a '${fieldName}' field: ${tojson(entry)}`, ); hashes.add(entry[fieldName]); } return Array.from(hashes); } /** * Helper function that verifies that we have as many key hashes as query stats entries and * returns a list of said hashes. * @param {list} entries - List of entries returned from $queryStats. * @returns {list} list of unique hashes corresponding to the entries. */ export function getQueryStatsKeyHashes(entries) { const keyHashArray = getUniqueValuesByName(entries, "keyHash"); // We expect all keys and hashes to be unique, so assert that we have as many unique hashes as // entries. assert.eq(keyHashArray.length, entries.length, tojson(entries)); return keyHashArray; } /** * Helper function that filters out the unique query shape hashes from the query stats entries and * returns a list of said hashes. * @param {list} entries - List of entries returned from $queryStats. * @returns {list} list of unique hashes corresponding to the shapes in the entries. */ export function getQueryStatsShapeHashes(entries) { return getUniqueValuesByName(entries, "queryShapeHash"); } /** * Helper function to construct a query stats key for a find query. */ export function getFindQueryStatsKey({conn, collName, queryShapeExtra, extra = {}}) { // This is most of the query stats key. There are configuration-specific details that are // added conditionally afterwards. const baseQueryShape = { cmdNs: {db: "test", coll: collName}, command: "find", }; const baseStatsKey = { queryShape: Object.assign(baseQueryShape, queryShapeExtra), client: {application: {name: "MongoDB Shell"}}, batchSize: "?number", }; const queryStatsKey = Object.assign(baseStatsKey, extra); const coll = conn.getDB("test")[collName]; if (conn.isMongos()) { queryStatsKey.readConcern = {level: "local", provenance: "implicitDefault"}; } else { // TODO SERVER-76263 - make this apply to mongos once it has collection telemetry info. queryStatsKey.collectionType = isView(conn, coll) ? "view" : "collection"; } if (FixtureHelpers.isReplSet(conn.getDB("test"))) { queryStatsKey["$readPreference"] = {mode: "secondaryPreferred"}; } return queryStatsKey; } /** * Helper function to construct a query stats key for an aggregate query. */ export function getAggregateQueryStatsKey({conn, collName, queryShapeExtra, extra = {}}) { const testDB = conn.getDB("test"); const coll = testDB[collName]; const baseQueryShape = {cmdNs: {db: "test", coll: collName}, command: "aggregate"}; const baseStatsKey = { queryShape: Object.assign(baseQueryShape, queryShapeExtra), client: {application: {name: "MongoDB Shell"}}, cursor: {batchSize: "?number"}, }; if (!conn.isMongos()) { // TODO SERVER-76263 - make this apply to mongos once it has collection telemetry info. baseStatsKey.collectionType = collName == "$cmd.aggregate" ? "virtual" : isView(conn, coll) ? "view" : "collection"; } if (FixtureHelpers.isReplSet(conn.getDB("test"))) { baseStatsKey["$readPreference"] = {mode: "secondaryPreferred"}; } const queryStatsKey = Object.assign(baseStatsKey, extra); return queryStatsKey; } /** * Helper function to construct a query stats key for a count query. * @param {object} coll - The given collection to run on * @param {string} collName - The collection name * @param {object} queryShapeExtra - The count-specific elements in its query shape, such as query, * limit, etc. */ export function getCountQueryStatsKey(conn, collName, queryShapeExtra) { const baseQueryShape = { cmdNs: {db: "test", coll: collName}, command: "count", }; const queryStatsKey = { queryShape: Object.assign(baseQueryShape, queryShapeExtra), client: {application: {name: "MongoDB Shell"}}, }; const coll = conn.getDB("test")[collName]; if (!conn.isMongos()) { // TODO SERVER-76263 - make this apply to mongos once it has collection telemetry info. queryStatsKey.collectionType = isView(conn, coll) ? "view" : "collection"; } if (FixtureHelpers.isReplSet(conn.getDB("test"))) { queryStatsKey["$readPreference"] = {mode: "secondaryPreferred"}; } return queryStatsKey; } /** * Helper function to construct a query stats key for a distinct query. * @param {object} coll - The given collection to run on * @param {string} collName - The collection name * @param {object} queryShapeExtra - The distinct-specific elements in its query shape, such as key, * query, etc. */ export function getDistinctQueryStatsKey(conn, collName, queryShapeExtra) { const baseQueryShape = { cmdNs: {db: "test", coll: collName}, command: "distinct", }; const queryStatsKey = { queryShape: Object.assign(baseQueryShape, queryShapeExtra), client: {application: {name: "MongoDB Shell"}}, }; const coll = conn.getDB("test")[collName]; if (!conn.isMongos()) { // TODO SERVER-76263 - make this apply to mongos once it has collection telemetry info. queryStatsKey.collectionType = isView(conn, coll) ? "view" : "collection"; } if (FixtureHelpers.isReplSet(conn.getDB("test"))) { queryStatsKey["$readPreference"] = {mode: "secondaryPreferred"}; } return queryStatsKey; } function getQueryStatsForNs(testDB, namespace) { return getQueryStats(testDB, { collName: namespace, extraMatch: {"key.queryShape.pipeline.0.$queryStats": {$exists: false}}, }); } function getSingleQueryStatsEntryForNs(testDB, namespace) { const queryStats = getQueryStatsForNs(testDB, namespace); assert.lte(queryStats.length, 1, "Multiple query stats entries found for " + namespace); assert.eq(queryStats.length, 1, "No query stats entries found for " + namespace); return queryStats[0]; } /** * Gets the execution count of the latest query stat entry for the given nss. It assumes that there * is only one query stats entry. If multiple entries are found, it will raise an assertion error. * * @param {string} testDB * @param {string} namespace * @returns {number} The execution count */ export function getExecCount(testDB, namespace) { const queryStats = getQueryStatsForNs(testDB, namespace); assert.lte(queryStats.length, 1, "Multiple query stats entries found for " + namespace); return queryStats.length == 0 ? 0 : queryStats[0].metrics.execCount; } /** * Clears the plan cache and query stats store so they are in a known (empty) state to prevent * stats and plan caches from leaking between queries. */ export function clearPlanCacheAndQueryStatsStore(conn, coll) { const testDB = conn.getDB("test"); // If this is a query against a view, we need to reset the plan cache for the underlying // collection. const viewSource = getViewSource(conn, coll); const collName = viewSource ? viewSource : coll.getName(); // Reset the query stats store and plan cache so the recorded stats will be consistent. resetQueryStatsStore(conn, "1%"); assert.commandWorked(testDB.runCommand({planCacheClear: collName})); } function getViewSource(conn, coll) { const testDB = conn.getDB("test"); const collectionInfos = testDB.getCollectionInfos({name: coll.getName()}); assert.eq(collectionInfos.length, 1, "Couldn't find collection info for " + coll.getName()); const collectionInfo = collectionInfos[0]; if (collectionInfo.type == "view") { return collectionInfo.options.viewOn; } return null; } function isView(conn, coll) { return getViewSource(conn, coll) !== null; } /** * Given a command and batch size, runs the command and enough getMores to exhaust the cursor, * returning the query stats. Asserts that the expected number of documents is ultimately returned, * and asserts that query stats are written as expected. */ export function exhaustCursorAndGetQueryStats({conn, cmd, key, expectedDocs}) { const testDB = conn.getDB("test"); // Set up the namespace and the batch size - it goes in different fields for find vs. aggregate. // For the namespace, it's usually the collection name, but in the case of aggregate: 1 queries, // it will be "$cmd.aggregate". let namespace = 1; let batchSize = 0; if (cmd.hasOwnProperty("find")) { assert(cmd.hasOwnProperty("batchSize"), "find command missing batchSize"); batchSize = cmd.batchSize; namespace = cmd.find; } else if (cmd.hasOwnProperty("aggregate")) { assert(cmd.hasOwnProperty("cursor"), "aggregate command missing cursor"); assert(cmd.cursor.hasOwnProperty("batchSize"), "aggregate command missing batchSize"); batchSize = cmd.cursor.batchSize; namespace = cmd.aggregate == 1 ? "$cmd.aggregate" : cmd.aggregate; } else { assert(false, "Unexpected command type"); } const execCountPre = getExecCount(testDB, namespace); jsTestLog("Running command with batch size " + batchSize + ": " + tojson(cmd)); const initialResult = assert.commandWorked(testDB.runCommand(cmd)); let cursor = initialResult.cursor; let allResults = cursor.firstBatch; // When batchSize equals expectedDocs, we may or may not get cursor ID 0 in the initial // response depending on the exact query. However, if it is strictly greater or less than, // we assert on whether or not the initial batch exhausts the cursor. const expectGetMores = cursor.id != 0; if (batchSize < expectedDocs) { assert.neq(0, cursor.id, "Cursor unexpectedly exhausted in initial batch"); } else if (batchSize > expectedDocs) { assert.eq(0, cursor.id, "Initial batch unexpectedly wasn't sufficient to exhaust the cursor"); } while (cursor.id != 0) { // Since the cursor isn't exhausted, ensure no query stats results have been written. const execCount = getExecCount(testDB, namespace); assert.eq(execCount, execCountPre); const getMore = {getMore: cursor.id, collection: namespace, batchSize: batchSize}; jsTestLog("Running getMore with batch size " + batchSize + ": " + tojson(getMore)); const getMoreResult = assert.commandWorked(testDB.runCommand(getMore)); cursor = getMoreResult.cursor; allResults = allResults.concat(cursor.nextBatch); } assert.eq(allResults.length, expectedDocs); const execCountPost = getExecCount(testDB, namespace); assert.eq(execCountPost, execCountPre + 1, "Didn't find query stats for namespace " + namespace); const queryStats = getSingleQueryStatsEntryForNs(conn, namespace); jsTest.log.info("Query Stats", {queryStats}); assertExpectedResults({ results: queryStats, expectedQueryStatsKey: key, expectedExecCount: execCountPost, expectedDocsReturnedSum: expectedDocs * execCountPost, expectedDocsReturnedMax: expectedDocs, expectedDocsReturnedMin: expectedDocs, expectedDocsReturnedSumOfSq: expectedDocs ** 2 * execCountPost, getMores: expectGetMores, }); return queryStats; } /** * The options passed to server deployments sometimes get modified. Instead of having a single * constant that we pass around (risking modification), we return the defaults from a function. */ export function getQueryStatsServerParameters() { return {setParameter: {internalQueryStatsRateLimit: -1}}; } /** * Run the given callback for each of the deployment scenarios we want to test query stats against * (standalone, replset, sharded cluster). Callback must accept two arguments: the connection and * the test object (ReplSetTest or ShardingTest). For the standalone case, the test is null. */ export function runForEachDeployment(callbackFn) { { const conn = MongoRunner.runMongod(getQueryStatsServerParameters()); callbackFn(conn, null); MongoRunner.stopMongod(conn); } runOnReplsetAndShardedCluster(callbackFn); } /** * Run the given callback against replset, sharded cluster deployments. This is especially useful * for tests involving query settings since PQS does not work in standalone. Callback must accept * two arguments: the connection and the test object (ReplSetTest or ShardingTest). */ export function runOnReplsetAndShardedCluster(callbackFn) { { const rst = new ReplSetTest({nodes: 3, nodeOptions: getQueryStatsServerParameters()}); rst.startSet(); rst.initiate(); callbackFn(rst.getPrimary(), rst); rst.stopSet(); } { const st = new ShardingTest( Object.assign({shards: 2, other: {mongosOptions: getQueryStatsServerParameters()}}), ); const testDB = st.s.getDB("test"); // Enable sharding separate from per-test setup to avoid calling enableSharding repeatedly. assert.commandWorked( testDB.adminCommand({enableSharding: testDB.getName(), primaryShard: st.shard0.shardName}), ); callbackFn(st.s, st); st.stop(); } } /** * Given a query stats entry, and stats that the entry should have, this function checks that the * entry is the result of a change stream request and that the metrics are what are expected. */ export function checkChangeStreamEntry({queryStatsEntry, db, collectionName, numExecs, numDocsReturned}) { assert.eq(collectionName, queryStatsEntry.key.queryShape.cmdNs.coll); // Confirm entry is a change stream request. const pipelineShape = queryStatsEntry.key.queryShape.pipeline; assert(pipelineShape[0].hasOwnProperty("$changeStream"), pipelineShape); // TODO SERVER-76263 Support reporting 'collectionType' on a sharded cluster. if (!FixtureHelpers.isMongos(db)) { assert.eq("changeStream", queryStatsEntry.key.collectionType); } // Checking that metrics match expected metrics. const queryMetrics = queryStatsEntry.metrics; const queryExecMetrics = getQueryExecMetrics(queryMetrics); assert.eq(queryMetrics.execCount, numExecs); assert.eq(queryExecMetrics.docsReturned.sum, numDocsReturned); // FirstResponseExecMicros and TotalExecMicros match since each getMore is recorded as a new // first response. const totalExecMicros = queryMetrics.totalExecMicros; const firstResponseExecMicros = getCursorMetrics(queryMetrics).firstResponseExecMicros; assert.eq(totalExecMicros.sum, firstResponseExecMicros.sum); assert.eq(totalExecMicros.max, firstResponseExecMicros.max); assert.eq(totalExecMicros.min, firstResponseExecMicros.min); } /** * Given a change stream cursor, this function will return the number of getMores executed until the * change stream is updated. This only applies when the cursor is waiting for one new document (or * set the batchSize of the input cursor to 1) so each hasNext() call will correspond to an internal * getMore. */ export function getNumberOfGetMoresUntilNextDocForChangeStream(cursor) { let numGetMores = 0; assert.soon(() => { numGetMores++; return cursor.hasNext(); }); // Get the document that is on the cursor to reset the cursor to a state where calling hasNext() // corresponds to a getMore. cursor.next(); return numGetMores; }