Files
mongo/jstests/libs/override_methods/simulate_atlas_proxy.js
Jiawei Yang 6ef245c441 SERVER-88659 Address flaky jstests caused by PM-3489 (#20783)
GitOrigin-RevId: 113b10a0ca88c6aa19af600ff469e0c7cdbcd305
2024-04-08 20:25:58 +00:00

680 lines
27 KiB
JavaScript

/**
* Overrides the runCommand method to prefix all databases and namespaces ("config", "admin",
* "local" excluded) with a tenant prefix, so that the accessed data will be migrated by the
* background operations run by the ContinuousTenantMigration and ContinuousShardSplit hooks.
*/
import {OverrideHelpers} from "jstests/libs/override_methods/override_helpers.js";
import {TransactionsUtil} from "jstests/libs/transactions_util.js";
import {
createCmdObjWithTenantId,
getTenantIdForDatabase,
isCmdObjWithTenantId,
prependTenantIdToDbNameIfApplicable,
removeTenantIdAndMaybeCheckPrefixes,
usingMultipleTenants
} from "jstests/serverless/libs/tenant_prefixing.js";
// Assert that some tenantIds are provided
assert(!!TestData.tenantId || (TestData.tenantIds && TestData.tenantIds.length > 0),
"Missing required tenantId or tenantIds");
// Save references to the original methods in the IIFE's scope.
// This scoping allows the original methods to be called by the overrides below.
const originalRunCommand = Mongo.prototype.runCommand;
const originalCloseMethod = Mongo.prototype.close;
// Save a reference to the connection created at shell startup. This will be used as a proxy for
// multiple internal routing connections for the lifetime of the test execution. If there is no
// initial connection, then we will not perform connection routing when using this override.
const initialConn = (typeof db !== 'undefined') ? db.getMongo() : undefined;
const testTenantMigrationDB = "testTenantMigration";
// For shard merge we need to use the local DB that is not blocked by tenant access blockers.
const localDB = "local";
/**
* Asserts that the provided connection is an internal routing connection, not the top-level proxy
* connection. The proxy connection also has an internal routing connection, so it is excluded from
* this check.
*/
function assertRoutingConnection(conn) {
if (conn !== initialConn) {
assert.eq(null,
conn._internalRoutingConnection,
"Expected connection to have no internal routing connection.");
}
}
/**
* @returns The internal routing connection for a provided connection
*/
function getRoutingConnection(conn) {
if (conn === initialConn && conn._internalRoutingConnection == null) {
conn._internalRoutingConnection = conn;
}
// Since we are patching the prototype below, there must eventually be a "base case" for
// determining which connection to run a method on. If the provided `conn` has no internal
// routing connection, we assume that it _is_ the internal routing connection, and return
// here.
if (conn._internalRoutingConnection == null) {
return conn;
}
// Sanity check ensuring we have not accidentally created an internal routing connection on an
// internal routing connection.
assertRoutingConnection(conn._internalRoutingConnection);
return conn._internalRoutingConnection;
}
/**
* Assigns a newly establish connection as the internal routing connection for a Mongo instance.
*
* @param {Mongo} conn The original Mongo connection
* @param {Mongo} mongo The newly established, internal connection
*/
function setRoutingConnection(conn, mongo) {
assert.neq(null,
conn._internalRoutingConnection,
"Expected connection to already have an internal routing connection.");
conn._internalRoutingConnection = mongo;
}
function closeRoutingConnection(conn) {
if (conn === initialConn) {
// We need to close the initial connection differently, since we patch the close method
// below to always proxy calls to the internal routing connection.
return originalCloseMethod.apply(conn);
}
// For all other connections we are safe to call close directly
conn.close();
}
/**
* @returns Whether we are running a shard split passthrough.
*/
function isShardSplitPassthrough() {
return !!TestData.splitPassthrough;
}
/**
* If the given response object contains the given tenant migration error, returns the error object.
* Otherwise, returns null.
*/
function extractTenantMigrationError(resObj, errorCode) {
if (resObj.code == errorCode) {
// Commands, like createIndex and dropIndex, have TenantMigrationCommitted or
// TenantMigrationAborted error in the top level.
return resObj;
}
if (resObj.writeErrors) {
for (let writeError of resObj.writeErrors) {
if (writeError.code == errorCode) {
return writeError;
}
}
}
// BulkWrite command has errors contained in a cursor response. The error will always be
// in the first batch of the cursor response since getMore is not allowed to run with
// tenant migration / shard merge suites.
if (resObj.cursor) {
if (resObj.cursor.firstBatch) {
for (let opRes of resObj.cursor.firstBatch) {
if (opRes.code && opRes.code == errorCode) {
return {code: opRes.code, errmsg: opRes.errmsg};
}
}
}
}
return null;
}
/**
* If the response contains the 'writeErrors' field, replaces it with a field named
* 'truncatedWriteErrors' which includes only the first and last error object in 'writeErrors'.
* To be used for logging.
*/
function reformatResObjForLogging(resObj) {
if (resObj.writeErrors) {
let truncatedWriteErrors = [resObj.writeErrors[0]];
if (resObj.writeErrors.length > 1) {
truncatedWriteErrors.push(resObj.writeErrors[resObj.writeErrors.length - 1]);
}
resObj["truncatedWriteErrors"] = truncatedWriteErrors;
delete resObj.writeErrors;
}
}
/**
* If the command was a batch command where some of the operations failed, modifies the command
* object so that only failed operations are retried.
*/
function modifyCmdObjForRetry(cmdObj, resObj) {
if (!resObj.hasOwnProperty("writeErrors") && ErrorCodes.isTenantMigrationError(resObj.code)) {
// If we get a top level error without writeErrors, retry the entire command.
return;
}
if (cmdObj.insert) {
let retryOps = [];
if (cmdObj.ordered === false) {
for (let writeError of resObj.writeErrors) {
if (ErrorCodes.isTenantMigrationError(writeError.code)) {
retryOps.push(cmdObj.documents[writeError.index]);
}
}
} else {
retryOps = cmdObj.documents.slice(resObj.writeErrors[0].index);
}
cmdObj.documents = retryOps;
}
// findAndModify may also have an update field, but is not a batched command.
if (cmdObj.update && !cmdObj.findAndModify && !cmdObj.findandmodify) {
let retryOps = [];
if (cmdObj.ordered === false) {
for (let writeError of resObj.writeErrors) {
if (ErrorCodes.isTenantMigrationError(writeError.code)) {
retryOps.push(cmdObj.updates[writeError.index]);
}
}
} else {
retryOps = cmdObj.updates.slice(resObj.writeErrors[0].index);
}
cmdObj.updates = retryOps;
}
if (cmdObj.delete) {
let retryOps = [];
if (cmdObj.ordered === false) {
for (let writeError of resObj.writeErrors) {
if (ErrorCodes.isTenantMigrationError(writeError.code)) {
retryOps.push(cmdObj.deletes[writeError.index]);
}
}
} else {
retryOps = cmdObj.deletes.slice(resObj.writeErrors[0].index);
}
cmdObj.deletes = retryOps;
}
if (cmdObj.bulkWrite) {
let retryOps = [];
// For bulkWrite tenant migration errors always act as if they are executed as
// `ordered:true` meaning we will have to retry every op from the one that errored.
retryOps =
cmdObj.ops.slice(resObj.cursor.firstBatch[resObj.cursor.firstBatch.length - 1].idx);
cmdObj.ops = retryOps;
}
}
/**
* Sets the keys of the given index map to consecutive non-negative integers starting from 0.
*/
function resetIndices(indexMap) {
let newIndexMap = {};
Object.keys(indexMap).map((key, index) => {
newIndexMap[index] = indexMap[key];
});
return newIndexMap;
}
function toIndexSet(indexedDocs) {
let set = new Set();
if (indexedDocs) {
for (let doc of indexedDocs) {
set.add(doc.index);
}
}
return set;
}
/**
* Remove the indices for non-upsert writes that succeeded.
*/
function removeSuccessfulOpIndexesExceptForUpserted(resObj, indexMap, ordered) {
// Optimization to only look through the indices in a set rather than in an array.
let indexSetForUpserted = toIndexSet(resObj.upserted);
let indexSetForWriteErrors = toIndexSet(resObj.writeErrors);
for (let index in Object.keys(indexMap)) {
if ((!indexSetForUpserted.has(parseInt(index)) &&
!(ordered && resObj.writeErrors && (index > resObj.writeErrors[0].index)) &&
!indexSetForWriteErrors.has(parseInt(index)))) {
delete indexMap[index];
}
}
return indexMap;
}
/**
* Rewrites a server connection string (ex: rsName/host,host,host) to a URI that the shell can
* connect to.
*/
function convertServerConnectionStringToURI(input) {
const inputParts = input.split('/');
return `mongodb://${inputParts[1]}/?replicaSet=${inputParts[0]}`;
}
/**
* Returns the state document for the outgoing tenant migration or shard split operation. Asserts
* that there is only one such operation.
*/
function getOperationStateDocument(conn) {
const collection = isShardSplitPassthrough() ? "shardSplitDonors" : "tenantMigrationDonors";
let filter = {tenantId: TestData.tenantId};
if (usingMultipleTenants()) {
let tenantIds = [];
TestData.tenantIds.forEach(tenantId => tenantIds.push(ObjectId(tenantId)));
filter = {tenantIds: tenantIds};
}
const findRes = assert.commandWorked(
originalRunCommand.apply(conn, ["config", {find: collection, filter}, 0]));
const docs = findRes.cursor.firstBatch;
// There should only be one active migration at any given time.
assert.eq(docs.length, 1, tojson(docs));
const result = docs[0];
if (isShardSplitPassthrough()) {
result.recipientConnectionString =
convertServerConnectionStringToURI(result.recipientConnectionString);
}
return result;
}
/**
* Marks the outgoing tenant migration or shard split operation as having caused the shell to
* reroute commands by inserting a document for it into the testTenantMigration.rerouted collection
* or local.rerouted collection for the shard merge protocol.
*/
function recordRerouteDueToTenantMigration(conn, migrationStateDoc) {
assertRoutingConnection(conn);
const dbToCheck = TestData.useLocalDBForDBCheck ? localDB : testTenantMigrationDB;
while (true) {
try {
const res = originalRunCommand.apply(conn, [
dbToCheck,
{
insert: "rerouted",
documents: [{_id: migrationStateDoc._id}],
writeConcern: {w: "majority"}
},
0
]);
if (res.ok) {
break;
} else if (isRetryableError(res)) {
jsTest.log(
"Failed to write to testTenantMigration.rerouted due to a retryable error " +
tojson(res));
continue;
} else {
// Throw non-retryable errors.
assert.commandWorked(res);
}
} catch (e) {
// Since the shell can throw custom errors that don't propagate the error code, check
// these exceptions for specific network error messages.
// TODO SERVER-54026: Remove check for network error messages once the shell reliably
// returns error codes.
if (isRetryableError(e)) {
jsTest.log(
"Failed to write to testTenantMigration.rerouted due to a retryable error exception " +
tojson(e));
continue;
}
throw e;
}
}
}
/**
* Keeps executing 'cmdObjWithTenantId' by running 'originalRunCommandFunc' if 'this.reroutingMongo'
* is not none or 'reroutingRunCommandFunc' if it is none until the command succeeds or fails with
* errors other than TenantMigrationCommitted or TenantMigrationAborted. When the command fails
* with TenantMigrationCommitted, sets 'this.reroutingMongo' to the mongo connection to the
* recipient for the migration. 'dbNameWithTenantId' is only used for logging.
*/
function runCommandRetryOnTenantMigrationErrors(
conn, securityToken, dbNameWithTenantId, cmdObjWithTenantId, options) {
let numAttempts = 0;
// Keep track of the write operations that were applied.
let n = 0;
let nModified = 0;
let upserted = [];
let nonRetryableWriteErrors = [];
let bulkWriteResponse = {};
const isRetryableWrite =
cmdObjWithTenantId.txnNumber && !cmdObjWithTenantId.hasOwnProperty("autocommit");
// 'indexMap' is a mapping from a write's index in the current cmdObj to its index in the
// original cmdObj.
let indexMap = {};
if (cmdObjWithTenantId.documents) {
for (let i = 0; i < cmdObjWithTenantId.documents.length; i++) {
indexMap[i] = i;
}
}
if (cmdObjWithTenantId.updates) {
for (let i = 0; i < cmdObjWithTenantId.updates.length; i++) {
indexMap[i] = i;
}
}
if (cmdObjWithTenantId.deletes) {
for (let i = 0; i < cmdObjWithTenantId.deletes.length; i++) {
indexMap[i] = i;
}
}
while (true) {
numAttempts++;
const newConn = getRoutingConnection(conn);
if (securityToken) {
newConn._setSecurityToken(securityToken);
}
let resObj =
originalRunCommand.apply(newConn, [dbNameWithTenantId, cmdObjWithTenantId, options]);
const migrationCommittedErr =
extractTenantMigrationError(resObj, ErrorCodes.TenantMigrationCommitted);
const migrationAbortedErr =
extractTenantMigrationError(resObj, ErrorCodes.TenantMigrationAborted);
// If the write didn't encounter a TenantMigrationCommitted or TenantMigrationAborted error
// at all, return the result directly.
if (numAttempts == 1 && (!migrationCommittedErr && !migrationAbortedErr)) {
return resObj;
}
// Add/modify the shells's n, nModified, upserted, and writeErrors, unless this command is
// part of a retryable write.
if (!isRetryableWrite) {
// bulkWrite case.
if (cmdObjWithTenantId.bulkWrite) {
// First attempt store the whole response.
if (numAttempts == 1) {
bulkWriteResponse = resObj;
} else {
// The last item from the previous response is guaranteed to be a
// tenant migration error. Remove it to append the retried response.
let newIdxBase = bulkWriteResponse.cursor.firstBatch.pop().idx;
// Iterate over new response and change the indexes to start with newIdx.
for (let opRes of resObj.cursor.firstBatch) {
opRes.idx += newIdxBase;
}
// Add the new responses (with modified indexes) onto the original responses.
bulkWriteResponse.cursor.firstBatch =
bulkWriteResponse.cursor.firstBatch.concat(resObj.cursor.firstBatch);
// Add new numErrors onto old numErrors. Subtract one to account for the
// tenant migration error that was popped off.
bulkWriteResponse.nErrors += resObj.nErrors - 1;
bulkWriteResponse.nInserted += resObj.nInserted;
bulkWriteResponse.nDeleted += resObj.nDeleted;
bulkWriteResponse.nMatched += resObj.nMatched;
bulkWriteResponse.nModified += resObj.nModified;
bulkWriteResponse.nUpserted += resObj.nUpserted;
// Update clusterTime and operationTime to keep causal consistency.
bulkWriteResponse["$clusterTime"] = resObj["$clusterTime"];
bulkWriteResponse["operationTime"] = resObj["operationTime"];
}
}
if (resObj.n) {
n += resObj.n;
}
if (resObj.nModified) {
nModified += resObj.nModified;
}
if (resObj.upserted || resObj.writeErrors) {
// This is an optimization to make later lookups into 'indexMap' faster, since it
// removes any key that is not pertinent in the current cmdObj execution.
indexMap = removeSuccessfulOpIndexesExceptForUpserted(
resObj, indexMap, cmdObjWithTenantId.ordered);
if (resObj.upserted) {
for (let upsert of resObj.upserted) {
let currentUpsertedIndex = upsert.index;
// Set the entry's index to the write's index in the original cmdObj.
upsert.index = indexMap[upsert.index];
// Track that this write resulted in an upsert.
upserted.push(upsert);
// This write will not need to be retried, so remove it from 'indexMap'.
delete indexMap[currentUpsertedIndex];
}
}
if (resObj.writeErrors) {
for (let writeError of resObj.writeErrors) {
// If we encounter a TenantMigrationCommitted or TenantMigrationAborted
// error, the rest of the batch must have failed with the same code.
if (ErrorCodes.isTenantMigrationError(writeError.code)) {
break;
}
let currentWriteErrorIndex = writeError.index;
// Set the entry's index to the write's index in the original cmdObj.
writeError.index = indexMap[writeError.index];
// Track that this write resulted in a non-retryable error.
nonRetryableWriteErrors.push(writeError);
// This write will not need to be retried, so remove it from 'indexMap'.
delete indexMap[currentWriteErrorIndex];
}
}
}
}
if (migrationCommittedErr || migrationAbortedErr) {
// If the command was inside a transaction, skip modifying any objects or fields, since
// we will retry the entire transaction outside of this file.
if (!TransactionsUtil.isTransientTransactionError(resObj)) {
// Update the command for reroute/retry.
// In the case of retryable writes, we should always retry the entire batch of
// operations instead of modifying the original command object to only include
// failed writes.
if (!isRetryableWrite) {
modifyCmdObjForRetry(cmdObjWithTenantId, resObj, true);
}
// It is safe to reformat this resObj since it will not be returned to the caller of
// runCommand.
reformatResObjForLogging(resObj);
// Build a new indexMap where the keys are the index that each write that needs to
// be retried will have in the next attempt's cmdObj.
indexMap = resetIndices(indexMap);
}
if (migrationCommittedErr) {
jsTestLog(`Got TenantMigrationCommitted for command against database ${
dbNameWithTenantId} after trying ${numAttempts} times: ${tojson(resObj)}`);
// Store the connection to the recipient so the next commands can be rerouted.
const donorConnection = getRoutingConnection(conn);
const migrationStateDoc = getOperationStateDocument(donorConnection);
const otherConn = connect(migrationStateDoc.recipientConnectionString).getMongo();
if (conn.getAutoEncryptionOptions() !== undefined) {
otherConn.setAutoEncryption(conn.getAutoEncryptionOptions());
otherConn.toggleAutoEncryption(conn.isAutoEncryptionEnabled());
}
setRoutingConnection(conn, otherConn);
// After getting a TenantMigrationCommitted error, wait for the python test fixture
// to do a dbhash check on the donor and recipient primaries before we retry the
// command on the recipient.
const dbToCheck = TestData.useLocalDBForDBCheck ? localDB : testTenantMigrationDB;
assert.soon(() => {
let findRes = assert.commandWorked(originalRunCommand.apply(donorConnection, [
dbToCheck,
{
find: "dbhashCheck",
filter: {_id: migrationStateDoc._id},
},
0
]));
const docs = findRes.cursor.firstBatch;
return docs[0] != null;
});
recordRerouteDueToTenantMigration(donorConnection, migrationStateDoc);
if (isShardSplitPassthrough()) {
closeRoutingConnection(donorConnection);
}
} else if (migrationAbortedErr) {
jsTestLog(`Got TenantMigrationAborted for command against database ${
dbNameWithTenantId} after trying ${numAttempts} times: ` +
`${tojson(cmdObjWithTenantId)} -> ${tojson(resObj)}`);
}
// If the result has a TransientTransactionError label, the entire transaction must be
// retried. Return immediately to let the retry be handled by
// 'network_error_and_txn_override.js'.
if (TransactionsUtil.isTransientTransactionError(resObj)) {
jsTestLog(`Got error for transaction against database ` +
`${dbNameWithTenantId} with TransientTransactionError, retrying ` +
`transaction against recipient: ${tojson(resObj)}`);
return resObj;
}
} else {
if (!isRetryableWrite) {
// Modify the resObj before returning the result.
if (resObj.n) {
resObj.n = n;
}
if (resObj.nModified) {
resObj.nModified = nModified;
}
if (upserted.length > 0) {
resObj.upserted = upserted;
}
if (nonRetryableWriteErrors.length > 0) {
resObj.writeErrors = nonRetryableWriteErrors;
}
if (cmdObjWithTenantId.bulkWrite) {
resObj = bulkWriteResponse;
}
}
return resObj;
}
}
}
Mongo.prototype.runCommand = function(dbName, cmdObj, options) {
const useSecurityToken = !!TestData.useSecurityToken;
const useResponsePrefixChecking = !!TestData.useResponsePrefixChecking;
const tenantId = getTenantIdForDatabase(dbName);
const dbNameWithTenantId = prependTenantIdToDbNameIfApplicable(dbName, tenantId);
const securityToken = useSecurityToken
? _createTenantToken({tenant: ObjectId(tenantId), expectPrefix: true})
: undefined;
// If the command is already prefixed, just run it
if (isCmdObjWithTenantId(cmdObj)) {
return runCommandRetryOnTenantMigrationErrors(
this, securityToken, dbNameWithTenantId, cmdObj, options);
}
// Prepend a tenant prefix to all database names and namespaces, where applicable.
const cmdObjWithTenantId = createCmdObjWithTenantId(cmdObj, tenantId);
const resObj = runCommandRetryOnTenantMigrationErrors(
this, securityToken, dbNameWithTenantId, cmdObjWithTenantId, options);
// Remove the tenant prefix from all database names and namespaces in the result since tests
// assume the command was run against the original database.
const cmdName = Object.keys(cmdObj)[0];
let checkPrefixOptions = !useResponsePrefixChecking ? {} : {
checkPrefix: true,
expectPrefix: true,
tenantId,
dbName,
cmdName,
debugLog: "Failed to check tenant prefix in response : " + tojsononeline(resObj) +
". The request command obj is " + tojsononeline(cmdObjWithTenantId)
};
removeTenantIdAndMaybeCheckPrefixes(resObj, checkPrefixOptions);
return resObj;
};
Mongo.prototype.getDbNameWithTenantPrefix = function(dbName) {
const tenantId = getTenantIdForDatabase(dbName);
return prependTenantIdToDbNameIfApplicable(dbName, tenantId);
};
// Override base methods on the Mongo prototype to try to proxy the call to the underlying
// internal routing connection, if one exists.
// NOTE: This list is derived from scripting/mozjs/mongo.cpp:62.
['auth',
'cleanup',
'close',
'compact',
'getAutoEncryptionOptions',
'isAutoEncryptionEnabled',
'cursorHandleFromId',
'find',
'generateDataKey',
'getDataKeyCollection',
'logout',
'encrypt',
'decrypt',
'isReplicaSetConnection',
'_markNodeAsFailed',
'getMinWireVersion',
'getMaxWireVersion',
'isReplicaSetMember',
'isMongos',
'isTLS',
'getApiParameters',
'_startSession',
'_refreshAccessToken',
// Don't override this method, since it is never called directly in jstests. The expectation of is
// that it will be run on the connection `Mongo.prototype.runCommand` chose.
// '_runCommandImpl',
].forEach(methodName => {
const $method = Mongo.prototype[methodName];
Mongo.prototype[methodName] = function() {
return $method.apply(getRoutingConnection(this), arguments);
};
});
// The following methods are overridden so that the method applies to both
// the proxy connection and the underlying internal routing connection, if one exists.
['toggleAutoEncryption',
'unsetAutoEncryption',
'setAutoEncryption',
].forEach(methodName => {
const $method = Mongo.prototype[methodName];
Mongo.prototype[methodName] = function() {
let rc = getRoutingConnection(this);
if (rc !== this) {
$method.apply(rc, arguments);
}
return $method.apply(this, arguments);
};
});
OverrideHelpers.prependOverrideInParallelShell(
"jstests/libs/override_methods/simulate_atlas_proxy.js");