SERVER-113997 SERVER-114329 Fix legacy timeseries namespace translation in findAndModify explain command (#44558)

Co-authored-by: Joan Bruguera Micó (at MongoDB) <joan.bruguera-mico@mongodb.com>
GitOrigin-RevId: 299aeac990ac6467ae0d16a51fc9b0c68d5baa33
This commit is contained in:
Tommaso Tocci 2026-01-09 10:30:31 +01:00 committed by MongoDB Bot
parent 3c3ed51901
commit fa497b53f1
6 changed files with 116 additions and 46 deletions

View File

@ -14,6 +14,9 @@ import {extendWorkload} from "jstests/concurrency/fsm_libs/extend_workload.js";
import {
$config as $baseConfig
} from "jstests/concurrency/fsm_workloads/timeseries/timeseries_raw_data_operations.js";
import {
assertExplainTargetsExpectedTimeseriesNamespace
} from "jstests/core/timeseries/libs/viewless_timeseries_util.js";
import {getPlanStage} from "jstests/libs/query/analyze_plan.js";
export const $config = extendWorkload($baseConfig, function($config, $super) {
@ -30,10 +33,7 @@ export const $config = extendWorkload($baseConfig, function($config, $super) {
"Expected not to find TS_MODIFY stage " + tojson(commandResult);
assert(commandResult.command.rawData,
`Expected command to include rawData but got ${tojson(commandResult)}`);
assert.eq(commandResult.command[commandName],
coll.getName(),
`Expected command namespace to be ${tojson(coll.getName())} but got ${
tojson(commandResult.command[commandName])}`);
assertExplainTargetsExpectedTimeseriesNamespace(db, coll, commandResult, commandName);
};
$config.states.explainAggregate = function explainAggregate(db, collName) {

View File

@ -15,6 +15,9 @@
* ]
*/
import {
assertExplainTargetsExpectedTimeseriesNamespace
} from "jstests/core/timeseries/libs/viewless_timeseries_util.js";
import {getPlanStage} from "jstests/libs/query/analyze_plan.js";
export const $config = (function() {
@ -88,12 +91,16 @@ export const $config = (function() {
}
assert.isnull(getPlanStage(commandResult, "TS_MODIFY")),
"Expected not to find TS_MODIFY stage " + tojson(commandResult);
assert(commandResult.command.rawData,
`Expected command to include rawData but got ${tojson(commandResult)}`);
assert.eq(commandResult.command[commandName],
coll.getName(),
`Expected command namespace to be ${tojson(coll.getName())} but got ${
tojson(commandResult.command[commandName])}`);
assert(
commandResult.command.rawData,
`Expected command to include rawData but got ${tojson(commandResult)}`,
);
assertExplainTargetsExpectedTimeseriesNamespace(db, coll, commandResult, commandName, {
// In balancer suites, `coll` may be tracked when the explain command was run,
// but then recreateCollection may drop the collection and re-create it as
// untracked.
mayConcurrentlyTrackOrUntrack: TestData.runningWithBalancer,
});
},
handleCollectionDrop: function(fn, opName) {

View File

@ -5,6 +5,7 @@
import {FeatureFlagUtil} from "jstests/libs/feature_flag_util.js";
import {FixtureHelpers} from "jstests/libs/fixture_helpers.js";
import {getTimeseriesCollForRawOps} from "jstests/libs/raw_operation_utils.js";
export function areViewlessTimeseriesEnabled(db) {
return FeatureFlagUtil.isPresentAndEnabled(db, "CreateViewlessTimeseriesCollections");
@ -60,3 +61,51 @@ export function isShardedTimeseries(coll) {
return FixtureHelpers.isSharded(coll) ||
FixtureHelpers.isSharded(getTimeseriesBucketsColl(coll));
}
/**
* Checks that the namespace targeted by `commandResult` the command matches `coll`,
* modulo quirks of translation to system.buckets for legacy timeseries.
*/
export function assertExplainTargetsExpectedTimeseriesNamespace(
db,
coll,
commandResult,
commandName,
{mayConcurrentlyTrackOrUntrack = false} = {},
) {
let targetColl = (() => {
if (commandResult.command.findAndModify &&
FixtureHelpers.isTracked(getTimeseriesCollForDDLOps(db, coll)) &&
!areViewlessTimeseriesEnabled(db)) {
// In sharded clusters for findAndModify over legacy tracked timeseries we convert the
// namespace on the router and we send the command with translated namespace to the
// shard, thus we expect explain to report the command targeting system.buckets internal
// namespace.
return getTimeseriesCollForDDLOps(db, coll);
}
return getTimeseriesCollForRawOps(db, coll);
})();
if (commandResult.command.findAndModify && !areViewlessTimeseriesEnabled(db) &&
(mayConcurrentlyTrackOrUntrack ||
(TestData.runningWithBalancer &&
FixtureHelpers.isTracked(getTimeseriesCollForDDLOps(db, coll)) &&
!FixtureHelpers.isSharded(getTimeseriesCollForDDLOps(db, coll))))) {
// If the collection is tracked or untracked findAndModify explain returns either the
// buckets or main timeseries namespace In suites with enabled balancer the collection could
// randomly became tracked.
jsTest.log(
"Skipping namespace check for findAndModify explain output since we don't know if the collection was tracked or not when the command was executed",
);
} else {
jsTest.log(`commandRes = ${tojson(commandResult)}`);
assert.eq(
commandResult.command[commandName],
targetColl.getName(),
`Expected command namespace to be ${tojson(targetColl.getName())} but got ${
tojson(
commandResult.command[commandName],
)}`,
);
}
}

View File

@ -11,6 +11,9 @@ import {
getTimeseriesCollForRawOps,
kRawOperationSpec
} from "jstests/core/libs/raw_operation_utils.js";
import {
assertExplainTargetsExpectedTimeseriesNamespace
} from "jstests/core/timeseries/libs/viewless_timeseries_util.js";
import {getPlanStage} from "jstests/libs/query/analyze_plan.js";
const coll = db[jsTestName()];
@ -32,19 +35,23 @@ assert.commandWorked(coll.insert([
const assertExplain = function(commandResult, commandName) {
assert(commandResult.ok);
if (commandResult.command.bulkWrite) {
assert.eq(commandResult.command.nsInfo.length,
1,
`Expected 1 namespace in explain command but got ${
commandResult.command.nsInfo.length}`);
assert.eq(commandResult.command.nsInfo[0].ns,
coll.getFullName(),
`Expected command namespace to be ${tojson(coll.getFullName())} but got ${
tojson(commandResult.command.nsInfo[0].ns)}`);
assert.eq(
commandResult.command.nsInfo.length,
1,
`Expected 1 namespace in explain command but got ${
commandResult.command.nsInfo.length}`,
);
assert.eq(
commandResult.command.nsInfo[0].ns,
getTimeseriesCollForRawOps(coll).getFullName(),
`Expected command namespace to be ${
tojson(getTimeseriesCollForRawOps(coll).getFullName())} but got ${
tojson(
commandResult.command.nsInfo[0].ns,
)}`,
);
} else {
assert.eq(commandResult.command[commandName],
coll.getName(),
`Expected command namespace to be ${tojson(coll.getName())} but got ${
tojson(commandResult.command[commandName])}`);
assertExplainTargetsExpectedTimeseriesNamespace(db, coll, commandResult, commandName);
}
assert(commandResult.command.rawData);
assert.isnull(getPlanStage(commandResult, "TS_MODIFY")),

View File

@ -21,10 +21,15 @@ import {ShardingTest} from "jstests/libs/shardingtest.js";
const assertExplain = function(coll, commandResult) {
const commandName = "findAndModify";
assert(commandResult.ok);
assert.eq(commandResult.command[commandName],
coll.getName(),
`Expected command namespace to be ${tojson(coll.getName())} but got ${
tojson(commandResult.command[commandName])}`);
assert.eq(
commandResult.command[commandName],
getTimeseriesCollForDDLOps(db, coll).getName(),
`Expected command namespace to be ${
tojson(getTimeseriesCollForDDLOps(db, coll).getName())} but got ${
tojson(
commandResult.command[commandName],
)}`,
);
assert(isRawOperationSupported(db) === (commandResult.command.rawData ?? false));
assert.isnull(getPlanStage(commandResult, "TS_MODIFY")),
"Expected not to find TS_MODIFY stage " + tojson(commandResult);

View File

@ -491,7 +491,9 @@ namespace {
* Replaces the target namespace in the 'cmdObj' by 'bucketNss'. Also sets the
* 'isTimeseriesNamespace' flag.
*/
BSONObj replaceNamespaceByBucketNss(const BSONObj& cmdObj, const NamespaceString& bucketNss) {
BSONObj replaceNamespaceByBucketNss(OperationContext* opCtx,
const BSONObj& cmdObj,
const NamespaceString& bucketNss) {
BSONObjBuilder bob;
for (const auto& elem : cmdObj) {
const auto name = elem.fieldNameStringData();
@ -501,11 +503,12 @@ BSONObj replaceNamespaceByBucketNss(const BSONObj& cmdObj, const NamespaceString
bob.append(elem);
}
}
// Set this flag so that shards can differentiate a request on a time-series view from a request
// on a time-series buckets collection since we replace the target namespace in the command with
// the buckets namespace.
bob.append(write_ops::FindAndModifyCommandRequest::kIsTimeseriesNamespaceFieldName, true);
if (!isRawDataOperation(opCtx)) {
// Set this flag so that shards can differentiate a request on a time-series view from a
// request on a time-series buckets collection since we replace the target namespace in the
// command with the buckets namespace.
bob.append(write_ops::FindAndModifyCommandRequest::kIsTimeseriesNamespaceFieldName, true);
}
return bob.obj();
}
@ -639,6 +642,9 @@ Status FindAndModifyCmd::explain(OperationContext* opCtx,
auto isTimeseriesViewRequest = false;
if (isTrackedTimeseries && !nss.isTimeseriesBucketsCollection()) {
nss = std::move(cm.getNss());
// If the request is for a view on a sharded timeseries buckets collection, we need to
// replace the namespace by buckets collection namespace in the command object.
cmdObj = replaceNamespaceByBucketNss(opCtx, cmdObj, nss);
if (!isRawDataOperation(opCtx)) {
isTimeseriesViewRequest = true;
}
@ -653,11 +659,6 @@ Status FindAndModifyCmd::explain(OperationContext* opCtx,
const auto let = getLet(cmdObj);
const auto rc = getLegacyRuntimeConstants(cmdObj);
if (cri.hasRoutingTable()) {
// If the request is for a view on a sharded timeseries buckets collection, we need to
// replace the namespace by buckets collection namespace in the command object.
if (isTimeseriesViewRequest) {
cmdObj = replaceNamespaceByBucketNss(cmdObj, nss);
}
auto expCtx = makeExpressionContextWithDefaultsForTargeter(
opCtx, nss, cri, collation, boost::none /* verbosity */, let, rc);
if (write_without_shard_key::useTwoPhaseProtocol(opCtx,
@ -787,24 +788,25 @@ bool FindAndModifyCmd::run(OperationContext* opCtx,
diagnostic_printers::ShardKeyDiagnosticPrinter{
cm.isSharded() ? cm.getShardKeyPattern().toBSON() : BSONObj()});
// Append mongoS' runtime constants to the command object before forwarding it to the shard.
auto cmdObjForShard = appendLegacyRuntimeConstantsToCommandObject(opCtx, cmdObj);
auto isTrackedTimeseries = cri.hasRoutingTable() && cm.getTimeseriesFields();
auto isTimeseriesViewRequest = false;
if (isTrackedTimeseries && !nss.isTimeseriesBucketsCollection()) {
// If the request is for a view on a sharded timeseries buckets collection, we need to
// replace the namespace by buckets collection namespace in the command object.
nss = std::move(cm.getNss());
isTimeseriesViewRequest = true;
cmdObjForShard = replaceNamespaceByBucketNss(opCtx, cmdObjForShard, nss);
if (!isRawDataOperation(opCtx)) {
isTimeseriesViewRequest = true;
}
}
// Note: at this point, 'nss' should be the timeseries buckets collection namespace if we're
// writing to a sharded timeseries collection.
// Append mongoS' runtime constants to the command object before forwarding it to the shard.
auto cmdObjForShard = appendLegacyRuntimeConstantsToCommandObject(opCtx, cmdObj);
if (cri.hasRoutingTable()) {
// If the request is for a view on a sharded timeseries buckets collection, we need to
// replace the namespace by buckets collection namespace in the command object.
if (isTimeseriesViewRequest) {
cmdObjForShard = replaceNamespaceByBucketNss(cmdObjForShard, nss);
}
auto letParams = getLet(cmdObjForShard);
auto runtimeConstants = getLegacyRuntimeConstants(cmdObjForShard);
BSONObj collation = getCollation(cmdObjForShard);