/**
 * TwoPhaseDropCollectionTest is a library for testing two phase collection drops in a replica set.
 *
 * External tests can utilize this library to verify various aspects of the two phase collection
 * drop behavior. It provides a way to easily create a replicated collection and control its
 * transition between the 'PREPARE' and 'COMMIT' phase of a collection drop.
 *
 * Details:
 *
 * The test uses a 2 node replica set to verify both phases of a 2-phase collection drop: the
 * 'Prepare' and 'Commit' phase. Executing a 'drop' collection command should put that collection
 * into the 'Prepare' phase. The 'Commit' phase (physically dropping the collection) of a drop
 * operation with optime T should only be executed when C >= T, where C is the majority commit point
 * of the replica set.
 *
 */
"use strict";

load("jstests/libs/fail_point_util.js");
load("jstests/libs/fixture_helpers.js");      // For 'FixtureHelpers'.
load("jstests/aggregation/extras/utils.js");  // For 'arrayEq'.

class TwoPhaseDropCollectionTest {
    constructor(testName, dbName) {
        this.testName = testName;
        this.dbName = dbName;

        this.oplogApplicationFailpoint = "rsSyncApplyStop";
    }

    /**
     * Log a message for 'TwoPhaseDropCollectionTest'.
     */
    static _testLog(msg) {
        jsTestLog("[TwoPhaseDropCollectionTest] " + msg);
    }

    /**
     * Returns true if the replica set supports two phase drops that use 'system.drop' namespaces.
     * Since 4.2, 'system.drop' drop pending collections will be disabled on storage engines that
     * support drop pending idents natively. See serverStatus().storageEngine.supportsPendingDrops.
     */
    static supportsDropPendingNamespaces(replSetTest) {
        const primaryDB = replSetTest.getPrimary().getDB('admin');
        const serverStatus = assert.commandWorked(primaryDB.serverStatus());
        const storageEngineSection = serverStatus.storageEngine;
        TwoPhaseDropCollectionTest._testLog('Storage engine features (from serverStatus()): ' +
                                            tojson(storageEngineSection));
        return !storageEngineSection.supportsPendingDrops;
    }

    /**
     * Instance method version of supportsDropPendingNamespaces().
     */
    supportsDropPendingNamespaces() {
        return TwoPhaseDropCollectionTest.supportsDropPendingNamespaces(this.replTest);
    }

    /**
     * Pause oplog application on a specified node.
     */
    pauseOplogApplication(node) {
        let failPoint = configureFailPoint(node, this.oplogApplicationFailpoint);
        failPoint.wait();
    }

    /**
     * Resume oplog application on a specified node.
     */
    resumeOplogApplication(node) {
        assert.commandWorked(
            node.adminCommand({configureFailPoint: this.oplogApplicationFailpoint, mode: "off"}));
    }

    /**
     * Return a list of all collections in a given database. Use 'args' as the 'listCollections'
     * command arguments.
     */
    static listCollections(database, args) {
        args = args || {};
        let failMsg = "'listCollections' command failed";
        let res = assert.commandWorked(database.runCommand("listCollections", args), failMsg);
        return res.cursor.firstBatch;
    }

    /**
     * Waits for all collections pending drop to be completely dropped on the given connection.
     */
    static waitForAllCollectionDropsToComplete(conn) {
        assert.soon(function() {
            const dbNames = conn.getDBNames();
            for (let dbName of dbNames) {
                const currDB = conn.getDB(dbName);
                let collectionsWithPending =
                    TwoPhaseDropCollectionTest.listCollections(currDB, {includePendingDrops: true});
                let collectionsNoPending = TwoPhaseDropCollectionTest.listCollections(currDB);
                if (!arrayEq(collectionsWithPending, collectionsNoPending)) {
                    // Do a write on the primary to ensure that the commit point advances.
                    let cmd = {
                        appendOplogNote: 1,
                        data: {id: "waitForAllCollectionDropsToCompleteHelper"}
                    };
                    FixtureHelpers.runCommandOnEachPrimary({db: conn.getDB("admin"), cmdObj: cmd});
                    return false;
                }
            }
            return true;
        }, "Not all collection drops completed on " + conn.host);
    }

    /**
     * Return a list of all collection names in a given database.
     */
    listCollectionNames(database, args) {
        return TwoPhaseDropCollectionTest.listCollections(database, args).map(c => c.name);
    }

    /**
     * Initiates a 2 node replica set to be used for the test. Returns the constructed ReplSetTest.
     */
    initReplSet() {
        let nodes = [{}, {rsConfig: {priority: 0}}];
        this.replTest = new ReplSetTest({name: this.testName, nodes: nodes});

        // Initiate the replica set.
        this.replTest.startSet();
        this.replTest.initiate();

        // The default WC is majority and rsSyncApplyStop failpoint will prevent satisfying any
        // majority writes.
        assert.commandWorked(this.replTest.getPrimary().adminCommand(
            {setDefaultRWConcern: 1, defaultWriteConcern: {w: 1}, writeConcern: {w: "majority"}}));
        this.replTest.awaitReplication();

        return this.replTest;
    }

    /**
     * Creates a collection with name 'collName' in the test database and then awaits replication.
     */
    createCollection(collName) {
        // Create the collection that will be dropped and let it replicate.
        let primaryDB = this.replTest.getPrimary().getDB(this.dbName);
        assert.commandWorked(primaryDB.createCollection(collName));
        this.replTest.awaitReplication();
    }

    /**
     * Return a regex matching a drop-pending namespace string for a collection with name
     * 'collName'.
     *
     * Drop pending names should be of the format "system.drop.<optime>.<collectionName>", where
     * 'optime' is the optime of the collection drop operation, encoded as a string, and
     * 'collectionName' is the original collection name.
     */
    static pendingDropRegex(collName) {
        return new RegExp("system\\.drop\\..*\\." + collName + "$");
    }

    /**
     * Returns true if the collection 'collName' exists on the primary.
     */
    collectionExists(collName) {
        let primaryDB = this.replTest.getPrimary().getDB(this.dbName);
        let coll =
            TwoPhaseDropCollectionTest.listCollections(primaryDB).find(c => c.name === collName);
        return coll !== undefined;
    }

    /**
     * If 'collName' is in drop pending state on the primary, returns the name of the collection
     * after drop pending rename. If collection is not in drop pending state, returns false.
     */
    collectionIsPendingDrop(collName) {
        let primaryDB = this.replTest.getPrimary().getDB(this.dbName);
        return TwoPhaseDropCollectionTest.collectionIsPendingDropInDatabase(primaryDB, collName);
    }

    /**
     * If 'collName' in database 'db' is in drop pending state on the primary, returns the name
     * of the collection after drop pending rename. If collection is not in drop pending state,
     * returns false.
     */
    static collectionIsPendingDropInDatabase(db, collName) {
        let collections =
            TwoPhaseDropCollectionTest.listCollections(db, {includePendingDrops: true});

        TwoPhaseDropCollectionTest._testLog("Checking presence of drop-pending collection for " +
                                            collName +
                                            " in the collection list: " + tojson(collections));

        let pendingDropRegex = TwoPhaseDropCollectionTest.pendingDropRegex(collName);
        return collections.find(c => pendingDropRegex.test(c.name));
    }

    /**
     * Waits until 'collName' in database 'db' is not in drop pending state.
     */
    static waitForDropToComplete(db, collName) {
        assert.soon(
            () => !TwoPhaseDropCollectionTest.collectionIsPendingDropInDatabase(db, collName));
    }

    /**
     * Puts a collection with name 'collName' into the drop pending state. Returns the name of the
     * collection after it has been renamed to the 'system.drop' namespace.
     */
    prepareDropCollection(collName) {
        let primaryDB = this.replTest.getPrimary().getDB(this.dbName);

        // Pause application on secondary so that commit point doesn't advance, meaning that a
        // dropped collection on the primary will remain in 'drop-pending' state.
        TwoPhaseDropCollectionTest._testLog("Pausing oplog application on the secondary node.");
        this.pauseOplogApplication(this.replTest.getSecondary());

        // Drop the collection on the primary.
        TwoPhaseDropCollectionTest._testLog("Dropping collection '" + collName +
                                            "' on primary node.");
        assert.commandWorked(primaryDB.runCommand({drop: collName, writeConcern: {w: 1}}));

        // Make sure the collection doesn't appear in the normal collection list and that it is now
        // in 'drop-pending' state.
        assert(!this.collectionExists(collName));
        let droppedColl = this.collectionIsPendingDrop(collName);

        assert(
            droppedColl,
            "Dropped collection '" + collName + "' was not found in the 'system.drop' namespace");

        return droppedColl.name;
    }

    /**
     * Restarts oplog application on the secondary and waits for the drop of collection 'collName'
     * to be committed (physically dropped).
     */
    commitDropCollection(collName) {
        // Let the secondary apply the collection drop operation, so that the replica set commit
        // point will advance, and the 'Commit' phase of the collection drop will complete on the
        // primary.
        TwoPhaseDropCollectionTest._testLog("Restarting oplog application on the secondary node.");
        this.resumeOplogApplication(this.replTest.getSecondary());

        TwoPhaseDropCollectionTest._testLog(
            "Waiting for collection drop operation to replicate to all nodes.");
        this.replTest.awaitReplication();

        // Make sure the collection has been fully dropped. It should not appear as a normal
        // collection or under the 'system.drop' namespace any longer. Physical collection drops may
        // happen asynchronously, any time after the drop operation is committed, so we wait to make
        // sure the collection is eventually dropped.
        TwoPhaseDropCollectionTest._testLog("Waiting for collection drop of '" + collName +
                                            "' to commit.");
        // Bind the member functions onto this instead of the anonymous function.
        const twoPhaseDrop = this;
        assert.soonNoExcept(function() {
            assert(!twoPhaseDrop.collectionExists(collName));
            assert(!twoPhaseDrop.collectionIsPendingDrop(collName));
            return true;
        });
    }

    /**
     * Disable all fail points and shut down the replica set.
     */
    stop() {
        TwoPhaseDropCollectionTest._testLog("Disabling fail points and shutting down replica set.");
        this.resumeOplogApplication(this.replTest.getSecondary());
        this.replTest.stopSet();
    }
}
