SERVER-95816 Backport property-based tests to v8.0 (#38184)

GitOrigin-RevId: 761eba164dd84ab30595962ce968e752c7d40d25
This commit is contained in:
Matthew Boros 2025-09-15 10:45:10 -04:00 committed by MongoDB Bot
parent 4a322ac672
commit 857f2a7efc
51 changed files with 3029 additions and 7 deletions

View File

@ -129,6 +129,7 @@ globals:
isRetryableError: true
numberDecimalsAlmostEqual: true
numberDecimalsEqual: true
_resultSetsEqualUnordered: true
debug: true
bsonsize: true
_DelegatingDriverSession: true

View File

@ -23,6 +23,8 @@ selector:
# The following tests start their own ShardingTest or ReplSetTest, respectively.
- requires_sharding
- requires_replication
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
archive:

View File

@ -51,6 +51,8 @@ selector:
- requires_sharding
- requires_replication
- requires_spawning_own_processes
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
archive:

View File

@ -28,6 +28,8 @@ selector:
exclude_with_any_tags:
- assumes_against_mongod_not_mongos
- requires_profiling
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
archive:

View File

@ -106,6 +106,8 @@ selector:
- config_shard_incompatible
# Currently this passthrough enables the balancer to allow the config transition to successfully complete.
- assumes_balancer_off
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
archive:

View File

@ -57,6 +57,8 @@ selector:
- uses_transactions
# Parallel shell is not causally consistent because it uses a different session.
- uses_parallel_shell
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
archive:

View File

@ -98,6 +98,8 @@ selector:
- uses_transactions
# Parallel shell is not causally consistent because it uses a different session.
- uses_parallel_shell
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
archive:

View File

@ -42,6 +42,7 @@ selector:
- jstests/core/query/or/or_to_in.js
# The following test fires a large query that takes too long with wildcard indexes.
- jstests/core/query/query_settings/query_settings_size_limits.js
- jstests/core/**/*_pbt.js
exclude_with_any_tags:
# This suite implicitly creates compound wildcard indexes.

View File

@ -70,6 +70,8 @@ selector:
# command.
- requires_profiling
- uses_parallel_shell
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
config:

View File

@ -103,6 +103,8 @@ selector:
# Capped collections cannot be sharded
- requires_capped
- uses_parallel_shell
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
config:

View File

@ -68,6 +68,8 @@ selector:
# system.profile collection doesn't exist on mongos.
- requires_profiling
- uses_parallel_shell
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
config:

View File

@ -36,6 +36,8 @@ selector:
- requires_profiling
# Running $queryStats will increment these counters which can screw up some test assertions.
- inspects_command_opcounters
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
archive:

View File

@ -28,6 +28,8 @@ selector:
# "Cowardly refusing to override write concern of command: ..."
- assumes_write_concern_unchanged
- assumes_standalone_mongod
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
archive:

View File

@ -49,6 +49,8 @@ selector:
exclude_with_any_tags:
- assumes_standalone_mongod
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:

View File

@ -117,6 +117,8 @@ selector:
- assumes_no_implicit_index_creation
- assumes_unsharded_collection
- cannot_create_unique_index_when_using_hashed_shard_key
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:
config:

View File

@ -83,6 +83,8 @@ selector:
- assumes_against_mongod_not_mongos
# system.profile collection doesn't exist on mongos.
- requires_profiling
# These tests run many aggregations, and the override slows them down enough to hit the evergreen timeout.
- query_intensive_pbt
executor:

View File

@ -282,6 +282,7 @@ selector:
- jstests/core/rename_system_buckets_collections.js
# Inserts documents that are too large for a timeseries collection.
- jstests/core/query/bson_size_limit.js
- jstests/core/**/*_pbt.js
exclude_with_any_tags:
- requires_sharding

View File

@ -0,0 +1,50 @@
/**
* A property-based test that asserts the correctness of queries that begin with $addFields.
*
* @tags: [
* query_intensive_pbt,
* requires_timeseries,
* # Runs queries that may return many results, requiring getmores.
* requires_getmore,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {createCorrectnessProperty} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {
addFieldsConstArb,
addFieldsVarArb,
getAggPipelineModel
} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 40;
const numQueriesPerRun = 40;
const controlColl = db.add_fields_pbt_control;
const experimentColl = db.add_fields_pbt_experiment;
const correctnessProperty = createCorrectnessProperty(controlColl, experimentColl);
const addFieldsArb = fc.oneof(addFieldsConstArb, addFieldsVarArb);
const aggModel = fc.record({addFieldsStage: addFieldsArb, restOfPipeline: getAggPipelineModel()})
.map(({addFieldsStage, restOfPipeline}) => {
return [addFieldsStage, ...restOfPipeline];
});
testProperty(correctnessProperty,
{controlColl, experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun}),
numRuns);
// TODO SERVER-103381 implement time-series PBT testing.

View File

@ -0,0 +1,234 @@
/**
* Test basic properties that should hold for our core agg stages, when placed at the end of a
* pipeline. This includes:
* - An exclusion projection should drop the specified fields.
* - An inclusion projection should keep the specified fields, and drop all others.
* - $limit should limit the number of results.
* - $sort should output documents in sorted order.
* - $group should output documents with unique _ids (the group key).
*
* These may seem like simple checks that aren't worth testing. However with complex optimizations,
* they may break sometimes, such as with SERVER-100299.
*
* @tags: [
* query_intensive_pbt,
* requires_timeseries,
* assumes_no_implicit_collection_creation_on_get_collection,
* # Runs queries that may return many results, requiring getmores.
* requires_getmore,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {groupArb} from "jstests/libs/property_test_helpers/models/group_models.js";
import {
getAggPipelineModel,
getSingleFieldProjectArb,
getSortArb,
limitArb
} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 20;
/*
* --- Exclusion projection testing ---
*
* Our projection testing does not allow dotted fields in the $project, since this would make the
* assert logic much more complicated. The fields are all non-dotted top level fields.
* The documents may contain objects and arrays, but this doesn't interfere with the assertions
* since we can still check if the field exists in the document or not (we don't need to inspect the
* value).
*/
function checkExclusionProjectionResults(query, results) {
const projectSpec = query.at(-1)['$project'];
const excludedField = Object.keys(projectSpec).filter(field => field !== '_id')[0];
const isIdFieldIncluded = projectSpec._id;
for (const doc of results) {
const docFields = Object.keys(doc);
// If the excluded field still exists, fail.
if (docFields.includes(excludedField)) {
return false;
}
// If _id is excluded and it exists, fail.
if (!isIdFieldIncluded && docFields.includes('_id')) {
return false;
}
}
return true;
}
const exclusionProjectionTest = {
// The stage we're testing.
stageArb: getSingleFieldProjectArb(
false /*isInclusion*/,
{simpleFieldsOnly: true}), // Only allow simple paths, no dotted paths.
// A function that tests the results are as expected.
checkResultsFn: checkExclusionProjectionResults,
// A message to output on failure.
failMsg: 'Exclusion projection did not remove the specified fields.'
};
// --- Inclusion projection testing ---
function checkInclusionProjectionResults(query, results) {
const projectSpec = query.at(-1)['$project'];
const includedField = Object.keys(projectSpec).filter(field => field !== '_id')[0];
const isIdFieldExcluded = !projectSpec._id;
for (const doc of results) {
for (const field of Object.keys(doc)) {
// If the _id field is excluded and it exists, fail.
if (field === '_id' && isIdFieldExcluded) {
return false;
}
// If we have a field on the doc that is not the included field, fail.
if (field !== '_id' && field !== includedField) {
return false;
}
}
}
return true;
}
const inclusionProjectionTest = {
stageArb: getSingleFieldProjectArb(true /*isInclusion*/, {simpleFieldsOnly: true}),
checkResultsFn: checkInclusionProjectionResults,
failMsg: 'Inclusion projection did not drop all other fields.'
};
// --- $limit testing ---
function checkLimitResults(query, results) {
const limitStage = query.at(-1);
const limitVal = limitStage['$limit'];
return results.length <= limitVal;
}
const limitTest = {
stageArb: limitArb,
checkResultsFn: checkLimitResults,
failMsg: '$limit did not limit how many documents there were in the output'
};
// --- $sort testing ---
function checkSortResults(query, results) {
const sortSpec = query.at(-1)['$sort'];
const sortField = Object.keys(sortSpec)[0];
const sortDirection = sortSpec[sortField];
function orderCorrect(doc1, doc2) {
const doc1SortVal = doc1[sortField];
const doc2SortVal = doc2[sortField];
// bsonWoCompare does not match the $sort semantics for arrays. It is nontrivial to write a
// comparison function that matches these semantics, so we will ignore arrays.
// TODO SERVER-101149 improve sort checking logic to possibly handle arrays and missing
// values.
if (Array.isArray(doc1SortVal) || Array.isArray(doc2SortVal)) {
return true;
}
if (typeof doc1SortVal === 'undefined' || typeof doc2SortVal === 'undefined') {
return true;
}
const cmp = bsonWoCompare(doc1SortVal, doc2SortVal);
if (sortDirection === 1) {
return cmp <= 0;
} else {
return cmp >= 0;
}
}
for (let i = 0; i < results.length - 1; i++) {
const doc1 = results[i];
const doc2 = results[i + 1];
if (!orderCorrect(doc1, doc2)) {
return false;
}
}
return true;
}
const sortTest = {
stageArb: getSortArb(),
checkResultsFn: checkSortResults,
failMsg: '$sort did not output documents in sorted order.'
};
// --- $group testing ---
function checkGroupResults(query, results) {
/*
* JSON.stringify can output the same string for two different inputs, for example
* `JSON.stringify(null)` and `JSON.stringify(NaN)` both output 'null'.
* Our PBTs are meant to cover a core subset of MQL. Because of this design decision, we don't
* have to worry about overlapping output for JSON.stringify. The data in our PBT test documents
* have a narrow enough set of types.
*/
const ids = results.map(doc => JSON.stringify(doc._id));
return new Set(ids).size === results.length;
}
const groupTest = {
stageArb: groupArb,
checkResultsFn: checkGroupResults,
failMsg: '$group did not output documents with unique _ids'
};
const testCases =
[exclusionProjectionTest, inclusionProjectionTest, limitTest, sortTest, groupTest];
const experimentColl = db.agg_behavior_correctness_experiment;
function makePropertyFn(checkResultsFn, failMsg) {
return function(getQuery, testHelpers) {
for (let queryIx = 0; queryIx < testHelpers.numQueryShapes; queryIx++) {
const query = getQuery(queryIx, 0 /* paramIx */);
const results = experimentColl.aggregate(query).toArray();
const passed = checkResultsFn(query, results);
if (!passed) {
return {
passed: false,
msg: failMsg,
query,
results,
explain: experimentColl.explain().aggregate(query)
};
}
}
return {passed: true};
};
}
for (const {stageArb, checkResultsFn, failMsg} of testCases) {
const propFn = makePropertyFn(checkResultsFn, failMsg);
// Create an agg model that ends with the stage we're testing. The bag does not have to be
// deterministic because these properties should always hold.
const startOfPipelineArb = getAggPipelineModel({deterministicBag: false});
const aggModel = fc.record({startOfPipeline: startOfPipelineArb, lastStage: stageArb})
.map(function({startOfPipeline, lastStage}) {
return [...startOfPipeline, lastStage];
});
// Run the property with a regular collection.
testProperty(
propFn,
{experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun: 20}),
numRuns);
// TODO SERVER-103381 re-enable timeseries PBT testing.
// Run the property with a TS collection.
// testProperty(propFn,
// {experimentColl},
// makeWorkloadModel(
// {collModel: getCollectionModel({isTS: true}), aggModel, numQueriesPerRun:
// 20}),
// numRuns);
}

View File

@ -0,0 +1,46 @@
/**
* A property-based test that asserts the correctness of queries that begin with $group.
*
* @tags: [
* query_intensive_pbt,
* requires_timeseries,
* # Runs queries that may return many results, requiring getmores.
* requires_getmore,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {createCorrectnessProperty} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {groupArb} from "jstests/libs/property_test_helpers/models/group_models.js";
import {getAggPipelineModel} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 40;
const numQueriesPerRun = 40;
const controlColl = db.group_pbt_control;
const experimentColl = db.group_pbt_experiment;
const correctnessProperty = createCorrectnessProperty(controlColl, experimentColl);
const aggModel = fc.record({groupStage: groupArb, restOfPipeline: getAggPipelineModel()})
.map(({groupStage, restOfPipeline}) => {
return [groupStage, ...restOfPipeline];
});
testProperty(correctnessProperty,
{controlColl, experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun}),
numRuns);
// TODO SERVER-103381 implement time-series PBT testing.

View File

@ -0,0 +1,46 @@
/**
* A property-based test that asserts the correctness of queries that begin with $match.
*
* @tags: [
* query_intensive_pbt,
* requires_timeseries,
* # Runs queries that may return many results, requiring getmores.
* requires_getmore,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {createCorrectnessProperty} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {getMatchArb} from "jstests/libs/property_test_helpers/models/match_models.js";
import {getAggPipelineModel} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 40;
const numQueriesPerRun = 40;
const controlColl = db.match_pbt_control;
const experimentColl = db.match_pbt_experiment;
const correctnessProperty = createCorrectnessProperty(controlColl, experimentColl);
const aggModel = fc.record({matchStage: getMatchArb(), restOfPipeline: getAggPipelineModel()})
.map(({matchStage, restOfPipeline}) => {
return [matchStage, ...restOfPipeline];
});
testProperty(correctnessProperty,
{controlColl, experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun}),
numRuns);
// TODO SERVER-103381 implement time-series PBT testing.

View File

@ -0,0 +1,50 @@
/**
* A property-based test that asserts the correctness of queries that begin with $project.
*
* @tags: [
* query_intensive_pbt,
* requires_timeseries,
* # Runs queries that may return many results, requiring getmores.
* requires_getmore,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {createCorrectnessProperty} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {
computedProjectArb,
getAggPipelineModel,
simpleProjectArb
} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 40;
const numQueriesPerRun = 40;
const controlColl = db.project_pbt_control;
const experimentColl = db.project_pbt_experiment;
const correctnessProperty = createCorrectnessProperty(controlColl, experimentColl);
const projectArb = fc.oneof(simpleProjectArb, computedProjectArb);
const aggModel = fc.record({projectStage: projectArb, restOfPipeline: getAggPipelineModel()})
.map(({projectStage, restOfPipeline}) => {
return [projectStage, ...restOfPipeline];
});
testProperty(correctnessProperty,
{controlColl, experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun}),
numRuns);
// TODO SERVER-103381 implement time-series PBT testing.

View File

@ -0,0 +1,107 @@
/**
* A property-based test that targets queries using an index scan to satisfy a $sort. It asserts the
* correctness of these queries.
*
* We also have a check to make sure the fast-check model targets this feature effectively. At least
* 80% of queries should use an index to satisfy a $sort. We can't bring this number to 100% without
* sacrificing coverage, so instead we aim for a high percentage of winning plans to use the feature
* The most common case where we don't use the index is:
* [{$sort: {a: 1}}, {$sort: {b: 1}}]
* We generate the $sort on `a` and an index {a: 1}, expecting the $sort to use the index. However
* our query optimizer realizes that the sort on `b` allows us to remove the sort on `a`.
* We could prevent the rest of the pipeline from using a $sort, but there are valuable cases that
* include sorting later, so we keep it.
* Another case that makes it impossible to reach 100% targeting is because the optimizer may not
* pick the plan we are looking for. There may be a better plan available.
*
* @tags: [
* query_intensive_pbt,
* requires_timeseries,
* assumes_no_implicit_collection_creation_on_get_collection,
* # Runs queries that may return many results, requiring getmores.
* requires_getmore,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {getPlanStages, getWinningPlanFromExplain} from "jstests/libs/analyze_plan.js";
import {createCorrectnessProperty} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {
getAggPipelineModel,
getSortArb
} from "jstests/libs/property_test_helpers/models/query_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 40;
const numQueriesPerRun = 40;
const controlColl = db.sort_via_index_pbt_control;
const experimentColl = db.sort_via_index_pbt_experiment;
/*
* Collect statistics on how many plans use the expected index to sort the documents. This helps us
* make sure our test is targeting the feature effectively.
*/
let totalNumPlans = 0;
let numPlansUsedIndex = 0;
function statsCollectorFn(explain) {
totalNumPlans++;
const ixscanStages = getPlanStages(getWinningPlanFromExplain(explain), 'IXSCAN');
// We place the sort index first, so it will be given the name 'index_0'.
if (ixscanStages.every(stage => stage.indexName === 'index_0')) {
numPlansUsedIndex++;
}
}
const correctnessProperty =
createCorrectnessProperty(controlColl, experimentColl, statsCollectorFn);
/*
* Generate a random $sort, aggregation pipelines, and collection. Using the $sort, create an
* additional index that has the same specifications. Also prefix each aggregation query with the
* $sort.
*/
function getWorkloadModel(isTS) {
return fc
.record({
sort: getSortArb(8 /* maxNumSortComponents */),
pipelines: fc.array(getAggPipelineModel(),
{minLength: 0, maxLength: numQueriesPerRun, size: '+2'}),
collSpec: getCollectionModel({isTS})
})
.map(({sort, pipelines, collSpec}) => {
// Prefix every pipeline with the sort operation.
const pipelinesWithSort = pipelines.map(pipeline => [sort, ...pipeline]);
// Create an index that will satisfy this sort.
// TODO SERVER-105223 use other kinds of indexes to satisfy the sort (hashed, wildcard).
// The server won't let us create an index with pattern {_id: -1}. If we see a sort
// with only _id, it's not necessary to create an index anyway since we always
// have {_id: 1}.
let indexes;
if (Object.keys(sort.$sort).length === 1 && sort.$sort._id) {
indexes = [...collSpec.indexes];
} else {
indexes = [{def: sort.$sort, options: {}}, ...collSpec.indexes];
}
return {collSpec: {isTS, docs: collSpec.docs, indexes}, queries: pipelinesWithSort};
});
}
testProperty(correctnessProperty,
{controlColl, experimentColl},
getWorkloadModel(false /* isTS */),
numRuns);
// Assert that the number of plans that used the index for the sort is >= 80%
assert.gt(totalNumPlans, 0);
assert.gte(numPlansUsedIndex / totalNumPlans, 0.8, {numPlansUsedIndex, totalNumPlans});
// TODO SERVER-103381 implement time-series PBT testing.

View File

@ -0,0 +1,59 @@
/**
* A property-based test that runs random queries with indexes (and the plan cache enabled) and
* compares the results to the same queries, deoptimized.
*
* @tags: [
* query_intensive_pbt,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* requires_timeseries,
* assumes_no_implicit_collection_creation_on_get_collection,
* # Incompatible with setParameter
* does_not_support_stepdowns,
* # Runs queries that may return many results, requiring getmores
* requires_getmore,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {createCorrectnessProperty} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {getAggPipelineModel} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 50;
const numQueriesPerRun = 20;
const controlColl = db.index_correctness_pbt_control;
const experimentColl = db.index_correctness_pbt_experiment;
const correctnessProperty = createCorrectnessProperty(controlColl, experimentColl);
const aggModel = getAggPipelineModel();
// Test with a regular collection.
testProperty(correctnessProperty,
{controlColl, experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun}),
numRuns);
// TODO SERVER-103381 re-enable timeseries PBT testing.
// Test with a TS collection.
// TODO SERVER-83072 re-enable $group in this test, by removing the filter below.
// const tsAggModel = aggModel.filter(query => {
// for (const stage of query) {
// if (Object.keys(stage).includes('$group')) {
// return false;
// }
// }
// return true;
// });
// testProperty(
// correctnessProperty,
// {controlColl, experimentColl},
// makeWorkloadModel(
// {collModel: getCollectionModel({isTS: true}), aggModel: tsAggModel, numQueriesPerRun}),
// numRuns);

View File

@ -0,0 +1,79 @@
/**
* Property-based test that asserts correctness of queries that begin with a $match with a top-level
* $or predicate. This tests subplanning code paths which are significantly different from others.
*
* @tags: [
* query_intensive_pbt,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* requires_timeseries,
* assumes_no_implicit_collection_creation_on_get_collection,
* # Incompatible with setParameter
* does_not_support_stepdowns,
* # Runs queries that may return many results, requiring getmores
* requires_getmore,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {
createCacheCorrectnessProperty
} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {getMatchPredicateSpec} from "jstests/libs/property_test_helpers/models/match_models.js";
import {getAggPipelineModel} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
if (isSlowBuild(db)) {
jsTestLog('Exiting early because debug is on, opt is off, or a sanitizer is enabled.');
quit();
}
const numRuns = 15;
const numQueriesPerRun = 20;
const controlColl = db.subplanning_pbt_control;
const experimentColl = db.subplanning_pbt_experiment;
// Use the cache correctness property, which runs similar query shapes with different constant
// values plugged in. We do this because subplanning can have unique interactions with the plan
// cache.
const correctnessProperty = createCacheCorrectnessProperty(controlColl, experimentColl);
// {$match: {$or: ...}}
const matchWithTopLevelOrArb = getMatchPredicateSpec()
.singleCompoundPredicate
.filter(pred => {
// This filter will pass 1/3rd of the time. Since generating
// queries is quick, this isn't a concern.
return Object.keys(pred).includes('$or');
})
.map(pred => {
return {$match: pred};
});
const aggModel = fc.record({orMatch: matchWithTopLevelOrArb, pipeline: getAggPipelineModel()})
.map(({orMatch, pipeline}) => {
return [orMatch, ...pipeline];
});
// Test with a regular collection.
testProperty(correctnessProperty,
{controlColl, experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun}),
numRuns);
// // TODO SERVER-103381 re-enable PBT testing for time-series
// // Test with a TS collection.
// TODO SERVER-83072 re-enable $group in this test, by removing the filter below.
// const tsAggModel = aggModel.filter(query => {
// for (const stage of query) {
// if (Object.keys(stage).includes('$group')) {
// return false;
// }
// }
// return true;
// });
// testProperty(correctnessProperty,
// {controlColl, experimentColl},
// makeWorkloadModel({collModel: getCollectionModel(), aggModel: tsAggModel,
// numQueriesPerRun}), numRuns);

View File

@ -0,0 +1,93 @@
/**
* A property-based test to assert the correctness of partial indexes. Generates a filter, indexes,
* and queries, then creates partial indexes using the filter and prefixes every query with
* {$match: filter}. This way, every query is eligible to use the indexes, rather than leaving it
* up to chance.
* Queries with similar shapes are run consecutively, to trigger plan cache interactions.
*
* @tags: [
* query_intensive_pbt,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* assumes_no_implicit_collection_creation_after_drop,
* # Incompatible with setParameter
* does_not_support_stepdowns,
* # Change in read concern can slow down queries enough to hit a timeout.
* assumes_read_concern_unchanged,
* does_not_support_causal_consistency,
* # Runs queries that may return many results, requiring getmores
* requires_getmore,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {
createCacheCorrectnessProperty
} from "jstests/libs/property_test_helpers/common_properties.js";
import {getDocsModel} from "jstests/libs/property_test_helpers/models/document_models.js";
import {getIndexModel} from "jstests/libs/property_test_helpers/models/index_models.js";
import {
getPartialFilterPredicateArb
} from "jstests/libs/property_test_helpers/models/match_models.js";
import {getAggPipelineModel} from "jstests/libs/property_test_helpers/models/query_models.js";
import {partialIndexCounterexamples} from "jstests/libs/property_test_helpers/pbt_resolved_bugs.js";
import {
concreteQueryFromFamily,
testProperty
} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
if (isSlowBuild(db)) {
jsTestLog('Exiting early because debug is on, opt is off, or a sanitizer is enabled.');
quit();
}
const numRuns = 100;
const numQueriesPerRun = 20;
const controlColl = db.partial_index_pbt_control;
const experimentColl = db.partial_index_pbt_experiment;
// Use the cache correctness property so we can test interactions between the plan cache and
// partial indexes.
const correctnessProperty = createCacheCorrectnessProperty(controlColl, experimentColl);
const workloadModel =
fc.record({
// This filter will be used for the partial index filter, and to prefix queries with
// {$match: filter} so that every query is eligible to use the partial indexes.
partialFilterPredShape: getPartialFilterPredicateArb(),
docs: getDocsModel(false /* isTS */),
indexes: fc.array(getIndexModel({allowPartialIndexes: false, allowSparse: false}),
{minLength: 0, maxLength: 15, size: '+2'}),
pipelines: fc.array(getAggPipelineModel(),
{minLength: 1, maxLength: numQueriesPerRun, size: '+2'})
}).map(({partialFilterPredShape, docs, indexes, pipelines}) => {
// The predicate model generates a family of predicates of the same shape, with different
// parameter options at the leaf nodes. For all indexes, we use the first predicate from the
// family as the partial filter expression.
const firstPartialFilterPred = concreteQueryFromFamily(partialFilterPredShape, 0);
const partialIndexes = indexes.map(({def, options}) => {
return {
def,
options:
Object.assign({}, options, {partialFilterExpression: firstPartialFilterPred})
};
});
// For queries, we can include the entire predicate family in the $match. When the property
// asks for similar query shapes with different parameters plugged in, the $match will
// behave correctly. In general our queries are modeled as families of shapes, so including
// the predicate family in the $match rather than one specific predicate makes sense.
const match = {$match: partialFilterPredShape};
const queriesWithMatch = pipelines.map(p => [match, ...p]);
return {collSpec: {isTS: false, docs, indexes: partialIndexes}, queries: queriesWithMatch};
});
// TODO SERVER-102825 SERVER-106023 re-enable partial index PBT
// Test with a regular collection.
// testProperty(correctnessProperty,
// {controlColl, experimentColl},
// workloadModel,
// numRuns,
// partialIndexCounterexamples
// );
// TODO SERVER-103381 extend this test to use time-series collections.

View File

@ -0,0 +1,62 @@
/**
* A property-based test that runs similar queries to potentially trigger cache usage, then asserts
* the queries return the same results when deoptimized.
* Auto-parameterization has had issues in the past. This test attempts to target that area.
*
* @tags: [
* query_intensive_pbt,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* requires_timeseries,
* assumes_no_implicit_collection_creation_after_drop,
* # Incompatible with setParameter
* does_not_support_stepdowns,
* # Runs queries that may return many results, requiring getmores
* requires_getmore,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {
createCacheCorrectnessProperty
} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {getAggPipelineModel} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {testProperty} from "jstests/libs/property_test_helpers/property_testing_utils.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 50;
const numQueriesPerRun = 15;
const controlColl = db.cache_correctness_pbt_control;
const experimentColl = db.cache_correctness_pbt_experiment;
const correctnessProperty = createCacheCorrectnessProperty(controlColl, experimentColl);
const aggModel = getAggPipelineModel();
// Test with a regular collection.
testProperty(correctnessProperty,
{controlColl, experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun}),
numRuns);
// TODO SERVER-103381 re-enable timeseries PBT testing.
// Test with a TS collection.
// TODO SERVER-83072 re-enable $group in this test, by removing the filter below.
// const tsAggModel = aggModel.filter(query => {
// for (const stage of query) {
// if (Object.keys(stage).includes('$group')) {
// return false;
// }
// }
// return true;
// });
// testProperty(
// correctnessProperty,
// {controlColl, experimentColl},
// makeWorkloadModel(
// {collModel: getCollectionModel({isTS: true}), aggModel: tsAggModel, numQueriesPerRun}),
// numRuns);

View File

@ -0,0 +1,99 @@
/**
* A property-based test that runs the same query several times to assert that it eventually uses
* the plan cache.
* There have been issues where the key we use to lookup in the plan cache is different from the
* key we use to store the cache entry. This test attempts to target these potential bugs.
*
* @tags: [
* query_intensive_pbt,
* requires_timeseries,
* assumes_standalone_mongod,
* # Plan cache state is node-local and will not get migrated alongside user data
* assumes_balancer_off,
* assumes_no_implicit_collection_creation_after_drop,
* # Need to clear cache between runs.
* does_not_support_stepdowns
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {getRejectedPlans} from "jstests/libs/analyze_plan.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {getAggPipelineModel} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {
getPlanCache,
testProperty
} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {checkSbeFullyEnabled} from "jstests/libs/sbe_util.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 100;
const numQueriesPerRun = 40;
const experimentColl = db[jsTestName()];
// Motivation: Check that the plan cache key we use to lookup in the cache and to store in the cache
// are consistent.
function repeatQueriesUseCache(getQuery, testHelpers) {
for (let queryIx = 0; queryIx < testHelpers.numQueryShapes; queryIx++) {
const query = getQuery(queryIx, 0 /* paramIx */);
const explain = experimentColl.explain().aggregate(query);
// If there are no rejected plans, there is no need to cache.
if (getRejectedPlans(explain).length === 0) {
continue;
}
// Currently, both classic and SBE queries use the classic plan cache.
const serverStatusBefore = db.serverStatus();
const classicHitsBefore = serverStatusBefore.metrics.query.planCache.classic.hits;
const sbeHitsBefore = serverStatusBefore.metrics.query.planCache.sbe.hits;
for (let i = 0; i < 5; i++) {
experimentColl.aggregate(query).toArray();
}
const serverStatusAfter = db.serverStatus();
const classicHitsAfter = serverStatusAfter.metrics.query.planCache.classic.hits;
const sbeHitsAfter = serverStatusAfter.metrics.query.planCache.sbe.hits;
// If neither the SBE plan cache hits nor the classic plan cache hits have incremented, then
// our query must not have hit the cache. We check for at least one hit, since ties can
// prevent a plan from being cached right away.
if (checkSbeFullyEnabled(db) && sbeHitsAfter - sbeHitsBefore > 0) {
continue;
} else if (classicHitsAfter - classicHitsBefore > 0) {
continue;
}
return {
passed: false,
message: 'Plan cache hits failed to increment after running query several times.',
query,
explain,
classicHitsBefore,
classicHitsAfter,
sbeHitsBefore,
sbeHitsAfter,
planCacheState: getPlanCache(experimentColl).list()
};
}
return {passed: true};
}
const aggModel = getAggPipelineModel();
testProperty(
repeatQueriesUseCache,
{experimentColl},
makeWorkloadModel({collModel: getCollectionModel({isTS: false}), aggModel, numQueriesPerRun}),
numRuns);
// TODO SERVER-103381 re-enable timeseries PBT testing.
// testProperty(
// repeatQueriesUseCache,
// {experimentColl},
// makeWorkloadModel({collModel: getCollectionModel({isTS: true}), aggModel, numQueriesPerRun}),
// numRuns);

View File

@ -0,0 +1,125 @@
/**
* A property-based test to hint every index for a query, and assert the same results are returned
* when compared to the deoptimized query.
* Some query plans are rarely chosen because a specific data distribution is required for them to
* be optimal. This makes it difficult to test the correctness of all plans end-to-end, so we make a
* best effort attempt here until SERVER-83234 is complete.
*
* TODO SERVER-83234
* When hinting via QuerySolution hash is available, we'll be able to hint every _plan_, rather
* than hinting every _index_. Currently we miss intersection and union plans among others.
* We should be able to run explain, find all of the QSN hashes to hint, then perform the assertion
* about all plans.
*
* @tags: [
* query_intensive_pbt,
* # This test runs commands that are not allowed with security token: setParameter.
* not_allowed_with_signed_security_token,
* requires_timeseries,
* assumes_no_implicit_collection_creation_on_get_collection,
* # Incompatible with setParameter
* does_not_support_stepdowns,
* # Runs queries that may return many results, requiring getmores
* requires_getmore,
* ]
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {getDifferentlyShapedQueries} from "jstests/libs/property_test_helpers/common_properties.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {getAggPipelineModel} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {
runDeoptimized,
testProperty
} from "jstests/libs/property_test_helpers/property_testing_utils.js";
if (isSlowBuild(db)) {
jsTestLog("Returning early because debug is on, opt is off, or a sanitizer is enabled.");
quit();
}
const numRuns = 50;
const numQueriesPerRun = 10;
const controlColl = db.run_all_plans_control;
const experimentColl = db.run_all_plans_experiment;
function runHintedAgg(query, index) {
try {
return {docs: experimentColl.aggregate(query, {hint: index.name}).toArray()};
} catch (e) {
return {err: e.code};
}
}
function hintedQueryHasSameResultsAsControlCollScan(getQuery, testHelpers) {
const indexes = experimentColl.getIndexes();
const queries = getDifferentlyShapedQueries(getQuery, testHelpers);
// Compute the control results all at once.
const resultMap = runDeoptimized(controlColl, queries);
for (let i = 0; i < queries.length; i++) {
const query = queries[i];
const controlResults = resultMap[i];
for (const index of indexes) {
const res = runHintedAgg(query, index);
assert(res.err || res.docs);
if (res.err && res.err !== ErrorCodes.NoQueryExecutionPlans) {
return {
passed: false,
message: 'Hinting index led to unexpected error.',
query,
error: res.err,
index
};
} else if (res.docs && !testHelpers.comp(controlResults, res.docs)) {
return {
passed: false,
message:
'Query results from hinted experiment collection did not match plain collection using collscan.',
query,
index,
explain: experimentColl.explain().aggregate(query, {hint: index.name}),
controlResults,
docsInCollection: controlColl.find().toArray(),
experimentalResults: res.docs
};
}
}
}
return {passed: true};
}
const aggModel = getAggPipelineModel();
// Test with a regular collection.
testProperty(
hintedQueryHasSameResultsAsControlCollScan,
{controlColl, experimentColl},
// Hinting a partial index can return incorrect results due to SERVER-26413.
// TODO SERVER-26413 re-enable partial index coverage.
makeWorkloadModel(
{collModel: getCollectionModel({allowPartialIndexes: false}), aggModel, numQueriesPerRun}),
numRuns);
// TODO SERVER-103381 re-enable timeseries PBT testing.
// Test with a TS collection.
// {
// // TODO SERVER-83072 re-enable $group in this test, by removing the filter below.
// const tsAggModel = aggModel.filter(query => {
// for (const stage of query) {
// if (Object.keys(stage).includes('$group')) {
// return false;
// }
// }
// return true;
// });
// testProperty(
// hintedQueryHasSameResultsAsControlCollScan,
// {controlColl, experimentColl},
// makeWorkloadModel(
// {collModel: getCollectionModel({isTS: true}), aggModel: tsAggModel,
// numQueriesPerRun}),
// numRuns);
// }

View File

@ -0,0 +1,142 @@
/*
* Tests `_resultSetsEqualUnordered`, which compares two sets of results (order of documents is
* disregarded) for equality. Field order inside an object is ignored, but array ordering and
* everything else is required for equality.
*/
const currentDate = new Date();
// We should throw for invalid input. This function expects both arguments to be a list of objects.
assert.throwsWithCode(() => _resultSetsEqualUnordered({}, []), 9193201);
assert.throwsWithCode(() => _resultSetsEqualUnordered([], 5), 9193201);
assert.throwsWithCode(() => _resultSetsEqualUnordered([4, 1], []), 9193202);
assert.throwsWithCode(() => _resultSetsEqualUnordered([], ["abc"]), 9193203);
assert.throwsWithCode(() => _resultSetsEqualUnordered([[]], [{a: 1}]), 9193202);
assert.throwsWithCode(() => _resultSetsEqualUnordered([], undefined), 9193201);
assert.throwsWithCode(() => _resultSetsEqualUnordered([], null), 9193201);
assert.throwsWithCode(() => _resultSetsEqualUnordered([null], []), 9193202);
// Some base cases.
assert(_resultSetsEqualUnordered([], []));
assert(_resultSetsEqualUnordered([{a: 1}], [{a: 1}]));
assert(_resultSetsEqualUnordered([{a: 1}, {a: 1}], [{a: 1}, {a: 1}]));
assert(_resultSetsEqualUnordered([{a: 1}, {b: 1}], [{b: 1}, {a: 1}]));
assert(_resultSetsEqualUnordered([{a: null}], [{a: null}]));
assert(!_resultSetsEqualUnordered([], [{a: 1}]));
assert(!_resultSetsEqualUnordered([{a: 1}], []));
// Different types should fail the comparison.
assert(!_resultSetsEqualUnordered([{a: 1}], [{a: '1'}]));
assert(!_resultSetsEqualUnordered([{a: 1}], [{a: NumberLong(1)}]));
assert(!_resultSetsEqualUnordered([{a: 1}], [{a: NumberDecimal(1)}]));
assert(!_resultSetsEqualUnordered([{a: NumberInt(1)}], [{a: NumberDecimal(1)}]));
assert(!_resultSetsEqualUnordered([{a: NumberInt(1)}], [{a: NumberLong(1)}]));
assert(!_resultSetsEqualUnordered([{a: null}], [{}]));
assert(!_resultSetsEqualUnordered([{a: null}], [{b: null}]));
assert(!_resultSetsEqualUnordered([{a: null}], [{a: undefined}]));
assert(!_resultSetsEqualUnordered([{}], [{a: undefined}]));
/*
* Given two sets of results - `equalResults` and `differentResults`, we test that all pairs of
* results in `equalResults` are equal to each other. We also test that pairs of one result from
* `equalResults` and one result from `differentResults` are unequal.
*/
function assertExpectedOutputs(equalResults, differentResults) {
for (const result1 of equalResults) {
for (const result2 of equalResults) {
assert(_resultSetsEqualUnordered(result1, result2), {result1, result2});
assert(_resultSetsEqualUnordered(result2, result1), {result1, result2});
}
}
for (const result1 of equalResults) {
for (const result2 of differentResults) {
assert(!_resultSetsEqualUnordered(result1, result2), {result1, result2});
assert(!_resultSetsEqualUnordered(result2, result1), {result1, result2});
}
}
}
function testIndividualDocumentEquality() {
const doc = {
a: 1,
b: [
{x: "a string", y: currentDate, z: NumberDecimal(1)},
{'a1.b1': 5, 'a1.c1': 2, 'a2': [3, 2, 1]}
]
};
const docOutOfOrder = {
b: [
{z: NumberDecimal(1), x: "a string", y: currentDate},
{'a1.b1': 5, 'a2': [3, 2, 1], 'a1.c1': 2}
],
a: 1
};
// We change the order of arrays here - our comparator should return false, because arrays need
// to be ordered.
const docAltered1 = {
a: 1,
b: [
{z: NumberDecimal(1), x: "a string", y: currentDate},
{'a1.b1': 5, 'a1.c1': 2, 'a2': [1, 2, 3]}
]
};
const docAltered2 = {
a: 1,
b: [
{'a1.b1': 5, 'a1.c1': 2, 'a2': [3, 2, 1]},
{z: NumberDecimal(1), x: "a string", y: currentDate}
]
};
// Change a few values, which should also make our comparator return false.
const docAltered3 = {
a: 1,
b: [
{x: "a string", y: currentDate, z: NumberDecimal(2)},
{'a1.b1': 5, 'a1.c1': 2, 'a2': [3, 2, 1]}
]
};
const docAltered4 = {
a: 1,
b: [
{x: "a string", y: currentDate, z: NumberDecimal(1)},
{'a1.b1': 5, 'a1.c1': 2, 'a2': [3, 3, 1]}
]
};
const docAltered5 = {
a: 1,
b: [
{x: "a different string", y: currentDate, z: NumberDecimal(1)},
{'a1.b1': 5, 'a1.c1': 2, 'a2': [3, 2, 1]}
]
};
// Each result contains one document for this case.
const equalDocs = [[doc], [docOutOfOrder]];
const unequalDocs = [[docAltered1], [docAltered2], [docAltered3], [docAltered4], [docAltered5]];
assertExpectedOutputs(equalDocs, unequalDocs);
}
function testResultOrderIndifference() {
const result = [{a: 1}, {a: 1, b: 1}, {a: 1, b: 1}, {b: 1}, {c: 1}];
// Different order of documents.
const resultOutOfOrder = [{b: 1, a: 1}, {c: 1}, {a: 1}, {b: 1}, {a: 1, b: 1}];
// Change the values, or have completely different documents in the result.
const resultAltered1 = [{a: 1, b: 1}, {d: 1}, {a: 1}, {b: 1}, {a: 1, b: 1}];
const resultAltered2 = [{a: 1, b: 2}, {c: 1}, {a: 1}, {b: 1}, {a: 1, b: 1}];
const resultAltered3 = [{a: 1, b: 2}, {c: 1}, {a: 1}, {b: 2}, {a: 1, b: 1}];
const resultAltered4 = [{a: 1, b: 1}, {c: 1}, {a: 1}, {b: 1}, {'a.a': 1, b: 1}];
const resultAltered5 = [{a: 1, b: 1}, {c: 1}, {a: 1}, {b: 1}, {a: '1', b: 1}];
const resultAltered6 = [{a: 1, b: 1}, {c: 1}, {a: 1, b: 1}, {b: 1}, {a: 1, b: 1}];
assertExpectedOutputs([result, resultOutOfOrder], [
resultAltered1,
resultAltered2,
resultAltered3,
resultAltered4,
resultAltered5,
resultAltered6
]);
}
testIndividualDocumentEquality();
testResultOrderIndifference();

View File

@ -1,3 +1,5 @@
import {FixtureHelpers} from "jstests/libs/fixture_helpers.js";
/**
* Executes a test case that inserts documents, issues an aggregate command on a collection
* 'collection' and compares the results with the expected.
@ -30,3 +32,14 @@ export function executeAggregationTestCase(collection, testCase) {
assert.commandFailedWithCode(error, testCase.expectedErrorCode);
}
}
/**
* For tests that run many aggregations, different build settings can affect whether we can finish
* the test before the timeout.
*/
export function isSlowBuild(db) {
const debugBuild = db.adminCommand("buildInfo").debug;
return debugBuild || !_optimizationsEnabled() || _isAddressSanitizerActive() ||
_isLeakSanitizerActive() || _isThreadSanitizerActive() ||
_isUndefinedBehaviorSanitizerActive();
}

View File

@ -503,16 +503,20 @@ export function getPlanStage(root, stage) {
* 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));
if (root.hasOwnProperty('queryPlanner')) {
if (root.queryPlanner.winningPlan.hasOwnProperty("shards")) {
const rejectedPlans = [];
for (let shard of root.queryPlanner.winningPlan.shards) {
for (let rejectedPlan of shard.rejectedPlans) {
rejectedPlans.push(Object.assign({shardName: shard.shardName}, rejectedPlan));
}
}
return rejectedPlans;
}
return rejectedPlans;
return root.queryPlanner.rejectedPlans;
} else {
return root.stages[0]['$cursor'].queryPlanner.rejectedPlans;
}
return root.queryPlanner.rejectedPlans;
}
/**

View File

@ -0,0 +1,13 @@
load("@aspect_rules_js//js:defs.bzl", "js_library")
js_library(
name = "all_javascript_files",
srcs = glob([
"*.js",
]),
target_compatible_with = select({
"//bazel/config:ppc_or_s390x": ["@platforms//:incompatible"],
"//conditions:default": [],
}),
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,213 @@
# Core Property-Based Tests
For a short introduction to property-based testing or fast-check, see [Appendix](#appendix).
## Core PBT Design
The 'Core PBTs' are a subset of our property-based tests that use a shared schema and models. Their purpose is to provide basic coverage of our query language that may not be tested by the rest of our jstests. This means only simple stages such as $project, $match, $sort, etc are covered. More complicated stages such as $lookup or $facet are not tested. PBTs outside of the core set may test these more complex features.
These tests have been highly effective at finding bugs. As of writing they have caught 24 bugs in 8 months. See [SERVER-89308](https://jira.mongodb.org/browse/SERVER-89308) for a full list of issues.
The Core PBT design is built off of a few key principles about randomized testing:
### Properties Dictate the Models
In our fuzzer, we have grammar for most of MQL. While this provides more coverage, it means the property we assert is weaker. We can add as much as we'd like to the model, because the property comes second to the model. We're willing to add exceptions to the property to make it work.
However, the "model dictates the property" design also backfired, because in addition to exceptions in the property, we need to post-process the generated queries. Adding $sort to several places throughout an aggregation pipeline means we are no longer testing MQL, but rather an artificial subset of MQL that a user would never write.
For this reason, the properties come first in our Core PBTs, and have few exceptions. They dictate what model we use so no postprocessing is needed. The PBT models are significantly smaller than the fuzzer models.
### Small Schema
#### Number of Fields
A small number of fields in our schema allows us to find interesting interactions more easily.
An example of an interaction could be query optimizations. Let's say an optimization on `[{$match: {*field*: 5}}, {$sort: {*field*: 1}}]` only kicks in when the two fields are the same. In a PBT where there are one thousand possible fields (`a`, `b`, `c`, but also `a.b.c`, `a.a.a` and all combinations), the probability of finding this optimization is `1/1000`. With six fields, it's increased to `1/6`.
Another interaction is between queries and indexes. Queries and indexes generated from a small schema make the indexes more likely to be used.
Bugs tend to come from interactions and special cases. A query that has no optimizations applied and does not use an index requires much less complicated logic, which is correlated to less bugs.
#### Simple Values to Avoid MQL Inconsistencies
Related to [Properties Dictate the Models](#properties-dictate-the-models), a simpler document model also allows for stronger properties.
There are inconsistencies in our query language that are accepted behavior, but cause issues in property-based testing. We can work around them by being careful about the values we allow in documents.
[SERVER-12869](https://jira.mongodb.org/browse/SERVER-12869) is an issue that stems from null and missing being encoded the same way in our index format. This means a covering plan (a plan with no `FETCH` node) cannot distinguish between null and missing. This inconsistency is the cause of lots of noise from our fuzzer, since one differing value in a query result can propogate. In our Core PBTs, we do not allow missing fields. This means:
- Documents must have all fields in the schema
- We can only index fields in the schema
- Queries can only reference fields in the schema
`null` is allowed.
Floating point values are another area the PBTs avoid. Results can differ depending on the order of floating point operations. These differences can propogate. For this reason the only number values allowed are integers.
## Modeling Workloads
A workload consists of a collection model and an aggregation model, in the following format:
```
{
collSpec: {
isTS: true/false to indicate if the collection should be time-series
docs: a list of documents
indexes: a list of indexes
},
queries: a list of aggregation pipelines,
extraParams: an optional list of extra values to be passed to the property function
}
```
Using one workload model instead of separate (and independent) collection models and agg models allows them to be interrelated.
For example, if we want to model a PBT to test partial indexes where every query should satisfy the partial index filter, we can write:
```
fc.record({
partialFilter: partialFilterPredicateModel,
docs: docsModel,
indexes: indexesModel,
aggs: aggsModel
}).map(({partialFilter, docs, indexes, aggs}) => {
// Append {partialFilterExpression: partialFilter} to all index options
// Prefix every query with {$match: partialFilter}
// Return our workload object.
});
```
and this is a valid workload model. If the collection and aggregation models are passed separately, they would be independent an unable to coordinate with shared arbitraries (like `partialFilter`).
### Schema
The Core PBT schema is:
```
{
_id: a unique integer
t: a date value
m: an object with subfields 'm1' and 'm2'. both are simple scalars
array: an array of scalars, other arrays, or objects. this is the only field that is allowed to be an array.
a: any simple scalar: integer, boolean, string, date, null
b: same as `a`
}
```
For now, this is also a valid model for a document in a time-series collection (where `t` is the time field and `m` is the meta field), but the models may diverge.
### Query Generation
These models cover a limited number of aggregation stages, located in `jstests/libs/property_test_helpers/models`. The supported stages are:
- $project
- $addFields
- $match
- $sort
- $group
- $limit
- $skip
#### Query Families
Rather than generating single, standalone queries, our query model generates a "family" of queries.
At its leaves, a query family contains multiple values that the leaf could take on. For example instead of generating a single query with a concrete value `1` at the leaf:
```
[{$match: {a: 1}}, {$project: {b: 0}}]
```
We generate `1,2,3` as potential values this slot can hold.
```
[{$match: {a: {concreteValues: [1,2,3]}}}, {$project: {b: 0}}]
```
Then we extract several queries that have the same shape.
```
[{$match: {a: 1}}, {$project: {b: 0}}]
[{$match: {a: 2}}, {$project: {b: 0}}]
[{$match: {a: 3}}, {$project: {b: 0}}]
```
This allows us to write properties that use the plan cache more often rather than relying on chance.
Properties can use the `getQuery` interface to ask for queries with different shapes, or the same shape with different leaf values plugged in.
## Core PBTs
`jstests/**/*_pbt.js`
Details are provided at the top of each file.
## Debugging a PBT Failure
Currently, all PBTs have a fixed seed.
This means that as long as the bug it found is deterministic on the server's side, the PBT will consistently run into the issue.
If the bug is not deterministic, the PBT may or may not fail.
### Shrinking (Minimizing)
Once a counterexample (a failing case) to the property is found, fast-check tests will automatically attempt to shrink the issue.
Shrinking often does not reach the global minimum counterexample, since fast-check cannot make certain jumps.
For example it has no way of knowing that
`{$and: [{a: {$eq: 1}}]}`
can usually be turned into
`{a: {$eq: 1}}`
or even
`{a: 1}`
This could be solved if fast-check had domain-specific knowledge about MQL or if it fuzzed counterexamples during shrinking.
However the counterexamples are usually small enough where there isn't much left to shrink.
For non-deterministic issues, fast-check's shrinking is not as effective because it receives mixed signals from the property on whether the shrunk counterexamples fail or not.
### Failure Output
After a failure is minimized, the counterexample is printed out.
This includes debug data such as the counterexample that fast-check found and the error it ran into.
The counterexample will be a workload (see [Modeling Workloads](#modeling-workloads)), containing all information about the collection and queries run against it.
To reproduce the issue, the workload can be copied and pasted into the failing property-based test, specifically by passing it in as the `examples` argument to `testProperty`.
fast-check will take these hand-written examples and run them before trying randomized examples.
See `partial_index_pbt.js` (which references `pbt_resolved_bugs.js`) for an example of this.
`partial_index_pbt.js` uses the `examples` argument to ensure workloads that previously would fail are run.
It can be used in the same way to repro existing bugs from BFs.
# Appendix
## Property-Based Testing (PBT)
Property-based testing is a testing method that asserts properties hold over many example inputs. In our use of PBT, it involves two components, a "model" and a "property function". The model is a description of the object we are testing. It is used to generate examples of what the object looks like. These examples are routed into the property function, which asserts that the object has the characteristics we expect them to have.
Let's say we wrote a new integer addition function `add` that we'd like to test. We could calculate the correct answer to different addition problems, and assert that `add` behaves correctly.
```
assert.eq(add(1, 2), 3);
assert.eq(add(-1, 1), 0);
...
```
In addition to tests written with concrete values, we could also write a PBT to test for characteristics we expect `add` to have. Addition is commutative for example, meaning `add(a, b)` should always equal `add(b, a)`. We can write a function for this:
```
function testAdd(a, b){
assert.eq(add(a, b), add(b, a));
}
```
The input to `testAdd` could use the builtin Javascript `Random` package, or a PBT library such as fast-check.
The way the query team uses PBT tends to be more complex, and almost always involves modeling a subset of our query language, documents, and indexes. Our fuzzer is a form of property-based testing, since we generate random queries and assert correctness against different controls (an older mongo version, a collection without indexes, etc)
## fast-check
fast-check (located in jstests/third_party/fast_check/fc-3.1.0.js) is a property-based testing framework for javascript/typescript. It provides building-block components to use for larger models, and has functionality to test properties against these models. It also has built-in logic for shrinking (minimizing) counterexamples to properties.
For an example of how to use fast-check to write a property-based test, see [project_coalescing.js](../../aggregation/sources/project/project_coalescing.js)

View File

@ -0,0 +1,119 @@
/*
* Common properties our property-based tests may use. Intended to be paired with the `testProperty`
* interface in property_testing_utils.js.
*/
import {runDeoptimized} from "jstests/libs/property_test_helpers/property_testing_utils.js";
// Returns different query shapes using the first parameters plugged in.
export function getDifferentlyShapedQueries(getQuery, testHelpers) {
const queries = [];
for (let queryIx = 0; queryIx < testHelpers.numQueryShapes; queryIx++) {
queries.push(getQuery(queryIx, 0 /* paramIx */));
}
return queries;
}
// Using the given shapeIx, returns all variations of that shape with different parameters plugged
// in.
function getAllVariationsOfQueryShape(shapeIx, getQuery, testHelpers) {
const queries = [];
for (let paramIx = 0; paramIx < testHelpers.leafParametersPerFamily; paramIx++) {
queries.push(getQuery(shapeIx, paramIx));
}
return queries;
}
/*
* Runs one of each query shape with the first parameters plugged in, comparing the experiment
* results to the control results.
* The `statsCollectorFn`, if provided, is run on the explain of each query on the experiment
* collection.
*/
export function createCorrectnessProperty(controlColl, experimentColl, statsCollectorFn) {
return function queryHasSameResultsAsControlCollScan(getQuery, testHelpers) {
const queries = getDifferentlyShapedQueries(getQuery, testHelpers);
// Compute the control results all at once.
const resultMap = runDeoptimized(controlColl, queries);
for (let i = 0; i < queries.length; i++) {
const query = queries[i];
const controlResults = resultMap[i];
const experimentResults = experimentColl.aggregate(query).toArray();
if (statsCollectorFn) {
statsCollectorFn(experimentColl.explain().aggregate(query));
}
if (!testHelpers.comp(controlResults, experimentResults)) {
return {
passed: false,
message:
'Query results from experiment collection did not match plain collection using collscan.',
query,
explain: experimentColl.explain().aggregate(query),
controlResults,
experimentResults
};
}
}
return {passed: true};
};
}
/*
* Runs similar query shapes with different parameters plugged in to trigger the plan cache, and
* compares to control results.
* The `statsCollectorFn`, if provided, is run on the explain of each query on the experiment
* collection.
*/
export function createCacheCorrectnessProperty(controlColl, experimentColl, statsCollectorFn) {
return function queriesUsingCacheHaveSameResultsAsControl(getQuery, testHelpers) {
// The first query we have available for each query shape.
const firstQueryOfEachShape = [];
// The rest of the queries we have available for each shape.
const remainingQueries = [];
for (let shapeIx = 0; shapeIx < testHelpers.numQueryShapes; shapeIx++) {
const variations = getAllVariationsOfQueryShape(shapeIx, getQuery, testHelpers);
firstQueryOfEachShape.push(variations[0]);
remainingQueries.push(...variations.slice(1));
}
// Compute the control results all at once.
const resultMap = runDeoptimized(controlColl, remainingQueries);
// Run the first of each shape three times to get them cached.
firstQueryOfEachShape.forEach(query => {
for (let i = 0; i < 3; i++) {
experimentColl.aggregate(query).toArray();
}
});
// Check that remaining queries, with different parameters, have correct results. These
// queries won't always use the cached plan because we don't model our
// autoparameterization rules exactly, but that's okay.
for (let i = 0; i < remainingQueries.length; i++) {
const query = remainingQueries[i];
const controlResults = resultMap[i];
const experimentResults = experimentColl.aggregate(query).toArray();
if (statsCollectorFn) {
statsCollectorFn(experimentColl.explain().aggregate(query));
}
if (!testHelpers.comp(controlResults, experimentResults)) {
return {
passed: false,
message: 'A query potentially using the plan cache has incorrect results. ' +
'The query that created the cache entry likely has different parameters.',
query,
explain: experimentColl.explain().aggregate(query),
controlResults,
experimentResults
};
}
}
return {passed: true};
};
}

View File

@ -0,0 +1,13 @@
load("@aspect_rules_js//js:defs.bzl", "js_library")
js_library(
name = "all_javascript_files",
srcs = glob([
"*.js",
]),
target_compatible_with = select({
"//bazel/config:ppc_or_s390x": ["@platforms//:incompatible"],
"//conditions:default": [],
}),
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,66 @@
/*
* Rudimentary models for our core property tests.
*/
import {oneof} from "jstests/libs/property_test_helpers/models/model_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
/*
* In these arbitraries, we use stratified sampling to increase the likelihood that interesting
* cases are found.
* For example, integers has interesting cases at the minimimum, -1, 0, 1, and maximum. Stratifying
* the small range [-1, 1] also encourages the model to create filters that match documents.
* Generating {$match: {a: 1}} against document {a: 1} is more likely.
*/
const kInt32Min = -2147483648;
const kInt32Max = +2147483647;
export const intArb = oneof(
fc.integer({min: -1, max: +1}), // tiny
fc.integer({min: -20, max: +20}), // smallish
fc.integer({min: kInt32Min, max: kInt32Max}), // full range
fc.constantFrom(kInt32Min, kInt32Max) // interesting corner cases
)
.map(i => NumberInt(i));
/*
* Stratify with regular characters, unicode, ascii, and null byte. Null byte is a special case
* because it can indicate the end of a string in string implementations, so it may need special
* logic.
*/
const nullByte =
fc.constantFrom('\0', '\x00', '\x01', '\x02', '\x03', '\x08', '\x18', '\x28', '\xff');
const charArb = oneof(fc.base64(), fc.unicode(), fc.ascii(), nullByte).filter(c => c !== '$');
const stringArb = fc.stringOf(charArb, {maxLength: 3});
// ValidateCollections fails if a partial index with a filter involving Date(year=0) exists. This
// year=0 behavior is accepted as a part of the PyMongo BSON library. To avoid false positives with
// the ValidateCollections hook, we make the minimum date year=1
const kMinDate = ISODate("0001-01-01T00:00:00Z");
const kMaxDate = ISODate("9999-12-31T23:59:59.999Z");
export const dateArb = oneof(
fc.date({min: new Date(-1), max: new Date(1)}), // tiny
fc.date({min: new Date(-100), max: new Date(100)}), // smallish
fc.date({min: kMinDate, max: kMaxDate}), // full range
fc.constantFrom(kMinDate, kMaxDate) // interesting corner cases
);
// .oneof() arguments are ordered from least complex to most, since fast-check uses this ordering to
// shrink.
export const scalarArb = oneof(intArb, fc.boolean(), stringArb, dateArb, fc.constant(null));
export const fieldArb = fc.constantFrom('a', 'b', 't', 'm', '_id', 'm.m1', 'm.m2', 'array');
export const dollarFieldArb = fieldArb.map(f => "$" + f);
export const assignableFieldArb = fc.constantFrom('a', 'b', 't', 'm');
export const leafParametersPerFamily = 10;
export class LeafParameter {
constructor(concreteValues) {
this.concreteValues = concreteValues;
}
}
export const leafParameterArb =
fc.array(scalarArb, {minLength: 1, maxLength: leafParametersPerFamily}).map((constants) => {
// In the leaves of the query family, we generate an object with a list of constants to
// place.
return new LeafParameter(constants);
});

View File

@ -0,0 +1,18 @@
/*
* Fast-check models for collections.
* See property_test_helpers/README.md for more detail on the design.
*/
import {getDocsModel} from "jstests/libs/property_test_helpers/models/document_models.js";
import {
getIndexModel,
getTimeSeriesIndexModel
} from "jstests/libs/property_test_helpers/models/index_models.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
export function getCollectionModel({isTS = false, allowPartialIndexes = false} = {}) {
const indexModel = isTS ? getTimeSeriesIndexModel({allowPartialIndexes})
: getIndexModel({allowPartialIndexes});
const indexesModel = fc.array(indexModel, {minLength: 0, maxLength: 15, size: '+2'});
return fc.record({isTS: fc.constant(isTS), docs: getDocsModel(isTS), indexes: indexesModel});
}

View File

@ -0,0 +1,71 @@
/*
* Fast-check document model for our core property tests.
*
* The schema is intended to work for time-series and regular collections.
*
* As part of the core PBT design, we intentionally support a narrow set of basic MQL functionality,
* which allows us to make stronger assertions than a robust fuzzer might. For documents, this
* means:
* - We allow null fields but not missing fields to avoid SERVER-12869, where covering plans
* cannot distinguish between null and missing.
* - We also only allow a minimal set of types in test data. For example functions as values in
* documents are not allowed, nor is undefined, NaN, Symbols, etc.
* Types we allow are integers, booleans, dates, strings, null, and limited depth objects and
* arrays.
* See property_test_helpers/README.md for more detail on the design.
*/
import {
dateArb,
intArb,
scalarArb
} from "jstests/libs/property_test_helpers/models/basic_models.js";
import {oneof} from "jstests/libs/property_test_helpers/models/model_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
const mFieldModel = fc.record({m1: scalarArb, m2: scalarArb});
const arrayFieldElementArb = oneof(scalarArb, fc.array(scalarArb, {maxLength: 2}), mFieldModel);
const arrayFieldModel = fc.array(arrayFieldElementArb, {maxLength: 5});
const defaultDocModel = fc.record({
_id: intArb,
t: dateArb,
m: mFieldModel,
array: oneof(scalarArb, arrayFieldModel),
a: scalarArb,
b: scalarArb
});
// `defaultDocModel` and `timeseriesDocModel` may diverge later. By exporting two models, we make it
// clear these models are separate so existing tests don't rely on behavior specific to `docModel`.
const timeseriesDocModel = defaultDocModel;
// Maximum number of documents that our collection model can generate.
const kMaxNumDocs = 250;
// An array of [0...249] to label our documents with.
const docIds = [];
for (let i = 0; i < kMaxNumDocs; i++) {
docIds.push(i);
}
const uniqueIdsArb = fc.shuffledSubarray(docIds, {minLength: kMaxNumDocs, maxLength: kMaxNumDocs});
export function getDocsModel(isTS) {
const docModel = isTS ? timeseriesDocModel : defaultDocModel;
// The size=+2 argument tells fc.array to generate array sizes closer to the max than the min.
// This way the average number of documents produced is >100, which means our queries will be
// less likely to produce empty results. The size argument does not affect minimization. On
// failure, fast-check will still minimize down to 1 document if possible.
// These docs are 'unlabeled' because we have not assigned them unique _ids yet.
const unlabeledDocsModel =
fc.array(docModel, {minLength: 1, maxLength: kMaxNumDocs, size: '+2'});
// Now label the docs with unique _ids.
return fc.record({unlabeledDocs: unlabeledDocsModel, _ids: uniqueIdsArb})
.map(({unlabeledDocs, _ids}) => {
// We can run into issues with fast-check if we mutate generated values.
// We'll make new docs and add _id to it.
return unlabeledDocs.map((oldDoc, ix) => {
// Make sure our unique _id overwrites the original doc _id, by
// putting it last in the list.
return Object.assign({}, oldDoc, {_id: _ids[ix]});
});
});
}

View File

@ -0,0 +1,64 @@
/*
* $group models for our core property tests.
*
* Note that $avg cannot be supported because it can cause floating point differences in results.
*/
import {
assignableFieldArb,
dollarFieldArb
} from "jstests/libs/property_test_helpers/models/basic_models.js";
import {oneof} from "jstests/libs/property_test_helpers/models/model_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
// These all model accumulated fields, which is the output field and the accumulator. An example
// is `a: {count: {}}` which can by used in a $group.
const countAccArb = assignableFieldArb.map(out => {
return {[out]: {$count: {}}};
});
// $sum. Example is `a: {$sum: '$b'}`.
const sumAccArb =
fc.record({input: dollarFieldArb, output: assignableFieldArb}).map(({input, output}) => {
return {[output]: {$sum: input}};
});
// Examples are `a: {$min: '$b'}` and `a: {$min: {input: b, n: 2}}`.
const minMaxAccArb = fc.record({
acc: fc.constantFrom('$min', '$max'),
input: dollarFieldArb,
output: assignableFieldArb,
n: fc.option(fc.integer({min: 1, max: 3}))
}).map(({acc, input, output, n}) => {
let accSpec;
if (n) {
// $min becomes $minN, $max becomes $maxN.
const accN = acc + 'N';
accSpec = {[accN]: {input, n}};
} else {
accSpec = {[acc]: input};
}
return {[output]: accSpec};
});
// The accumulators we support are $count, $sum, and $min/$max. Accumulators such as $top and
// $bottom run into issues with ties in the sort order. It's impossible to incorporate those into
// the core PBTs, so we'll have to write a dedicated standalone PBT for them.
const accumulatedFieldArb = oneof(countAccArb, sumAccArb, minMaxAccArb);
// A groupby key could be a single field `$a` or an object, `{a: '$b', c: '$d'}`
const objectGbKeyArb = fc.dictionary(assignableFieldArb, dollarFieldArb, {minKeys: 1, maxKeys: 3});
const groupByKeyArb = oneof(
dollarFieldArb,
// TODO SERVER-102229, re-enable object group keys once issue is fixed.
// objectGbKeyArb
);
export const groupArb =
fc.record({
gbField: fc.option(groupByKeyArb),
accumulatedFields: fc.array(accumulatedFieldArb, {minLength: 0, maxLength: 3})
}).map(({gbField, accumulatedFields}) => {
// Merge the groupby key and all accumulated fields.
const groupSpec = Object.assign({_id: gbField}, ...accumulatedFields);
return {$group: groupSpec};
});

View File

@ -0,0 +1,224 @@
/*
* Fast-check models for indexes.
* See property_test_helpers/README.md for more detail on the design.
*/
import {fieldArb, scalarArb} from "jstests/libs/property_test_helpers/models/basic_models.js";
import {
getPartialFilterPredicateArb
} from "jstests/libs/property_test_helpers/models/match_models.js";
import {oneof} from "jstests/libs/property_test_helpers/models/model_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
/*
* Takes an object, a position in that object, and a new key/value. Returns a new object where the
* key/val at the specified position is replaced with the new key/val. If no new key is provided,
* the existing key is kept. If no new value is provided, the existing value is kept.
*
* Example: {a: 1, b: 1, c: 1}, ix=1, newKey="b2"
* Output: {a: 1, b2: 1, c: 1}
*
* Example: {a: 1, b: 1, c: 1}, ix=2, newValue=2
* Output: {a: 1, b: 1, c: 2}
*
* Example: {a: 1, b: 1, c: 1}, ix=0, newKey="a2", newValue=0
* Output: {a2: 0, b: 1, c: 1}
*/
function replaceKeyValAtPosition(obj, ix, {newKey, newVal}) {
const keys = Object.keys(obj);
const values = Object.values(obj);
assert(0 <= ix && ix < keys.length);
assert(newKey || newVal);
if (!newKey) {
newKey = keys[ix];
}
if (!newVal) {
newVal = values[ix];
}
const newObj = {};
for (let i = 0; i < keys.length; i++) {
if (i === ix) {
newObj[newKey] = newVal;
} else {
newObj[keys[i]] = values[i];
}
}
return newObj;
}
// Regular indexes
// Tuple of indexed field, and it's sort direction.
const singleIndexDefArb = fc.record({field: fieldArb, dir: fc.constantFrom(1, -1)});
// Unique array of [[a, true], [b, false], ...] to be mapped to an index definition. Unique on the
// indexed field. Filter out any indexes that only use the _id field.
const arrayOfSingleIndexDefsArb = fc.uniqueArray(singleIndexDefArb, {
minLength: 1,
maxLength: 5,
selector: fieldAndSort => fieldAndSort.field,
}).filter(arrayOfIndexDefs => {
// We can run into errors if we try to make an {_id: -1} index.
if (arrayOfIndexDefs.length === 1 && arrayOfIndexDefs[0].field === '_id') {
return false;
}
return true;
});
const simpleIndexDefArb = arrayOfSingleIndexDefsArb.map(arrayOfIndexDefs => {
// Convert to a valid index definition structure.
const fullDef = {};
for (const {field, dir} of arrayOfIndexDefs) {
fullDef[field] = dir;
}
return fullDef;
});
const emptyOptionsArb = fc.constant({});
// Generates an index option for partial filters. Since predicate models by default have a list of
// parameters at their leaves, we specify we want a single scalar at the leaves.
const partialFilterOptionArb = getPartialFilterPredicateArb({leafArb: scalarArb}).map(pred => {
return {partialFilterExpression: pred};
});
/*
* A b-tree index model.
*/
function getSimpleIndexModel({allowPartialIndexes, allowSparse}) {
const options = [emptyOptionsArb];
if (allowSparse) {
options.push(fc.constant({sparse: true}));
}
if (allowPartialIndexes) {
options.push(partialFilterOptionArb);
}
return fc.record({def: simpleIndexDefArb, options: oneof(...options)});
}
/*
* Hashed indexes
* Generate a simple index definition, an position into that definition, and replace the value at
* that position with the value 'hashed'
*/
const hashedIndexDefArb =
fc.record({indexDef: simpleIndexDefArb, hashedIx: fc.integer({min: 0, max: 4 /* Inclusive */})})
.map(({indexDef, hashedIx}) => {
hashedIx %= Object.keys(indexDef).length;
return replaceKeyValAtPosition(indexDef, hashedIx, {newVal: 'hashed'});
})
.filter(fullDef => {
// Can't create hashed index on array field.
return !Object.keys(fullDef).includes('array');
});
function getHashedIndexModel(allowPartialIndexes) {
const optionsArb =
allowPartialIndexes ? oneof(emptyOptionsArb, partialFilterOptionArb) : emptyOptionsArb;
return fc.record({def: hashedIndexDefArb, options: optionsArb});
}
// This models wildcard indexes where the wildcard field is at the top-level, like "$**" rather than
// "a.$**". These definitions are allowed to specify a `wildcardProjection` in the index options.
const wildcardProjectionOptionsArb = fc.record({
wildcardProjection: fc.uniqueArray(fieldArb, {minLength: 1, maxLength: 8}).map(fields => {
const options = {};
for (const field of fields) {
options[field] = 1;
}
return options;
})
});
/*
* Generate a simple index definition, a position into that definition, and replace the key at the
* position with '$**'.
*/
const fullWildcardDefArb = fc.record({
indexDef: simpleIndexDefArb,
wcIx: fc.integer({min: 0, max: 4})
}).map(({indexDef, wcIx}) => {
wcIx %= Object.keys(indexDef).length;
return replaceKeyValAtPosition(indexDef, wcIx, {newKey: '$**'});
});
/*
* Models a wildcard index where the wildcard field is not at the top-level. So for example "a.$**".
* Generate a simple index definition, a position into that definition, a field, and replace the key
* at the position with `field + '.$**'`.
*/
const dottedWildcardDefArb = fc.record({
indexDef: simpleIndexDefArb,
fieldPrefix: fieldArb,
wcIx: fc.integer({min: 0, max: 4})
}).map(({indexDef, fieldPrefix, wcIx}) => {
wcIx %= Object.keys(indexDef).length;
const wcFieldName = fieldPrefix + '.$**';
return replaceKeyValAtPosition(indexDef, wcIx, {newKey: wcFieldName});
});
function isMultikey(indexDef) {
for (const field of Object.keys(indexDef)) {
if (field === 'array') {
return true;
}
}
return false;
}
/*
* A wildcard index can be at the top-level (fullWildcardDef) or on a field (dottedWildcardDef).
*/
function getWildCardIndexModel(allowPartialIndexes) {
// Full wildcard options contain a wildcard projection, and possibly a partial filter if partial
// indexes are allowed.
let fullWcOptionsArb;
if (allowPartialIndexes) {
fullWcOptionsArb = fc.record({
wcOptions: wildcardProjectionOptionsArb,
partialFilterOptions: fc.option(partialFilterOptionArb, {nil: {}})
}).map(({wcOptions, partialFilterOptions}) => {
return Object.assign({}, wcOptions, partialFilterOptions);
});
} else {
fullWcOptionsArb = wildcardProjectionOptionsArb;
}
const fullWcModel = fc.record({def: fullWildcardDefArb, options: fullWcOptionsArb});
// Dotted wildcard indexes don't allow wildcard projection options, but can be partial if that's
// allowed.
const dottedWcOptionsArb =
allowPartialIndexes ? oneof(emptyOptionsArb, partialFilterOptionArb) : emptyOptionsArb;
const dottedWcModel = fc.record({def: dottedWildcardDefArb, options: dottedWcOptionsArb});
return oneof(fullWcModel, dottedWcModel).filter(({def, options}) => {
// Wildcard indexes are not allowed to be multikey.
return !isMultikey(def);
});
}
/*
* `getIndexModel` and `getTimeSeriesIndexModel` return index models for their respective collection
* types. Takes arguments to allow or disallow partial indexes and sparse indexes.
*
* Partial indexes are disabled by default because they tend to lead to indexes not being used. It's
* unlikely for a query to begin with a $match that is a subset of a partial index filter just by
* coincidence. With partial indexes being generated, we're more likely to not find an index to use
* and fall back to a collscan which is a less interesting case to test.
*
* Wildcard, hashed, sparse, and multikey indexes are not compatible with time-series collections.
*/
export function getIndexModel({allowPartialIndexes = false, allowSparse = true} = {}) {
return oneof(
getSimpleIndexModel({allowPartialIndexes, allowSparse}),
getWildCardIndexModel(allowPartialIndexes),
// TODO SERVER-99889 re-enable PBT hashed index testing
// getHashedIndexModel(allowPartialIndexes)
);
}
export function getTimeSeriesIndexModel({allowPartialIndexes = false} = {}) {
// TODO SERVER-102738 support more time-series index types.
const simpleIndexModel = getSimpleIndexModel({allowPartialIndexes, allowSparse: false});
return simpleIndexModel.filter(({def, options}) => {
// Filter out multikey indexes.
return !isMultikey(def);
});
}

View File

@ -0,0 +1,169 @@
/*
* Fast-check models for $match.
*/
import {
fieldArb,
leafParameterArb
} from "jstests/libs/property_test_helpers/models/basic_models.js";
import {oneof, singleKeyObjArb} from "jstests/libs/property_test_helpers/models/model_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
const simpleComparators = ['$eq', '$ne', '$lt', '$lte', '$gt', '$gte'];
function makeSimpleConditionArb(leafArb, allowedSimpleComparisons) {
// The simplest conditions use comparators like $eq, $gt, $lte. The keys are comparators and the
// values are the specified leaf arbitraries. `numConditions` specifies how many conditions to
// have in the arbitrary.
// For example with numConditions=2, we could generate `{$gt: 5, $lte: 10}`
const makeSimpleConditionHelper = function(numConditions) {
return fc.dictionary(fc.constantFrom(...allowedSimpleComparisons),
leafArb,
{minKeys: numConditions, maxKeys: numConditions});
};
// Weigh arbitraries with less conditions higher. An arbitrary with one condition is the most
// common, with two is less common, three is rare.
// Three conditions is likely to always be false or have one condition be redundant, like
// `{$gt: 5, $lte: 10, $gte: 6}`. But we include it for completeness.
return oneof({arbitrary: makeSimpleConditionHelper(1 /* numConditions */), weight: 10},
{arbitrary: makeSimpleConditionHelper(2 /* numConditions */), weight: 5},
{arbitrary: makeSimpleConditionHelper(3 /* numConditions */), weight: 1});
}
/*
* In this file, `condition` refers to a comparison that could be made against a field, but does
* not include the field itself. `predicate` refers to the field and the comparison together.
*
* For example {a: {$lt: 5}} is a predicate, while {$lt: 5} is a condition.
*
* This helps us clearly define what each arbitrary is modeling.
*/
function getLeafConditionArb(
{leafArb, allowedSimpleComparisons, allowedExistsArgs, allowIn, allowNin}) {
const leafConditionArbs = [makeSimpleConditionArb(leafArb, allowedSimpleComparisons)];
if (allowedExistsArgs.length > 0) {
const existsConditionArb = fc.record({$exists: fc.constantFrom(...allowedExistsArgs)});
leafConditionArbs.push(existsConditionArb);
}
if (allowIn) {
const inConditionArb = fc.record({$in: fc.array(leafArb, {maxLength: 3})});
leafConditionArbs.push(inConditionArb);
}
if (allowNin) {
const ninConditionArb = fc.record({$nin: fc.array(leafArb, {maxLength: 3})});
leafConditionArbs.push(ninConditionArb);
}
return oneof(...leafConditionArbs);
}
// A configurable predicate model. Provides fine-grained control of which operators are allowed in
// the predicate.
export function getMatchPredicateSpec({
// Specifies the arbitrary to place at the leaf of comparisons. For example, for
// {a: {$eq: _}}
// We could place a constant scalar, or a list of scalars (the default) to parameterize the
// predicate shape, forming a `query family` (defined in the README)
leafArb = leafParameterArb,
maxDepth = 5,
allowOrs = true,
allowNors = true,
allowNot = true,
// Leaf comparison types, like $eq, $ne, $gt, etc.
allowedSimpleComparisons = simpleComparators,
allowIn = true,
allowNin = true,
// $exists is disallowed if this set is empty.
allowedExistsArgs = [true, false]
} = {}) {
const compoundOps = ['$and'];
if (allowOrs) {
compoundOps.push('$or');
}
if (allowNors) {
compoundOps.push('$nor');
}
const leafConditionArb = getLeafConditionArb(
{leafArb, allowedSimpleComparisons, allowedExistsArgs, allowIn, allowNin});
// For recursive arbitraries, the `tie` function is how we refer to other arbitraries involved
// in the recursion. We can't directly refer to them, since they're not created yet.
return fc.letrec(tie => {
const allowedConditions = [
leafConditionArb,
// TODO SERVER-101007
// TODO SERVER-101260
// After these tickets are complete, re-enable $elemMatch.
// tie('elemMatch')
];
if (allowNot) {
allowedConditions.push(tie('not'));
}
return {
// The expression under an $elemMatch can specify a field to compare to, or could
// only be a condition:
// {$elemMatch: {$gt: 5}} # Look for an array element greater than 5
// {$elemMatch: {a: {$gt: 5}}} # Look for an object in the array with field a > 5
// They must be the same type, all conditions or all predicates.
elemMatch: oneof(fc.array(tie('condition'), {minLength: 1, maxLength: 3}),
fc.array(tie('predicate'), {minLength: 1, maxLength: 3}))
.map(children => {
const joinedPredicates = Object.assign({}, ...children);
return {$elemMatch: joinedPredicates};
}),
not: fc.record({$not: tie('condition')}),
condition: oneof(...allowedConditions),
// $and/$or/$nor with child predicates.
// Example: {$and: [{a: 1, b: 1}, {$or: [...]}]}
singleCompoundPredicate:
singleKeyObjArb(fc.constantFrom(...compoundOps),
fc.array(tie('predicate'), {minLength: 1, maxLength: 3})),
// Example: {a: {$eq: 5}}
singleSimplePredicate: singleKeyObjArb(fieldArb, tie('condition')),
// A single predicate is a simple predicate, or a compound predicate.
singlePredicate: fc.oneof({withCrossShrink: true, maxDepth},
tie('singleSimplePredicate'),
tie('singleCompoundPredicate')),
// For a full predicate model, we merge up to three single predicates.
// Example: {a: {$eq: 1}, b: {$or: [...]}}
predicate: fc.array(tie('singlePredicate'), {minLength: 1, maxLength: 3}).map(preds => {
return Object.assign({}, ...preds);
})
};
});
}
/*
* Arbitrary $match expression that may contain simple comparisons, nested comparisons, $elemMatch,
* and $not.
* $or, $nor, $in and $nin are only allowed if `allowOrTypes` is true.
*/
export function getMatchArb(allowOrTypes = true) {
const predicateArb = getMatchPredicateSpec({
leafArb: leafParameterArb,
allowOrs: allowOrTypes,
allowNors: allowOrTypes,
allowIn: allowOrTypes,
allowNin: allowOrTypes
}).predicate;
return fc.record({$match: predicateArb});
}
// Partial filter expressions are allowed a limited depth, and the operators listed here:
// https://www.mongodb.com/docs/manual/core/index-partial/#create-a-partial-index
export function getPartialFilterPredicateArb({leafArb = leafParameterArb} = {}) {
return getMatchPredicateSpec({
leafArb,
maxDepth: 2,
allowOrs: true,
allowNors: false,
allowNot: false,
// $ne not allowed
allowedSimpleComparisons: simpleComparators.filter(c => c !== '$ne'),
allowIn: true,
allowNin: false,
allowedExistsArgs: [true]
})
.predicate;
}

View File

@ -0,0 +1,16 @@
/*
* Helper functions for our core property test models.
*/
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
// fc.oneof with better shrinking enabled. Should be used by default.
// If additional options need to be passed, use `fc.oneof`.
export const oneof = function(...arbs) {
return fc.oneof({withCrossShrink: true}, ...arbs);
};
// Creates an arbitrary that generates objects with one key/val, using the given key arbitrary and
// value arbitrary.
export const singleKeyObjArb = function(keyArb, valueArb) {
return fc.record({key: keyArb, value: valueArb}).map(({key, value}) => ({[key]: value}));
};

View File

@ -0,0 +1,128 @@
/*
* Fast-check models for aggregation pipelines for our core property tests.
*
* For our agg model, we generate query shapes with a list of concrete values the parameters could
* take on at the leaves. We call this a "query family". This way, our properties have access to
* many varying query shapes, but also variations of the same query shape.
*
* See property_test_helpers/README.md for more detail on the design.
*/
import {
assignableFieldArb,
dollarFieldArb,
fieldArb,
leafParameterArb
} from "jstests/libs/property_test_helpers/models/basic_models.js";
import {groupArb} from "jstests/libs/property_test_helpers/models/group_models.js";
import {getMatchArb} from "jstests/libs/property_test_helpers/models/match_models.js";
import {oneof} from "jstests/libs/property_test_helpers/models/model_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
// Inclusion/Exclusion projections. {$project: {_id: 1, a: 0}}
export function getSingleFieldProjectArb(isInclusion, {simpleFieldsOnly = false} = {}) {
const projectedFieldArb = simpleFieldsOnly ? assignableFieldArb : fieldArb;
return fc.record({field: projectedFieldArb, includeId: fc.boolean()})
.map(function({field, includeId}) {
const includeIdVal = includeId ? 1 : 0;
const includeFieldVal = isInclusion ? 1 : 0;
return {$project: {_id: includeIdVal, [field]: includeFieldVal}};
});
}
export const simpleProjectArb = oneof(getSingleFieldProjectArb(true /*isInclusion*/),
getSingleFieldProjectArb(false /*isInclusion*/));
// Project from one field to another. {$project {a: '$b'}}
export const computedProjectArb =
fc.tuple(fieldArb, dollarFieldArb).map(function([destField, srcField]) {
return {$project: {[destField]: srcField}};
});
// Add field with a constant argument. {$addFields: {a: 5}}
export const addFieldsConstArb =
fc.tuple(fieldArb, leafParameterArb).map(function([destField, leafParams]) {
return {$addFields: {[destField]: leafParams}};
});
// Add field from source field. {$addFields: {a: '$b'}}
export const addFieldsVarArb =
fc.tuple(fieldArb, dollarFieldArb).map(function([destField, sourceField]) {
return {$addFields: {[destField]: sourceField}};
});
/*
* Generates a random $sort, with [1, maxNumSortComponents] sort components.
*
* `maxNumSortComponents` defaults to 1, because combining $sort on multiple fields with other
* aggregation stages can lead to parallel key errors. For example
* [{$addFields: {a: '$array'}}, {$sort: {a: 1, array: 1}}]
* attempts to sort on two array fields. This is not allowed in MQL.
*
* If the caller has guarantees about what stages will precede the $sort and can avoid parallel key
* issues, they may set `maxNumSortComponents` to something greater than 1.
*/
export function getSortArb(maxNumSortComponents = 1) {
const sortDirectionArb = fc.constantFrom(1, -1);
const sortComponent = fc.record({field: fieldArb, dir: sortDirectionArb});
return fc
.uniqueArray(sortComponent, {
minLength: 1,
maxLength: maxNumSortComponents,
selector: fieldAndDir => fieldAndDir.field,
})
.map(components => {
const sortSpec = {};
for (const {field, dir} of components) {
sortSpec[field] = dir;
}
return {$sort: sortSpec};
});
}
export const limitArb = fc.record({$limit: fc.integer({min: 1, max: 5})});
export const skipArb = fc.record({$skip: fc.integer({min: 1, max: 5})});
/*
* Return the arbitraries for agg stages that are allowed given:
* - `allowOrs` for whether we allow $or in $match
* - `deterministicBag` for whether the query needs to return the same bag of results every time.
* $limit and $skip prevent the bag from being consistent for each run, so we exclude these
* when a deterministic bag is required.
* The output is in order from simplest agg stages to most complex, for minimization.
*/
function getAllowedStages(allowOrs, deterministicBag) {
if (deterministicBag) {
return [
simpleProjectArb,
getMatchArb(allowOrs),
// SERVER-92824 was not backported to 8.0, so we have to disable $addFields.
// addFieldsConstArb,
// addFieldsVarArb,
computedProjectArb,
getSortArb(),
groupArb
];
} else {
// If we don't require a deterministic bag, we can allow $skip and $limit anywhere.
return [
limitArb,
skipArb,
simpleProjectArb,
getMatchArb(allowOrs),
// SERVER-92824 was not backported to 8.0, so we have to disable $addFields.
// addFieldsConstArb,
// addFieldsVarArb,
computedProjectArb,
getSortArb(),
groupArb
];
}
}
/*
* Our full model for aggregation pipelines. See `getAllowedStages` for description of `allowOrs`
* and `deterministicBag`. By default, ORs are allowed and the bag of results will be deterministic.
*/
export function getAggPipelineModel({allowOrs = true, deterministicBag = true} = {}) {
const aggStageArb = oneof(...getAllowedStages(allowOrs, deterministicBag));
// Length 6 seems long enough to cover interactions between stages.
return fc.array(aggStageArb, {minLength: 1, maxLength: 6});
}

View File

@ -0,0 +1,41 @@
/*
* Fast-check models for workloads. A workload is a collection model and an aggregation model.
* See property_test_helpers/README.md for more detail on the design.
*/
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
function typeCheckSingleAggModel(aggregation) {
// Should be a list of objects.
assert(Array.isArray(aggregation), 'Each aggregation pipeline should be an array.');
for (const aggStage of aggregation) {
assert.eq(typeof aggStage, 'object', 'Each aggregation stage should be an object.');
}
}
// Sample once from the aggsModel to do some type checking. This can prevent accidentally passing
// models to the wrong parameters.
function typeCheckManyAggsModel(aggsModel) {
const aggregations = fc.sample(aggsModel, {numRuns: 1})[0];
// Should be a list of aggregation pipelines.
assert(Array.isArray(aggregations), 'aggsModel should generate an array');
assert.gt(aggregations.length, 0, 'aggsModel should generate a non-empty array');
aggregations.forEach(agg => typeCheckSingleAggModel(agg));
}
/*
* Creates a workload model from the given collection model and aggregation model.
* Can be passed:
* - `aggsModel` which generates multiple aggregation pipelines at a time or
* - `aggModel` and `numQueriesPerRun` which will be used to create an `aggsModel`
*/
export function makeWorkloadModel({collModel, aggModel, aggsModel, numQueriesPerRun} = {}) {
assert(!aggsModel || !aggModel, 'Cannot specify both `aggsModel` and `aggModel`');
assert(
!aggsModel || !numQueriesPerRun,
'Cannot specify `aggsModel` and `numQueriesPerRun`, since `numQueriesPerRun` is only used when provided `aggModel`.');
if (aggModel) {
aggsModel = fc.array(aggModel, {minLength: 1, maxLength: numQueriesPerRun, size: '+2'});
}
typeCheckManyAggsModel(aggsModel);
return fc.record({collSpec: collModel, queries: aggsModel});
}

View File

@ -0,0 +1,61 @@
/*
* This file contains a list of bugs we've found from property-based testing. We use this list to
* run the scenarios at the beginning of testing to make sure they don't fail anymore.
*
* Some of these scenarios are hard to find by chance. When a change is made to a PBT model, we
* may not run into these scenarios anymore since stability of the generated examples is not
* guaranteed. As a solution we usually write a new non-PBT test with the example to make sure we
* have coverage, but we can also run them through existing PBTs using this file.
*
* This file is also useful for BFs. fast-check will print out the counterexample, and it can be
* pasted into this file to repro the bug on the first run. After that further analysis can be done.
*/
// Repro from SERVER-102825.
const partialIndexExample102825 = {
collSpec: {
isTS: false,
docs: [{_id: 0, a: 0}],
indexes: [{
def: {a: 1},
options: {partialFilterExpression: {$or: [{a: 1}, {a: {$lte: 'a string'}}]}}
}]
},
queries: [
[{$match: {$or: [{a: 1}, {a: {$lte: 'a string'}}], _id: {$lte: 5}}}],
[{$match: {$or: [{a: 1}, {a: {$lte: 10}}], _id: {$lte: 5}}}]
]
};
const partialIndexExample2_partialFilter = {
$or: [{a: {$lt: ""}}, {_id: {$eq: 0}, a: {$eq: 0}}]
};
const partialIndexExample2 = {
collSpec: {
isTS: false,
docs: [{_id: 1, m: 0, a: 0, b: 0}],
indexes: [
{def: {a: 1}, options: {partialFilterExpression: partialIndexExample2_partialFilter}},
{
def: {a: 1, m: 1},
options: {partialFilterExpression: partialIndexExample2_partialFilter}
},
{
def: {b: 1, a: 1},
options: {partialFilterExpression: partialIndexExample2_partialFilter}
}
]
},
queries: [
[{$match: {$or: [{a: {$lt: ""}}, {_id: {$eq: 0}, a: {$eq: -1}}]}}, {$sort: {b: 1}}],
[{$match: {$or: [{a: {$lt: 1}}, {_id: {$eq: 0}, a: {$eq: 0}}]}}, {$sort: {b: 1}}]
]
};
export const partialIndexCounterexamples = [
// I'm not sure why examples have to be placed in an array, the documentation doesn't say. I've
// verified it works though.
[partialIndexExample102825],
// TODO SERVER-106023 uncomment this example workload.
// [partialIndexExample2]
];

View File

@ -0,0 +1,237 @@
/*
* Utility functions to help run a property-based test in a jstest.
*/
import {
LeafParameter,
leafParametersPerFamily
} from "jstests/libs/property_test_helpers/models/basic_models.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
/*
* Given a query family and an index in [0-numLeafParameters), we replace the leaves of the query
* with the corresponding constant at that index.
*/
export function concreteQueryFromFamily(queryShape, leafId) {
if (queryShape instanceof LeafParameter) {
// We found a leaf, and want to return a concrete constant instead.
// The leaf node should have one key, and the value should be our constants.
const vals = queryShape.concreteValues;
return vals[leafId % vals.length];
} else if (Array.isArray(queryShape)) {
// Recurse through the array, replacing each leaf with a value.
const result = [];
for (const el of queryShape) {
result.push(concreteQueryFromFamily(el, leafId));
}
return result;
} else if (typeof queryShape === 'object' && queryShape !== null) {
// Recurse through the object values and create a new object.
const obj = {};
const keys = Object.keys(queryShape);
for (const key of keys) {
obj[key] = concreteQueryFromFamily(queryShape[key], leafId);
}
return obj;
}
return queryShape;
}
function createColl(coll, isTS = false) {
const db = coll.getDB();
const args = isTS ? {timeseries: {timeField: 't', metaField: 'm'}} : {};
assert.commandWorked(db.createCollection(coll.getName(), args));
}
/*
* Acceptable error codes from creating an index. We could change our model or add filters
* to the model to remove these cases, but that would cause them to become overcomplicated.
* In pbt_self_test.js, we assert that the number of indexes created is high enough, to avoid our
* tests silently erroring too much on index creation.
*/
const okIndexCreationErrorCodes = [
// Index already exists.
ErrorCodes.IndexOptionsConflict,
// Overlapping fields and path collisions in wildcard projection.
31249,
31250,
7246200,
7246204,
7246208,
// For partial index filters, we can sometimes go over the depth limit of the filter. It's
// difficult to control the exact depth of the filters generated without sacrificing lots of
// interesting cases, so instead we allow this error.
ErrorCodes.CannotCreateIndex,
// Error code when creating specific partial indexes on time-series, for example when the
// predicate is `{a: {$in: [null]}}`
5916301,
];
/*
* Clear any state in the collection (other than data, which doesn't change). Create indexes the
* test uses, then run the property test.
*
* As input, properties a list of query families to use during the property test, and some helpers
* which include a comparator, and details about how many queries we have.
*
* The `getQuery(i, j)` function returns query shape `i` with it's `j`th parameters plugged in.
* For example, to get different query shapes we would call
* getQuery(0, 0)
* getQuery(1, 0)
* ...
* To get the same query shape with different parameters, we would call
* getQuery(0, 0)
* getQuery(0, 1)
* ...
* TODO SERVER-98132 redesign getQuery to be more opaque about how many query shapes and constants
* there are.
*/
function runProperty(propertyFn, namespaces, workload) {
let {collSpec, queries, extraParams} = workload;
const {controlColl, experimentColl} = namespaces;
// `extraParams` is an optional field in a workload model.
if (!extraParams) {
extraParams = [];
}
// Setup the control/experiment collections, define the helper functions, then run the property.
if (controlColl) {
assert(controlColl.drop());
createColl(controlColl);
assert.commandWorked(controlColl.insert(collSpec.docs));
}
assert(experimentColl.drop());
createColl(experimentColl, collSpec.isTS);
assert.commandWorked(experimentColl.insert(collSpec.docs));
collSpec.indexes.forEach((indexSpec, num) => {
const name = "index_" + num;
assert.commandWorkedOrFailedWithCode(
experimentColl.createIndex(indexSpec.def, Object.assign({}, indexSpec.options, {name})),
okIndexCreationErrorCodes);
});
const testHelpers = {
comp: _resultSetsEqualUnordered,
numQueryShapes: queries.length,
leafParametersPerFamily
};
function getQuery(queryIx, paramIx) {
assert.lt(queryIx, queries.length);
const query = queries[queryIx];
return concreteQueryFromFamily(query, paramIx);
}
return propertyFn(getQuery, testHelpers, ...extraParams);
}
/*
* We need a custom reporter function to get more details on the failure. The default won't show
* what property failed very clearly, or provide more details beyond the counterexample.
*/
function reporter(propertyFn, namespaces) {
return function(runDetails) {
if (runDetails.failed) {
// Print the fast-check failure summary, the counterexample, and additional details
// about the property failure.
jsTestLog('Failed property: ' + propertyFn.name);
jsTestLog(runDetails);
const workload = runDetails.counterexample[0];
jsTestLog(workload);
jsTestLog(runProperty(propertyFn, namespaces, workload));
assert(false);
}
};
}
/*
* Given a property (a JS function), the experiment collection, and execution details, run the given
* property. We call `runProperty` to clear state and call the property function correctly. On
* failure, `runProperty` is called again in the reporter, and prints out more details about the
* failed property.
*/
export function testProperty(propertyFn, namespaces, workloadModel, numRuns, examples) {
assert.eq(typeof propertyFn, 'function');
assert(Object.keys(namespaces)
.every(collName => collName === 'controlColl' || collName === 'experimentColl'));
assert.eq(typeof numRuns, 'number');
const seed = 4;
jsTestLog('Running property `' + propertyFn.name + '` from test file `' + jsTestName() +
'`, seed = ' + seed);
// PBTs can throw (and then catch) exceptions for a few reasons. For example it's hard to model
// indexes exactly, so we end up trying to create some invalid indexes which throw exceptions.
// These exceptions make the logs hard to read and can be ignored, so we turn off
// traceExceptions. These failures are still logged on a single line with the message
// "Assertion while executing command"
// True PBT failures (uncaught) are still readable and have stack traces.
TestData.traceExceptions = false;
let alwaysPassed = true;
fc.assert(fc.property(workloadModel, workload => {
// Only return if the property passed or not. On failure,
// `runProperty` is called again and more details are exposed.
const result = runProperty(propertyFn, namespaces, workload);
// If it failed for the first time, print that out so we have the first failure available
// in case shrinking fails.
if (!result.passed && alwaysPassed) {
jsTestLog('The property ' + propertyFn.name + ' from ' + jsTestName() + ' failed');
jsTestLog('Initial inputs **before minimization**');
jsTestLog(workload);
jsTestLog('Initial failure details **before minimization**');
jsTestLog(result);
alwaysPassed = false;
}
return result.passed;
}), {seed, numRuns, reporter: reporter(propertyFn, namespaces), examples});
}
function isCollTS(collName) {
const res = assert.commandWorked(db.runCommand({listCollections: 1, filter: {name: collName}}));
const colls = res.cursor.firstBatch;
assert.eq(colls.length, 1);
return colls[0].type === 'timeseries';
}
export function getPlanCache(coll) {
const collName = coll.getName();
if (isCollTS(collName)) {
return db.system.buckets[collName].getPlanCache();
}
return db[collName].getPlanCache();
}
function unoptimize(q) {
return [{$_internalInhibitOptimization: {}}].concat(q);
}
/*
* Runs the given function with the following settings:
* - execution framework set to classic engine
* - plan cache disabled
* - pipeline optimizations disabled
* Returns a map from the position of the query in the list to the result documents.
*/
export function runDeoptimized(controlColl, queries) {
// The `internalQueryDisablePlanCache` prevents queries from getting cached, but it does not
// prevent queries from using existing cache entries. To fully ignore the cache, we clear it
// and then set the `internalQueryDisablePlanCache` knob.
getPlanCache(controlColl).clear();
const db = controlColl.getDB();
const priorSettings = assert.commandWorked(db.adminCommand(
{getParameter: 1, internalQueryFrameworkControl: 1, internalQueryDisablePlanCache: 1}));
assert.commandWorked(db.adminCommand({
setParameter: 1,
internalQueryFrameworkControl: 'forceClassicEngine',
internalQueryDisablePlanCache: true
}));
const resultMap = queries.map(query => controlColl.aggregate(unoptimize(query)).toArray());
assert.commandWorked(db.adminCommand({
setParameter: 1,
internalQueryFrameworkControl: priorSettings.internalQueryFrameworkControl,
internalQueryDisablePlanCache: priorSettings.internalQueryDisablePlanCache
}));
return resultMap;
}

View File

@ -0,0 +1,13 @@
load("@aspect_rules_js//js:defs.bzl", "js_library")
js_library(
name = "all_javascript_files",
srcs = glob([
"*.js",
]),
target_compatible_with = select({
"//bazel/config:ppc_or_s390x": ["@platforms//:incompatible"],
"//conditions:default": [],
}),
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,56 @@
/**
* Self-test for our PBT infrastructure. Asserts that shrinking (minimization) works properly.
*/
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
const seed = 4;
/*
* Fails if we have 1 or more docs, or any indexes.
* For this property, our minimal counterexample should have exactly 1 doc, and 0 indexes.
*/
function mockProperty(collection) {
if (collection.docs.length >= 1) {
return false;
}
if (collection.indexes.length > 0) {
return false;
}
return true;
}
const fullyMinimizedDoc = {
t: new Date(0),
m: {m1: 0, m2: 0},
array: 0,
a: 0,
b: 0
};
function testShrinking(isTS) {
const collModel = getCollectionModel({isTS});
let reporterRan = false;
function reporter(runDetails) {
assert(runDetails.failed, runDetails);
const {isTS, docs, indexes} = runDetails.counterexample[0];
// Expect 1 doc for full minimization
assert.eq(docs.length, 1, docs);
// _ids cannot be fully minimized because of how we assign them out uniquely.
delete docs[0]._id;
assert.eq(docs[0], fullyMinimizedDoc, docs);
// Expect 0 indexes
assert.eq(indexes.length, 0, indexes);
reporterRan = true;
}
// This property should fail, and our `reporter` will be called, which asserts that we see the
// minimized counterexample.
fc.assert(fc.property(collModel, mockProperty), {seed, numRuns: 100, reporter});
// Assert that the property didn't silently pass.
assert(reporterRan);
}
testShrinking(false);
testShrinking(true);

View File

@ -0,0 +1,154 @@
/**
* Tests that our models behave correctly. These are intended to prevent our PBTs from silently
* doing no work. For example of no documents are generated, every query will seem correct.
*
* We check that on average:
* - Enough documents exist in the collections
* - Enough indexes are created
* - Queries return a result set of an acceptable size
* - Parameterization (the ability to generate queries of the same shape but different leaf values
* plugged in) works correctly
*/
import {isSlowBuild} from "jstests/libs/aggregation_pipeline_utils.js";
import {getCollectionModel} from "jstests/libs/property_test_helpers/models/collection_models.js";
import {getMatchArb} from "jstests/libs/property_test_helpers/models/match_models.js";
import {
addFieldsConstArb,
getAggPipelineModel
} from "jstests/libs/property_test_helpers/models/query_models.js";
import {makeWorkloadModel} from "jstests/libs/property_test_helpers/models/workload_models.js";
import {
concreteQueryFromFamily,
testProperty
} from "jstests/libs/property_test_helpers/property_testing_utils.js";
import {fc} from "jstests/third_party/fast_check/fc-3.1.0.js";
const seed = 4;
const conn = MongoRunner.runMongod();
const db = conn.getDB("test");
if (isSlowBuild(db)) {
jsTestLog(
'Skipping self tests on slow build, since many aggregations are required which are not' +
'affected by optimization or debug settings.');
MongoRunner.stopMongod(conn);
quit();
}
function avg(arrOfInts) {
let sum = 0;
for (const n of arrOfInts) {
sum += n;
}
return sum / arrOfInts.length;
}
const experimentColl = db.pbt_self_test_experiment;
// Test the number of documents and indexes are high enough for PBT to be effective.
// This can be tested with timeseries and non-timeseries collections because the index models are
// different.
function testNumDocsAndIndexes(isTS) {
// Test that we create enough indexes and documents per run.
const numDocs = [];
const numIndexes = [];
function mockProperty(getQuery, testHelpers) {
numDocs.push(experimentColl.count());
numIndexes.push(experimentColl.getIndexes().length);
return {passed: true};
}
let numRuns = 100;
let numQueriesPerRun = 1;
testProperty(mockProperty,
{experimentColl},
makeWorkloadModel({
collModel: getCollectionModel({isTS}),
aggModel: getAggPipelineModel(),
numQueriesPerRun
}),
numRuns);
const avgNumDocs = avg(numDocs);
assert.eq(numDocs.length, numRuns);
assert.gt(avgNumDocs, 100);
jsTestLog('Average number of documents was: ' + avgNumDocs);
const avgNumIndexes = avg(numIndexes);
assert.eq(numIndexes.length, numRuns);
assert.gt(avgNumIndexes, 4);
jsTestLog('Average number of indexes was: ' + avgNumIndexes);
}
testNumDocsAndIndexes(false /* isTS */);
testNumDocsAndIndexes(true /* isTS */);
// Test that average number of documents matched is high enough to have meaningful results.
// This does not test time-series because results should be the same with a TS collection.
function testMatchedDocsMetrics(allowOrs) {
// Now test that queries return an acceptable number of results on average.
const testCases = [
{
name: 'single $match queries',
aggModel: getMatchArb(allowOrs).map(matchStage => [matchStage]),
minimumAcceptedAvgNumDocs: 15
},
{
name: 'deterministic aggregations',
aggModel: getAggPipelineModel({allowOrs, deterministicBag: true}),
minimumAcceptedAvgNumDocs: 30
},
{
name: 'nondeterministic aggregations',
aggModel: getAggPipelineModel({allowOrs, deterministicBag: false}),
minimumAcceptedAvgNumDocs: 30
}
];
for (const {name, aggModel, minimumAcceptedAvgNumDocs} of testCases) {
const numDocsReturned = [];
function mockProperty(getQuery, testHelpers) {
for (let shapeIx = 0; shapeIx < testHelpers.numQueryShapes; shapeIx++) {
const query = getQuery(shapeIx, 0 /* paramIx */);
const results = experimentColl.aggregate(query).toArray();
numDocsReturned.push(results.length);
}
return {passed: true};
}
// Run 100 queries total.
const numRuns = 20;
const numQueriesPerRun = 10;
testProperty(
mockProperty,
{experimentColl},
makeWorkloadModel({collModel: getCollectionModel(), aggModel, numQueriesPerRun}),
numRuns);
const avgNumDocsReturned = avg(numDocsReturned);
assert.gt(avgNumDocsReturned, minimumAcceptedAvgNumDocs, name);
jsTestLog('Average number of documents returned for ' + name +
' was: ' + avgNumDocsReturned);
}
}
/*
* allowOrs=false is acceptable because including OR predicates would only increase the number of
* documents matched compared to a regular predicate or an AND predicate.
*/
testMatchedDocsMetrics(false /* allowOrs */);
MongoRunner.stopMongod(conn);
// For stages that we expect to have multiple options for the leaves (parameterized). We take a
// sample of the stages, and check that we can extract several version of the stage from them.
const parameterizedStages = [getMatchArb(true), addFieldsConstArb];
function hasDifferentLeafParams(stage) {
const concreteStage1 = concreteQueryFromFamily(stage, 0 /* leafId */);
const concreteStage2 = concreteQueryFromFamily(stage, 1 /* leafId */);
return JSON.stringify(concreteStage1) != JSON.stringify(concreteStage2);
}
for (const stage of parameterizedStages) {
const sample = fc.sample(stage, {seed, numRuns: 100});
assert(sample.some(hasDifferentLeafParams), sample);
}

View File

@ -61,6 +61,7 @@
#include "mongo/base/status.h"
#include "mongo/base/status_with.h"
#include "mongo/bson/bsonmisc.h"
#include "mongo/bson/bsonobj_comparator.h"
#include "mongo/bson/bsonobjbuilder.h"
#include "mongo/bson/bsontypes.h"
#include "mongo/client/client_api_version_parameters_gen.h"
@ -611,6 +612,109 @@ BSONObj numberDecimalsAlmostEqual(const BSONObj& input, void*) {
}
void sortBSONObjectInternallyHelper(const BSONObj& input, BSONObjBuilder& bob);
// Helper for `sortBSONObjectInternally`, handles a BSONElement for different recursion cases.
void sortBSONElementInternally(const BSONElement& el, BSONObjBuilder& bob) {
if (el.type() == BSONType::Array) {
BSONObjBuilder sub(bob.subarrayStart(el.fieldNameStringData()));
for (const auto& child : el.Array()) {
sortBSONElementInternally(child, sub);
}
sub.doneFast();
} else if (el.type() == BSONType::Object) {
BSONObjBuilder sub(bob.subobjStart(el.fieldNameStringData()));
sortBSONObjectInternallyHelper(el.Obj(), sub);
sub.doneFast();
} else {
bob.append(el);
}
}
void sortBSONObjectInternallyHelper(const BSONObj& input, BSONObjBuilder& bob) {
BSONObjIteratorSorted it(input);
while (it.more()) {
sortBSONElementInternally(it.next(), bob);
}
}
// Returns a new BSON with the same field/value pairings, but is recursively sorted by the fields.
// Arrays are not changed.
BSONObj sortBSONObjectInternally(const BSONObj& input) {
BSONObjBuilder bob(input.objsize());
sortBSONObjectInternallyHelper(input, bob);
return bob.obj();
}
// Sorts a vector of BSON objects by their fields as they appear in the BSON.
void sortQueryResults(std::vector<BSONObj>& input) {
std::sort(input.begin(), input.end(), [&](const BSONObj& lhs, const BSONObj& rhs) {
return SimpleBSONObjComparator::kInstance.evaluate(lhs < rhs);
});
}
/*
* Takes two arrays of documents, and returns whether they contain the same set of BSON Objects. The
* BSON do not need to be in the same order for this to return true. Has no special logic for
* handling double/NumberDecimal closeness.
*/
BSONObj _resultSetsEqualUnordered(const BSONObj& input, void*) {
BSONObjIterator i(input);
auto first = i.next();
auto second = i.next();
uassert(9193201,
str::stream() << "_resultSetsEqualUnordered expects two arrays of containing objects "
"as input received "
<< first.type() << " and " << second.type(),
first.type() == BSONType::Array && second.type() == BSONType::Array);
auto firstAsBson = first.Array();
auto secondAsBson = second.Array();
for (const auto& el : firstAsBson) {
uassert(9193202,
str::stream() << "_resultSetsEqualUnordered expects all elements of input arrays "
"to be objects, received "
<< el.type(),
el.type() == BSONType::Object);
}
for (const auto& el : secondAsBson) {
uassert(9193203,
str::stream() << "_resultSetsEqualUnordered expects all elements of input arrays "
"to be objects, received "
<< el.type(),
el.type() == BSONType::Object);
}
if (firstAsBson.size() != secondAsBson.size()) {
return BSON("" << false);
}
// Optimistically assume they're already in the same order.
if (first.binaryEqualValues(second)) {
return BSON("" << true);
}
std::vector<BSONObj> firstSorted;
std::vector<BSONObj> secondSorted;
for (size_t i = 0; i < firstAsBson.size(); i++) {
firstSorted.push_back(sortBSONObjectInternally(firstAsBson[i].Obj()));
secondSorted.push_back(sortBSONObjectInternally(secondAsBson[i].Obj()));
}
sortQueryResults(firstSorted);
sortQueryResults(secondSorted);
for (size_t i = 0; i < firstSorted.size(); i++) {
if (!firstSorted[i].binaryEqual(secondSorted[i])) {
return BSON("" << false);
}
}
return BSON("" << true);
}
class GoldenTestContextShell : public unittest::GoldenTestContextBase {
public:
explicit GoldenTestContextShell(const unittest::GoldenTestConfig* config,
@ -804,6 +908,8 @@ void installShellUtils(Scope& scope) {
scope.injectNative("_closeGoldenData", _closeGoldenData);
scope.injectNative("_buildBsonObj", _buildBsonObj);
scope.injectNative("_fnvHashToHexString", _fnvHashToHexString);
scope.injectNative("_resultSetsEqualUnordered", _resultSetsEqualUnordered);
installShellUtilsLauncher(scope);
installShellUtilsExtended(scope);