SERVER-114857 create sharded collection + reshard collection command (#45008)

Co-authored-by: Denis Grebennicov <denis.grebennicov@mongodb.com>
Co-authored-by: Jan <jsteemann@users.noreply.github.com>
GitOrigin-RevId: 2e777a54831a66a500e8fcb41d2b4dcc8d3a7c4f
This commit is contained in:
nicola cabiddu 2025-12-15 18:41:24 +00:00 committed by MongoDB Bot
parent 8febf70330
commit fa0ac8e778
7 changed files with 524 additions and 129 deletions

View File

@ -4,21 +4,24 @@
class Action {
static INSERT_DOC = 0;
static CREATE_DATABASE = 1;
static CREATE_SHARDED_COLLECTION = 2;
static CREATE_UNSPLITTABLE_COLLECTION = 3;
static CREATE_UNTRACKED_COLLECTION = 4;
static DROP_COLLECTION = 5;
static DROP_DATABASE = 6;
static RENAME_TO_NON_EXISTENT_SAME_DB = 7;
static RENAME_TO_EXISTENT_SAME_DB = 8;
static RENAME_TO_NON_EXISTENT_DIFFERENT_DB = 9;
static RENAME_TO_EXISTENT_DIFFERENT_DB = 10;
static SHARD_COLLECTION = 11;
static UNSHARD_COLLECTION = 12;
static RESHARD_COLLECTION = 13;
static MOVE_PRIMARY = 14;
static MOVE_COLLECTION = 15;
static MOVE_CHUNK = 16;
static CREATE_SHARDED_COLLECTION_RANGE = 2;
static CREATE_SHARDED_COLLECTION_HASHED = 3;
static CREATE_UNSPLITTABLE_COLLECTION = 4;
static CREATE_UNTRACKED_COLLECTION = 5;
static DROP_COLLECTION = 6;
static DROP_DATABASE = 7;
static RENAME_TO_NON_EXISTENT_SAME_DB = 8;
static RENAME_TO_EXISTENT_SAME_DB = 9;
static RENAME_TO_NON_EXISTENT_DIFFERENT_DB = 10;
static RENAME_TO_EXISTENT_DIFFERENT_DB = 11;
static SHARD_COLLECTION_RANGE = 12;
static SHARD_COLLECTION_HASHED = 13;
static UNSHARD_COLLECTION = 14;
static RESHARD_COLLECTION_TO_RANGE = 15;
static RESHARD_COLLECTION_TO_HASHED = 16;
static MOVE_PRIMARY = 17;
static MOVE_COLLECTION = 18;
static MOVE_CHUNK = 19;
static getName(actionId) {
switch (actionId) {
@ -26,8 +29,10 @@ class Action {
return "insert doc";
case Action.CREATE_DATABASE:
return "create database";
case Action.CREATE_SHARDED_COLLECTION:
return "create sharded collection";
case Action.CREATE_SHARDED_COLLECTION_RANGE:
return "create sharded collection (range)";
case Action.CREATE_SHARDED_COLLECTION_HASHED:
return "create sharded collection (hashed)";
case Action.CREATE_UNSPLITTABLE_COLLECTION:
return "create unsplittable collection";
case Action.CREATE_UNTRACKED_COLLECTION:
@ -44,12 +49,16 @@ class Action {
return "rename to non-existent collection different database";
case Action.RENAME_TO_EXISTENT_DIFFERENT_DB:
return "rename to existent collection different database";
case Action.SHARD_COLLECTION:
return "shard collection";
case Action.SHARD_COLLECTION_RANGE:
return "shard collection (range)";
case Action.SHARD_COLLECTION_HASHED:
return "shard collection (hashed)";
case Action.UNSHARD_COLLECTION:
return "unshard collection";
case Action.RESHARD_COLLECTION:
return "reshard collection";
case Action.RESHARD_COLLECTION_TO_RANGE:
return "reshard collection (range)";
case Action.RESHARD_COLLECTION_TO_HASHED:
return "reshard collection (hashed)";
case Action.MOVE_PRIMARY:
return "move primary";
case Action.MOVE_COLLECTION:
@ -60,6 +69,18 @@ class Action {
throw new Error(`Invalid action ID: ${actionId}`);
}
}
/**
* Get all action IDs.
* @returns {Array<number>} Array of all action IDs.
*/
static getAllActionIds() {
// Static class fields are not enumerable, so Object.values() won't work.
// Use getOwnPropertyNames and filter for numeric values.
return Object.getOwnPropertyNames(Action)
.map((name) => Action[name])
.filter((value) => typeof value === "number");
}
}
export {Action};

View File

@ -33,7 +33,8 @@ class CollectionTestModel {
const states = [
State.DATABASE_ABSENT,
State.DATABASE_PRESENT_COLLECTION_ABSENT,
State.COLLECTION_PRESENT_SHARDED,
State.COLLECTION_PRESENT_SHARDED_RANGE,
State.COLLECTION_PRESENT_SHARDED_HASHED,
State.COLLECTION_PRESENT_UNSPLITTABLE,
State.COLLECTION_PRESENT_UNTRACKED,
];
@ -51,25 +52,43 @@ class CollectionTestModel {
// ===== DATABASE_PRESENT_COLLECTION_ABSENT state transitions =====
this.setActions(State.DATABASE_PRESENT_COLLECTION_ABSENT, [
[Action.INSERT_DOC, State.COLLECTION_PRESENT_UNTRACKED],
[Action.CREATE_SHARDED_COLLECTION, State.COLLECTION_PRESENT_SHARDED], // TODO: SERVER-114857 - No-op for now
[Action.CREATE_SHARDED_COLLECTION_RANGE, State.COLLECTION_PRESENT_SHARDED_RANGE],
[Action.CREATE_SHARDED_COLLECTION_HASHED, State.COLLECTION_PRESENT_SHARDED_HASHED],
[Action.CREATE_UNSPLITTABLE_COLLECTION, State.COLLECTION_PRESENT_UNSPLITTABLE],
[Action.CREATE_UNTRACKED_COLLECTION, State.COLLECTION_PRESENT_UNTRACKED],
[Action.DROP_DATABASE, State.DATABASE_ABSENT],
[Action.MOVE_PRIMARY, State.DATABASE_PRESENT_COLLECTION_ABSENT],
]);
// ===== COLLECTION_PRESENT_SHARDED state transitions =====
this.setActions(State.COLLECTION_PRESENT_SHARDED, [
[Action.INSERT_DOC, State.COLLECTION_PRESENT_SHARDED],
// ===== COLLECTION_PRESENT_SHARDED_RANGE state transitions =====
this.setActions(State.COLLECTION_PRESENT_SHARDED_RANGE, [
[Action.INSERT_DOC, State.COLLECTION_PRESENT_SHARDED_RANGE],
[Action.DROP_COLLECTION, State.DATABASE_PRESENT_COLLECTION_ABSENT],
[Action.DROP_DATABASE, State.DATABASE_ABSENT],
[Action.RENAME_TO_NON_EXISTENT_SAME_DB, State.DATABASE_PRESENT_COLLECTION_ABSENT],
[Action.RENAME_TO_EXISTENT_SAME_DB, State.DATABASE_PRESENT_COLLECTION_ABSENT],
// Cross-database renames not supported for sharded collections.
[Action.UNSHARD_COLLECTION, State.COLLECTION_PRESENT_UNSPLITTABLE],
[Action.RESHARD_COLLECTION, State.COLLECTION_PRESENT_SHARDED], // TODO: SERVER-114857 - No-op for now
[Action.MOVE_PRIMARY, State.COLLECTION_PRESENT_SHARDED],
[Action.MOVE_CHUNK, State.COLLECTION_PRESENT_SHARDED],
[Action.RESHARD_COLLECTION_TO_RANGE, State.COLLECTION_PRESENT_SHARDED_RANGE],
[Action.RESHARD_COLLECTION_TO_HASHED, State.COLLECTION_PRESENT_SHARDED_HASHED],
[Action.MOVE_PRIMARY, State.COLLECTION_PRESENT_SHARDED_RANGE],
[Action.MOVE_CHUNK, State.COLLECTION_PRESENT_SHARDED_RANGE],
// MOVE_COLLECTION only works on unsharded collections.
]);
// ===== COLLECTION_PRESENT_SHARDED_HASHED state transitions =====
this.setActions(State.COLLECTION_PRESENT_SHARDED_HASHED, [
[Action.INSERT_DOC, State.COLLECTION_PRESENT_SHARDED_HASHED],
[Action.DROP_COLLECTION, State.DATABASE_PRESENT_COLLECTION_ABSENT],
[Action.DROP_DATABASE, State.DATABASE_ABSENT],
[Action.RENAME_TO_NON_EXISTENT_SAME_DB, State.DATABASE_PRESENT_COLLECTION_ABSENT],
[Action.RENAME_TO_EXISTENT_SAME_DB, State.DATABASE_PRESENT_COLLECTION_ABSENT],
// Cross-database renames not supported for sharded collections.
[Action.UNSHARD_COLLECTION, State.COLLECTION_PRESENT_UNSPLITTABLE],
[Action.RESHARD_COLLECTION_TO_RANGE, State.COLLECTION_PRESENT_SHARDED_RANGE],
[Action.RESHARD_COLLECTION_TO_HASHED, State.COLLECTION_PRESENT_SHARDED_HASHED],
[Action.MOVE_PRIMARY, State.COLLECTION_PRESENT_SHARDED_HASHED],
[Action.MOVE_CHUNK, State.COLLECTION_PRESENT_SHARDED_HASHED],
// MOVE_COLLECTION only works on unsharded collections.
]);
@ -81,7 +100,8 @@ class CollectionTestModel {
[Action.RENAME_TO_NON_EXISTENT_SAME_DB, State.DATABASE_PRESENT_COLLECTION_ABSENT],
[Action.RENAME_TO_EXISTENT_SAME_DB, State.DATABASE_PRESENT_COLLECTION_ABSENT],
// Cross-database renames not supported for tracked collections.
[Action.SHARD_COLLECTION, State.COLLECTION_PRESENT_SHARDED], // TODO: SERVER-114857 - No-op for now
[Action.SHARD_COLLECTION_RANGE, State.COLLECTION_PRESENT_SHARDED_RANGE],
[Action.SHARD_COLLECTION_HASHED, State.COLLECTION_PRESENT_SHARDED_HASHED],
[Action.MOVE_PRIMARY, State.COLLECTION_PRESENT_UNSPLITTABLE],
[Action.MOVE_COLLECTION, State.COLLECTION_PRESENT_UNSPLITTABLE],
]);
@ -94,7 +114,8 @@ class CollectionTestModel {
[Action.RENAME_TO_NON_EXISTENT_SAME_DB, State.DATABASE_PRESENT_COLLECTION_ABSENT],
[Action.RENAME_TO_EXISTENT_SAME_DB, State.DATABASE_PRESENT_COLLECTION_ABSENT],
// Cross-database renames require source and target on same shard.
[Action.SHARD_COLLECTION, State.COLLECTION_PRESENT_SHARDED], // TODO: SERVER-114857 - No-op for now
[Action.SHARD_COLLECTION_RANGE, State.COLLECTION_PRESENT_SHARDED_RANGE],
[Action.SHARD_COLLECTION_HASHED, State.COLLECTION_PRESENT_SHARDED_HASHED],
[Action.MOVE_PRIMARY, State.COLLECTION_PRESENT_UNTRACKED],
[Action.MOVE_COLLECTION, State.COLLECTION_PRESENT_UNTRACKED],
]);

View File

@ -5,6 +5,24 @@
* Note: Random is a global object provided by the MongoDB shell.
*/
/**
* Sharding type constants.
*/
const ShardingType = {
RANGE: "range",
HASHED: "hashed",
};
/**
* Get the shard key spec for a given sharding type.
* Uses 'data' field which requires explicit index creation before sharding.
* @param {string} shardingType - ShardingType.RANGE or ShardingType.HASHED
* @returns {Object} The shard key spec, e.g. {data: 1} or {data: "hashed"}
*/
function getShardKeySpec(shardingType) {
return shardingType === ShardingType.HASHED ? {data: "hashed"} : {data: 1};
}
/**
* Base command class.
*/
@ -45,8 +63,11 @@ class Command {
* The insertion behavior may differ depending on whether the collection is already created.
*/
class InsertDocCommand extends Command {
constructor(dbName, collName, shardSet) {
constructor(dbName, collName, shardSet, collectionCtx) {
super(dbName, collName, shardSet);
// Store context state needed for event matching
this.collectionExists = collectionCtx.exists;
this.collectionNonEmpty = collectionCtx.nonEmpty;
// Create the document in the constructor so it can be used by both execute() and getChangeEvents()
this.document = {
_id: new ObjectId(),
@ -144,32 +165,96 @@ class DropDatabaseCommand extends Command {
}
/**
* Shard collection command.
* TODO: SERVER-114857 - For now, we create a normal untracked collection instead of
* actually sharding it. This allows subsequent operations (rename, drop, etc.) to work
* on an existing collection. Full sharding setup will be addressed in SERVER-114857.
* Create index command.
* Creates an index for the shard key (required before sharding).
* Precondition (guaranteed by FSM): collection exists.
*/
class CreateShardedCollectionCommand extends Command {
class CreateIndexCommand extends Command {
constructor(dbName, collName, shardSet, collectionCtx) {
super(dbName, collName, shardSet);
assert(collectionCtx.shardKeySpec, "shardKeySpec must be provided to CreateIndexCommand");
this.shardKeySpec = collectionCtx.shardKeySpec;
}
execute(connection) {
// Create a normal untracked collection as a workaround
// TODO: SERVER-114857 - This should actually shard the collection
assert.commandWorked(connection.getDB(this.dbName).createCollection(this.collName));
const coll = connection.getDB(this.dbName).getCollection(this.collName);
assert.commandWorked(coll.createIndex(this.shardKeySpec));
}
toString() {
return "CreateShardedCollectionCommand";
return `CreateIndexCommand(${tojson(this.shardKeySpec)})`;
}
}
/**
* Drop index command.
* Drops the shard key index (cleanup after resharding).
* Preconditions (guaranteed by generator): collection exists, index exists.
*/
class DropIndexCommand extends Command {
constructor(dbName, collName, shardSet, collectionCtx) {
super(dbName, collName, shardSet);
assert(collectionCtx.shardKeySpec, "shardKeySpec must be provided to DropIndexCommand");
this.shardKeySpec = collectionCtx.shardKeySpec;
}
execute(connection) {
const coll = connection.getDB(this.dbName).getCollection(this.collName);
assert.commandWorked(coll.dropIndex(this.shardKeySpec));
}
toString() {
return `DropIndexCommand(${tojson(this.shardKeySpec)})`;
}
}
/**
* Shard existing collection command.
* Sharding type (range vs hashed) is determined by the shardKeySpec in collectionCtx.
* Preconditions (guaranteed by FSM): collection exists, shard key index exists.
*/
class ShardCollectionCommand extends Command {
constructor(dbName, collName, shardSet, collectionCtx) {
super(dbName, collName, shardSet);
assert(collectionCtx.shardKeySpec, "shardKeySpec must be provided to ShardCollectionCommand");
this.shardKeySpec = collectionCtx.shardKeySpec;
}
execute(connection) {
const ns = `${this.dbName}.${this.collName}`;
// Shard the collection
assert.commandWorked(
connection.adminCommand({
shardCollection: ns,
key: this.shardKeySpec,
}),
);
}
toString() {
const type = Object.values(this.shardKeySpec).some((v) => v === "hashed") ? "hashed" : "range";
return `ShardCollectionCommand(${type})`;
}
}
/**
* Unshard collection command.
* TODO: SERVER-114857 - This is a no-op simulation for testing.
* Since sharding is not actually set up, unsharding is also a no-op.
* Converts a sharded collection to an unsplittable (single-shard) collection.
* Picks a random shard as the destination.
* Precondition (guaranteed by FSM): collection exists and is sharded.
*/
class UnshardCollectionCommand extends Command {
execute(connection) {
// No-op: Unsharding simulation left blank since sharding is not actually set up.
// The state machine transition still occurs correctly.
assert(this.shardSet && this.shardSet.length > 0, "Shard set must be provided for UnshardCollectionCommand");
const targetShard = this.shardSet[Random.randInt(this.shardSet.length)];
const ns = `${this.dbName}.${this.collName}`;
assert.commandWorked(
connection.adminCommand({
unshardCollection: ns,
toShard: targetShard._id,
}),
);
}
toString() {
@ -178,16 +263,42 @@ class UnshardCollectionCommand extends Command {
}
/**
* Unified rename collection command.
* Handles all rename scenarios based on configuration.
* Reshard collection command.
* Resharding type (range vs hashed) is determined by shardKeySpec in collectionCtx.
* Precondition (guaranteed by FSM): collection exists and is sharded, index exists.
*/
class ReshardCollectionCommand extends Command {
constructor(dbName, collName, shardSet, collectionCtx) {
super(dbName, collName, shardSet);
assert(collectionCtx.shardKeySpec, "shardKeySpec must be provided to ReshardCollectionCommand");
this.shardKeySpec = collectionCtx.shardKeySpec;
}
execute(connection) {
const ns = `${this.dbName}.${this.collName}`;
// Use numInitialChunks: 1 to avoid cardinality errors in test environments with little data.
assert.commandWorked(
connection.adminCommand({
reshardCollection: ns,
key: this.shardKeySpec,
numInitialChunks: 1,
}),
);
}
toString() {
const type = Object.values(this.shardKeySpec).some((v) => v === "hashed") ? "hashed" : "range";
return `ReshardCollectionCommand(${type})`;
}
}
/**
* Base rename collection command.
* Subclasses define targetShouldExist and crossDatabase flags.
*/
class RenameCommand extends Command {
constructor(dbName, collName, shardSet, targetShouldExist, crossDatabase) {
super(dbName, collName, shardSet);
// targetShouldExist is kept for distinguishing command types but not used in execution
this.targetShouldExist = targetShouldExist;
this.crossDatabase = crossDatabase;
}
// Subclasses must set: this.targetShouldExist, this.crossDatabase
execute(connection) {
const targetDb = this.crossDatabase ? `${this.dbName}_target` : this.dbName;
@ -211,45 +322,36 @@ class RenameCommand extends Command {
}
}
// Concrete rename command classes for backward compatibility.
// Concrete rename command classes.
class RenameToNonExistentSameDbCommand extends RenameCommand {
constructor(dbName, collName, shardSet) {
super(dbName, collName, shardSet, false, false);
super(dbName, collName, shardSet);
this.targetShouldExist = false;
this.crossDatabase = false;
}
}
class RenameToExistentSameDbCommand extends RenameCommand {
constructor(dbName, collName, shardSet) {
super(dbName, collName, shardSet, true, false);
super(dbName, collName, shardSet);
this.targetShouldExist = true;
this.crossDatabase = false;
}
}
class RenameToNonExistentDifferentDbCommand extends RenameCommand {
constructor(dbName, collName, shardSet) {
super(dbName, collName, shardSet, false, true);
super(dbName, collName, shardSet);
this.targetShouldExist = false;
this.crossDatabase = true;
}
}
class RenameToExistentDifferentDbCommand extends RenameCommand {
constructor(dbName, collName, shardSet) {
super(dbName, collName, shardSet, true, true);
}
}
/**
* Reshard collection command.
* TODO: SERVER-114857 - This is a no-op simulation for testing.
* Resharding is complex and changes the shard key, which is not critical for
* state machine testing. The collection remains in the sharded state.
*/
class ReshardCollectionCommand extends Command {
execute(connection) {
// No-op: Resharding simulation left blank as it's not critical for state machine testing.
// The collection stays sharded, which is sufficient for the state machine model.
}
toString() {
return "ReshardCollectionCommand";
super(dbName, collName, shardSet);
this.targetShouldExist = true;
this.crossDatabase = true;
}
}
@ -278,7 +380,7 @@ class MoveCommandBase extends Command {
execute(connection) {
// No-op: Move operations are not critical for state machine testing since
// sharding is not actually set up (SERVER-114857).
// sharding is not actually set up.
// In a real implementation, this would execute:
// const targetShardId = this._getTargetShard(connection);
// const moveCommand = this._buildMoveCommand(targetShardId);
@ -366,20 +468,22 @@ export {
Command,
InsertDocCommand,
CreateDatabaseCommand,
CreateShardedCollectionCommand,
CreateIndexCommand,
DropIndexCommand,
ShardCollectionCommand,
CreateUnsplittableCollectionCommand,
CreateUntrackedCollectionCommand,
DropCollectionCommand,
DropDatabaseCommand,
RenameCommand,
RenameToNonExistentSameDbCommand,
RenameToExistentSameDbCommand,
RenameToNonExistentDifferentDbCommand,
RenameToExistentDifferentDbCommand,
UnshardCollectionCommand,
ReshardCollectionCommand,
MoveCommandBase,
MovePrimaryCommand,
MoveCollectionCommand,
MoveChunkCommand,
ShardingType,
getShardKeySpec,
};

View File

@ -4,16 +4,16 @@
* Requires an explicit seed to ensure deterministic behavior.
*/
import {Action} from "jstests/libs/util/change_stream/change_stream_action.js";
import {State} from "jstests/libs/util/change_stream/change_stream_state.js";
import {ShardingCommandGeneratorParams} from "jstests/libs/util/change_stream/change_stream_sharding_command_generator_params.js";
import {
InsertDocCommand,
CreateDatabaseCommand,
CreateShardedCollectionCommand,
ShardCollectionCommand,
CreateUnsplittableCollectionCommand,
CreateUntrackedCollectionCommand,
DropCollectionCommand,
DropDatabaseCommand,
RenameCommand,
RenameToNonExistentSameDbCommand,
RenameToExistentSameDbCommand,
RenameToNonExistentDifferentDbCommand,
@ -23,13 +23,23 @@ import {
MovePrimaryCommand,
MoveCollectionCommand,
MoveChunkCommand,
CreateIndexCommand,
DropIndexCommand,
ShardingType,
getShardKeySpec,
} from "jstests/libs/util/change_stream/change_stream_commands.js";
// Maps action IDs to command classes.
const actionToCommandClass = {
class ShardingCommandGenerator {
// Maps action IDs to command classes.
static actionToCommandClass = {
[Action.INSERT_DOC]: InsertDocCommand,
[Action.CREATE_DATABASE]: CreateDatabaseCommand,
[Action.CREATE_SHARDED_COLLECTION]: CreateShardedCollectionCommand,
[Action.CREATE_SHARDED_COLLECTION_RANGE]: ShardCollectionCommand,
[Action.CREATE_SHARDED_COLLECTION_HASHED]: ShardCollectionCommand,
[Action.SHARD_COLLECTION_RANGE]: ShardCollectionCommand,
[Action.SHARD_COLLECTION_HASHED]: ShardCollectionCommand,
[Action.RESHARD_COLLECTION_TO_RANGE]: ReshardCollectionCommand,
[Action.RESHARD_COLLECTION_TO_HASHED]: ReshardCollectionCommand,
[Action.CREATE_UNSPLITTABLE_COLLECTION]: CreateUnsplittableCollectionCommand,
[Action.CREATE_UNTRACKED_COLLECTION]: CreateUntrackedCollectionCommand,
[Action.DROP_COLLECTION]: DropCollectionCommand,
@ -38,15 +48,12 @@ const actionToCommandClass = {
[Action.RENAME_TO_EXISTENT_SAME_DB]: RenameToExistentSameDbCommand,
[Action.RENAME_TO_NON_EXISTENT_DIFFERENT_DB]: RenameToNonExistentDifferentDbCommand,
[Action.RENAME_TO_EXISTENT_DIFFERENT_DB]: RenameToExistentDifferentDbCommand,
[Action.SHARD_COLLECTION]: CreateShardedCollectionCommand,
[Action.UNSHARD_COLLECTION]: UnshardCollectionCommand,
[Action.RESHARD_COLLECTION]: ReshardCollectionCommand,
[Action.MOVE_PRIMARY]: MovePrimaryCommand,
[Action.MOVE_COLLECTION]: MoveCollectionCommand,
[Action.MOVE_CHUNK]: MoveChunkCommand,
};
};
class ShardingCommandGenerator {
constructor(seed) {
assert(seed !== null && seed !== undefined, "Seed must be explicitly provided to ShardingCommandGenerator");
this.seed = seed;
@ -60,12 +67,13 @@ class ShardingCommandGenerator {
* Create a command instance for a given action ID.
* @param {number} action - The action ID.
* @param {ShardingCommandGeneratorParams} params - The generator parameters.
* @param {Object} collectionCtx - The collection context with sharding config.
* @returns {Command} The command instance.
*/
createCommand(action, params) {
const CommandClass = actionToCommandClass[action];
createCommand(action, params, collectionCtx) {
const CommandClass = ShardingCommandGenerator.actionToCommandClass[action];
assert(CommandClass !== undefined, `No command class found for action ${action}`);
return new CommandClass(params.getDbName(), params.getCollName(), params.getShardSet());
return new CommandClass(params.getDbName(), params.getCollName(), params.getShardSet(), {...collectionCtx});
}
/**
@ -91,6 +99,7 @@ class ShardingCommandGenerator {
// Step 3: Select starting state.
let currentState = startState;
let collectionCtx = this._initialCollectionCtxForState(startState);
const commands = [];
// Step 4: While there are unvisited actions.
@ -98,8 +107,8 @@ class ShardingCommandGenerator {
// 4a: For each unvisited self-loop action (state → state), visit and mark as visited.
let selfLoopAction = this._getRandomUnvisitedSelfLoop(testModel, currentState, unvisitedActions);
while (selfLoopAction !== null) {
const command = this.createCommand(selfLoopAction, params);
commands.push(command);
this._appendAction(commands, selfLoopAction, params, collectionCtx);
this._updateCollectionCtxForAction(selfLoopAction, collectionCtx);
this._markActionAsVisited(unvisitedActions, currentState, selfLoopAction);
selfLoopAction = this._getRandomUnvisitedSelfLoop(testModel, currentState, unvisitedActions);
}
@ -107,8 +116,8 @@ class ShardingCommandGenerator {
// 4b: If exists unvisited non-self-loop action, visit it and move to target state.
const action = this._getRandomUnvisitedNonSelfLoop(testModel, currentState, unvisitedActions);
if (action !== null) {
const command = this.createCommand(action.action, params);
commands.push(command);
this._appendAction(commands, action.action, params, collectionCtx);
this._updateCollectionCtxForAction(action.action, collectionCtx);
this._markActionAsVisited(unvisitedActions, currentState, action.action);
currentState = action.to;
} else if (unvisitedActions.size > 0) {
@ -122,8 +131,8 @@ class ShardingCommandGenerator {
const path = shortestPaths.get(currentState).get(targetState).path;
for (const step of path) {
const command = this.createCommand(step.action, params);
commands.push(command);
this._appendAction(commands, step.action, params, collectionCtx);
this._updateCollectionCtxForAction(step.action, collectionCtx);
this._markActionAsVisited(unvisitedActions, step.from, step.action);
}
currentState = targetState;
@ -259,6 +268,181 @@ class ShardingCommandGenerator {
}
return nearest;
}
/**
* Create an empty collection context (collection does not exist).
*/
static _emptyCollectionCtx() {
return {
exists: false,
nonEmpty: false,
shardKeySpec: null,
existingIndexes: [],
};
}
/**
* Initialize collection context based on the starting state.
* collectionCtx tracks: exists, nonEmpty, shardKeySpec, existingIndexes.
* Note: Even if starting from a collection-present state, we assume the collection
* is empty (nonEmpty: false) since we don't know its actual contents.
*/
_initialCollectionCtxForState(state) {
const collectionPresent = [
State.COLLECTION_PRESENT_SHARDED_RANGE,
State.COLLECTION_PRESENT_SHARDED_HASHED,
State.COLLECTION_PRESENT_UNSPLITTABLE,
State.COLLECTION_PRESENT_UNTRACKED,
];
const ctx = ShardingCommandGenerator._emptyCollectionCtx();
ctx.exists = collectionPresent.includes(state);
return ctx;
}
/**
* Check if an index exists in the context.
*/
_indexExists(ctx, indexSpec) {
return ctx.existingIndexes.some((idx) => bsonWoCompare(idx, indexSpec) === 0);
}
/**
* Add an index to the context's existing indexes.
*/
_addIndex(ctx, indexSpec) {
if (!this._indexExists(ctx, indexSpec)) {
ctx.existingIndexes.push(indexSpec);
}
}
/**
* Update collection context after executing an action.
* Sharding type is determined by the action itself.
*/
_updateCollectionCtxForAction(action, ctx) {
switch (action) {
case Action.INSERT_DOC:
ctx.exists = true;
ctx.nonEmpty = true;
break;
case Action.CREATE_SHARDED_COLLECTION_RANGE:
case Action.SHARD_COLLECTION_RANGE:
ctx.exists = true;
ctx.shardKeySpec = getShardKeySpec(ShardingType.RANGE);
this._addIndex(ctx, getShardKeySpec(ShardingType.RANGE));
break;
case Action.CREATE_SHARDED_COLLECTION_HASHED:
case Action.SHARD_COLLECTION_HASHED:
ctx.exists = true;
ctx.shardKeySpec = getShardKeySpec(ShardingType.HASHED);
this._addIndex(ctx, getShardKeySpec(ShardingType.HASHED));
break;
case Action.RESHARD_COLLECTION_TO_RANGE:
case Action.RESHARD_COLLECTION_TO_HASHED: {
// Remove old shard key index, add new one
assert(ctx.shardKeySpec, "Reshard requires existing shard key");
ctx.existingIndexes = ctx.existingIndexes.filter((idx) => bsonWoCompare(idx, ctx.shardKeySpec) !== 0);
const newShardKeySpec =
action === Action.RESHARD_COLLECTION_TO_RANGE
? getShardKeySpec(ShardingType.RANGE)
: getShardKeySpec(ShardingType.HASHED);
ctx.shardKeySpec = newShardKeySpec;
this._addIndex(ctx, newShardKeySpec);
break;
}
case Action.CREATE_UNSPLITTABLE_COLLECTION:
case Action.CREATE_UNTRACKED_COLLECTION:
ctx.exists = true;
ctx.nonEmpty = false;
break;
case Action.UNSHARD_COLLECTION:
// Collection becomes unsplittable (single-shard) but still exists.
// Shard key and indexes are preserved.
break;
case Action.DROP_COLLECTION:
case Action.DROP_DATABASE:
case Action.RENAME_TO_NON_EXISTENT_SAME_DB:
case Action.RENAME_TO_EXISTENT_SAME_DB:
case Action.RENAME_TO_NON_EXISTENT_DIFFERENT_DB:
case Action.RENAME_TO_EXISTENT_DIFFERENT_DB:
// Collection no longer exists (dropped or renamed away).
Object.assign(ctx, ShardingCommandGenerator._emptyCollectionCtx());
break;
default:
// Other actions do not modify collection context.
break;
}
}
// Maps actions to their sharding type (for determining shard key spec)
static actionToShardingType = {
[Action.CREATE_SHARDED_COLLECTION_RANGE]: ShardingType.RANGE,
[Action.SHARD_COLLECTION_RANGE]: ShardingType.RANGE,
[Action.RESHARD_COLLECTION_TO_RANGE]: ShardingType.RANGE,
[Action.CREATE_SHARDED_COLLECTION_HASHED]: ShardingType.HASHED,
[Action.SHARD_COLLECTION_HASHED]: ShardingType.HASHED,
[Action.RESHARD_COLLECTION_TO_HASHED]: ShardingType.HASHED,
};
// Actions that require shard key index to exist before execution
static actionsRequiringIndex = new Set([
Action.SHARD_COLLECTION_RANGE,
Action.SHARD_COLLECTION_HASHED,
Action.RESHARD_COLLECTION_TO_RANGE,
Action.RESHARD_COLLECTION_TO_HASHED,
]);
// Actions that require dropping the old shard key index after execution
static actionsRequiringIndexCleanup = new Set([
Action.RESHARD_COLLECTION_TO_RANGE,
Action.RESHARD_COLLECTION_TO_HASHED,
]);
/**
* Append the command(s) for a given action.
* Some actions require prerequisite commands (e.g., index creation before sharding).
*/
_appendAction(commands, action, params, collectionCtx) {
// Step 1: Determine target shard key spec from action mapping.
const shardingType = ShardingCommandGenerator.actionToShardingType[action];
const targetShardKeySpec = shardingType ? getShardKeySpec(shardingType) : null;
// Step 2: Build collection context copy with appropriate shard key spec.
const ctx = {...collectionCtx};
if (targetShardKeySpec) {
ctx.shardKeySpec = targetShardKeySpec;
}
// Step 3: Pre-commands
// Create shard key index if action requires it and index doesn't exist
if (
ShardingCommandGenerator.actionsRequiringIndex.has(action) &&
!this._indexExists(collectionCtx, targetShardKeySpec)
) {
commands.push(new CreateIndexCommand(params.getDbName(), params.getCollName(), params.getShardSet(), ctx));
}
// Drop collection before dropping database (simplifies change event matching)
if (action === Action.DROP_DATABASE && collectionCtx.exists) {
commands.push(
new DropCollectionCommand(params.getDbName(), params.getCollName(), params.getShardSet(), ctx),
);
}
// Step 4: Main command
commands.push(this.createCommand(action, params, ctx));
// Step 5: Post-commands - drop old shard key index after resharding (only if key changed)
if (ShardingCommandGenerator.actionsRequiringIndexCleanup.has(action)) {
const oldShardKeySpec = collectionCtx.shardKeySpec;
// Only drop old index if it's different from the new one
if (bsonWoCompare(oldShardKeySpec, targetShardKeySpec) !== 0) {
const dropCtx = {...ctx, shardKeySpec: oldShardKeySpec};
commands.push(
new DropIndexCommand(params.getDbName(), params.getCollName(), params.getShardSet(), dropCtx),
);
}
}
}
}
export {ShardingCommandGenerator};

View File

@ -1,34 +1,27 @@
/**
* Parameters for ShardingCommandGenerator.
* Encapsulates database, collection, and shard information.
* Simple struct encapsulating database, collection, and shard information.
*/
class ShardingCommandGeneratorParams {
/**
* @param {string} dbName - Database name
* @param {string} collName - Collection name
* @param {Array} shardSet - Array of shard objects
*/
constructor(dbName, collName, shardSet) {
this.dbName = dbName;
this.collName = collName;
this.shardSet = shardSet;
}
/**
* Get the database name.
* @returns {string} The database name.
*/
getDbName() {
return this.dbName;
}
/**
* Get the collection name.
* @returns {string} The collection name.
*/
getCollName() {
return this.collName;
}
/**
* Get the shard set.
* @returns {Array} Array of shard objects.
*/
getShardSet() {
return this.shardSet;
}

View File

@ -4,9 +4,10 @@
class State {
static DATABASE_ABSENT = 0;
static DATABASE_PRESENT_COLLECTION_ABSENT = 1;
static COLLECTION_PRESENT_SHARDED = 2;
static COLLECTION_PRESENT_UNSPLITTABLE = 3;
static COLLECTION_PRESENT_UNTRACKED = 4;
static COLLECTION_PRESENT_SHARDED_RANGE = 2;
static COLLECTION_PRESENT_SHARDED_HASHED = 3;
static COLLECTION_PRESENT_UNSPLITTABLE = 4;
static COLLECTION_PRESENT_UNTRACKED = 5;
static getName(stateId) {
switch (stateId) {
@ -14,8 +15,10 @@ class State {
return "DatabaseAbsent";
case State.DATABASE_PRESENT_COLLECTION_ABSENT:
return "DatabasePresent::CollectionAbsent";
case State.COLLECTION_PRESENT_SHARDED:
return "CollectionPresent::ShardedCollection";
case State.COLLECTION_PRESENT_SHARDED_RANGE:
return "CollectionPresent::ShardedCollection(Range)";
case State.COLLECTION_PRESENT_SHARDED_HASHED:
return "CollectionPresent::ShardedCollection(Hashed)";
case State.COLLECTION_PRESENT_UNSPLITTABLE:
return "CollectionPresent::UnsplittableCollection";
case State.COLLECTION_PRESENT_UNTRACKED:

View File

@ -5,6 +5,7 @@
*
* @tags: [uses_change_streams]
*/
import {Action} from "jstests/libs/util/change_stream/change_stream_action.js";
import {CollectionTestModel} from "jstests/libs/util/change_stream/change_stream_collection_test_model.js";
import {ShardingCommandGenerator} from "jstests/libs/util/change_stream/change_stream_sharding_command_generator.js";
import {ShardingCommandGeneratorParams} from "jstests/libs/util/change_stream/change_stream_sharding_command_generator_params.js";
@ -160,6 +161,74 @@ describe("ShardingCommandGenerator", function () {
jsTest.log.info("✓ Sequential multi-Writer test passed");
});
it("runs the graph mutator and exercises all FSM transitions", () => {
const dbName = "test_db_sharding";
const collName = "test_coll_sharding";
const seed = 314159;
const db = this.st.s.getDB(dbName);
db.dropDatabase();
const model = new CollectionTestModel().setStartState(State.DATABASE_ABSENT);
const params = new ShardingCommandGeneratorParams(dbName, collName, this.shards);
const generator = new ShardingCommandGenerator(seed);
const commands = generator.generateCommands(model, params);
jsTest.log.info(`\n========== Graph mutator - Full FSM traversal ==========`);
jsTest.log.info(`Seed: ${seed}, DB: ${dbName}, Coll: ${collName}`);
jsTest.log.info(`Total commands: ${commands.length}`);
jsTest.log.info(`Command list:`);
commands.forEach((cmd, idx) => {
jsTest.log.info(` [${idx}] ${cmd.toString()}`);
});
// Build set of command strings present in the generated sequence.
const commandStrings = commands.map((c) => c.toString());
// Collect all unique actions from the FSM model.
const allActionsInFsm = new Set();
for (const state of model.states) {
for (const action of model.collectionStateToActionsMap(state).keys()) {
allActionsInFsm.add(action);
}
}
// Derive expected command patterns from the Action enum (single source of truth).
jsTest.log.info(`\n--- Command coverage verification ---`);
const missingCommands = [];
for (const actionId of allActionsInFsm) {
const commandClass = ShardingCommandGenerator.actionToCommandClass[actionId];
assert(commandClass, `No command class mapped for action ${actionId}`);
const commandClassName = commandClass.name;
const actionName = Action.getName(actionId);
const found = commandStrings.some((s) => s.includes(commandClassName));
jsTest.log.info(` ${actionName}: ${found ? "✓" : "✗"}`);
if (!found) {
missingCommands.push(actionName);
}
}
// Assert all FSM actions produced their expected commands.
assert.eq(
missingCommands.length,
0,
`Missing command types: ${missingCommands.join(", ")}. All FSM actions must be covered.`,
);
jsTest.log.info(` Total FSM actions: ${allActionsInFsm.size}`);
jsTest.log.info(` Commands generated: ${commands.length}`);
jsTest.log.info(`✓ All FSM actions produced expected commands`);
jsTest.log.info(`\n========== Executing commands ==========`);
commands.forEach((cmd, cmdIdx) => {
jsTest.log.info(`Executing [${cmdIdx}]: ${cmd.toString()}`);
cmd.execute(this.st.s);
});
jsTest.log.info(`✓ All ${commands.length} commands executed successfully`);
});
});
describe("ChangeEventMatcher helpers", function () {